diff --git a/extensions/diffs/src/store.test.ts b/extensions/diffs/src/store.test.ts index b1a57af1982..25ed6a608f4 100644 --- a/extensions/diffs/src/store.test.ts +++ b/extensions/diffs/src/store.test.ts @@ -2,21 +2,25 @@ import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import path from "node:path"; import { resetPluginBlobStoreForTests } from "openclaw/plugin-sdk/plugin-state-runtime"; +import type { PluginBlobStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { createMockServerResponse } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; +import type { DiffBlobMetadata } from "./store.js"; import { createDiffStoreHarness } from "./test-helpers.js"; describe("DiffArtifactStore", () => { let rootDir: string; let store: DiffArtifactStore; + let blobStore: PluginBlobStore; let cleanupRootDir: () => Promise; beforeEach(async () => { ({ rootDir, store, + blobStore, cleanup: cleanupRootDir, } = await createDiffStoreHarness("openclaw-diffs-store-")); }); @@ -92,6 +96,26 @@ describe("DiffArtifactStore", () => { expect(loaded).toBeNull(); }); + it("sweeps expired SQLite-only viewer artifacts during cleanup", async () => { + vi.useFakeTimers(); + const now = new Date("2026-02-27T16:00:00Z"); + vi.setSystemTime(now); + + const artifact = await store.createArtifact({ + html: "sqlite", + title: "SQLite", + inputKind: "patch", + fileCount: 1, + ttlMs: 1_000, + }); + + vi.setSystemTime(new Date(now.getTime() + 2_000)); + await store.cleanupExpired(); + + expect(await blobStore.deleteExpired()).toBe(0); + await expect(blobStore.lookup(`view:${artifact.id}`)).resolves.toBeUndefined(); + }); + it("updates the stored file path", async () => { const artifact = await store.createArtifact({ html: "demo", diff --git a/extensions/diffs/src/store.ts b/extensions/diffs/src/store.ts index e9f959dceda..f613e2a0893 100644 --- a/extensions/diffs/src/store.ts +++ b/extensions/diffs/src/store.ts @@ -188,6 +188,8 @@ export class DiffArtifactStore { } async cleanupExpired(): Promise { + await this.blobStore.deleteExpired(); + const root = await this.artifactRoot(); const entries = await root.list("", { withFileTypes: true }).catch(() => []); diff --git a/extensions/diffs/src/test-helpers.ts b/extensions/diffs/src/test-helpers.ts index 6cbaf0cee14..f80d5fd2569 100644 --- a/extensions/diffs/src/test-helpers.ts +++ b/extensions/diffs/src/test-helpers.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createPluginBlobStore, + type PluginBlobStore, resetPluginBlobStoreForTests, } from "openclaw/plugin-sdk/plugin-state-runtime"; import { resolvePreferredOpenClawTmpDir } from "../api.js"; @@ -28,22 +29,25 @@ export async function createDiffStoreHarness( ): Promise<{ rootDir: string; store: DiffArtifactStore; + blobStore: PluginBlobStore; cleanup: () => Promise; }> { const { rootDir, cleanup } = await createTempDiffRoot(prefix); const originalStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = await fs.mkdtemp(path.join(rootDir, "state-")); resetPluginBlobStoreForTests(); + const blobStore = createPluginBlobStore("diffs", { + namespace: "artifacts", + maxEntries: MAX_TEST_DIFF_ARTIFACT_BLOBS, + }); return { rootDir, store: new DiffArtifactStore({ rootDir, cleanupIntervalMs: options.cleanupIntervalMs, - blobStore: createPluginBlobStore("diffs", { - namespace: "artifacts", - maxEntries: MAX_TEST_DIFF_ARTIFACT_BLOBS, - }), + blobStore, }), + blobStore, cleanup: async () => { if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; diff --git a/src/plugin-state/plugin-blob-store.test.ts b/src/plugin-state/plugin-blob-store.test.ts index 8a4d6ac5f7f..7284986b367 100644 --- a/src/plugin-state/plugin-blob-store.test.ts +++ b/src/plugin-state/plugin-blob-store.test.ts @@ -30,4 +30,35 @@ describe("plugin blob store", () => { await expect(store.entries()).resolves.toEqual([]); }); }); + + it("deletes expired entries for the current namespace", async () => { + await withOpenClawTestState({ label: "plugin-blob-store-expired" }, async () => { + const store = createPluginBlobStore<{ contentType: string }>("zalo", { + namespace: "media", + maxEntries: 10, + }); + const otherNamespace = createPluginBlobStore<{ contentType: string }>("zalo", { + namespace: "other-media", + maxEntries: 10, + }); + + await store.register("live", { contentType: "image/jpeg" }, Buffer.from("live")); + await store.register("expired", { contentType: "image/png" }, Buffer.from("expired"), { + ttlMs: 1, + }); + await otherNamespace.register("expired", { contentType: "image/gif" }, Buffer.from("other"), { + ttlMs: 1, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + + await expect(store.deleteExpired()).resolves.toBe(1); + await expect(store.entries()).resolves.toMatchObject([ + { + key: "live", + metadata: { contentType: "image/jpeg" }, + }, + ]); + await expect(otherNamespace.deleteExpired()).resolves.toBe(1); + }); + }); }); diff --git a/src/plugin-state/plugin-blob-store.ts b/src/plugin-state/plugin-blob-store.ts index 09926c4849a..e7008bfe68b 100644 --- a/src/plugin-state/plugin-blob-store.ts +++ b/src/plugin-state/plugin-blob-store.ts @@ -30,6 +30,7 @@ export type PluginBlobStore> = { lookup(key: string): Promise | undefined>; consume(key: string): Promise | undefined>; delete(key: string): Promise; + deleteExpired(): Promise; entries(): Promise[]>; clear(): Promise; }; @@ -39,6 +40,7 @@ export type PluginBlobSyncStore> = { lookup(key: string): PluginBlobEntry | undefined; consume(key: string): PluginBlobEntry | undefined; delete(key: string): boolean; + deleteExpired(): number; entries(): PluginBlobEntry[]; clear(): void; }; @@ -173,6 +175,9 @@ export function createPluginBlobStore>( async delete(key) { return syncStore.delete(key); }, + async deleteExpired() { + return syncStore.deleteExpired(); + }, async entries() { return syncStore.entries(); }, @@ -341,6 +346,23 @@ export function createPluginBlobSyncStore>( ); return Number(result.numAffectedRows ?? 0) > 0; }, + deleteExpired() { + const expiredAt = now(); + const result = runOpenClawStateWriteTransaction( + (database) => + executeSqliteQuerySync( + database.db, + getPluginBlobKysely(database.db) + .deleteFrom("plugin_blob_entries") + .where("plugin_id", "=", pluginId) + .where("namespace", "=", namespace) + .where("expires_at", "is not", null) + .where("expires_at", "<=", expiredAt), + ), + databaseOptions, + ); + return Number(result.numAffectedRows ?? 0); + }, entries() { const database = openOpenClawStateDatabase(databaseOptions); const rows = executeSqliteQuerySync(