Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/directus-asset-cache-pruning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@onderwijsin/nuxt-directus-client": minor
"@onderwijsin/nuxt-directus-config": minor
---

Add opt-in pruning for stale Directus asset-cache entries, with throttled request cleanup and an exported consumer-owned Nitro task.
35 changes: 35 additions & 0 deletions modules/directus-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ All options are configured under `directusClient`:
| `client.assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. |
| `client.assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. |
| `client.assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. |
| `client.assets.cache.prune.enabled` | `false` | Opts into pruning expired entries when storage does not enforce physical TTLs. |
| `client.assets.cache.prune.onRequest` | `true` | Enables throttled background pruning after cached asset requests. |
| `client.assets.cache.prune.interval` | `3600` | Minimum interval between request-triggered prune attempts, in seconds. |
| `client.commands` | `[readItem, readItems]` | SDK commands to auto-import. Unsupported names are rejected. |
| `client.preview.enabled` | `false` | Enables preview query parsing and request-scoped preview credentials; set to `true` to opt in. |
| `client.preview.versioning` | `true` | Enables versioned preview lookup. |
Expand Down Expand Up @@ -234,6 +237,38 @@ anonymous public responses participate in the application-scoped asset cache. If
the current session, its response is marked `Cache-Control: private, no-store` for downstream
clients.

Pruning is opt-in for storage backends that do not reliably expire entries. Request-triggered
pruning runs in the background and is throttled by `client.assets.cache.prune.interval`; it never
blocks asset delivery. The package also exports an optional Nitro task for consumers to register
manually:

```ts
client: {
assets: {
cache: {
prune: { enabled: false, onRequest: true, interval: 3600 }
}
}
}
```

```ts
// server/tasks/directus-assets/prune.ts
export { default } from "@onderwijsin/nuxt-directus-client/runtime/prune-task";
```

Enable Nitro's experimental tasks and optionally schedule `directus-assets:prune` in the consumer
application. The module does not enable task infrastructure or add a schedule automatically:

```ts
export default defineNuxtConfig({
nitro: {
experimental: { tasks: true },
scheduledTasks: { "0 * * * *": ["directus-assets:prune"] }
}
});
```

## Version previews

Directus Content Versions are independent, unpublished changes to a main item. A version has a
Expand Down
27 changes: 22 additions & 5 deletions modules/directus-client/__tests__/asset-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ const state = vi.hoisted(() => {
},
setItemRaw: async (key: string, value: Uint8Array) => {
values.set(key, value);
}
},
getKeys: async (base?: string) =>
[...values.keys()].filter((key) => !base || key.startsWith(base))
};
const rootStorage = {
getMount: (mount: string) => (mount === "directus-assets" ? { base: "/configured" } : {})
getMount: (mount: string) =>
mount === "directus-assets" ? { base: "/configured", driver: {} } : {}
};
return { rootStorage, storage, values };
});
Expand All @@ -23,8 +26,12 @@ vi.mock("nitropack/runtime", () => ({
useStorage: (mount?: string) => (mount ? state.storage : state.rootStorage)
}));

const { createAssetCacheState, createAssetCacheStorage, getOrCreateAssetCacheHandler } =
await import("../src/runtime/assets/cache");
const {
createAssetCacheState,
createAssetCacheStorage,
getOrCreateAssetCacheHandler,
resolveAssetCacheStoragePrefix
} = await import("../src/runtime/assets/cache");
const { fetchDirectusAsset } = await import("../src/runtime/assets/transport");

let resolveAnonymous: (event: HTTPEvent) => Promise<Response>;
Expand All @@ -35,7 +42,8 @@ const cacheConfig = {
storage: "directus-assets",
maxAge: 60,
maxBodySize: 10 * 1024 * 1024,
swr: false
swr: false,
prune: { enabled: false, onRequest: true, interval: 3600 }
};

describe("Directus asset cache", () => {
Expand All @@ -49,6 +57,15 @@ describe("Directus asset cache", () => {
);
});

it("resolves the escaped ocache storage namespace", async () => {
const prefix = await resolveAssetCacheStoragePrefix();

expect(prefix).toContain("handlers:");
expect(prefix).not.toContain("directus-assets");
expect(prefix).toContain("directusassets.");
expect(prefix.endsWith(":")).toBe(true);
});

it("reuses a handler within state and isolates different application state", () => {
expect(getOrCreateAssetCacheHandler(stateForTest, cacheConfig, resolveAnonymous)).toBe(handler);
expect(
Expand Down
3 changes: 2 additions & 1 deletion modules/directus-client/__tests__/asset-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ function configure(baseUrl: string, cacheEnabled: boolean) {
maxAge: 60,
maxBodySize: 10 * 1024 * 1024,
swr: false,
staleMaxAge: undefined
staleMaxAge: undefined,
prune: { enabled: false, onRequest: true, interval: 3600 }
}
}
},
Expand Down
73 changes: 73 additions & 0 deletions modules/directus-client/__tests__/asset-prune-coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const pruneAssetCache = vi.hoisted(() => vi.fn());

vi.mock("../src/runtime/assets/prune", () => ({ pruneAssetCache }));
vi.mock("nitropack/runtime", () => ({
useNitroApp: () => ({ directusAssetCache: undefined }),
useStorage: () => ({})
}));

const { createAssetCacheState } = await import("../src/runtime/assets/cache");
const { scheduleAssetCachePrune } = await import("../src/runtime/assets/prune-coordinator");

const config = {
enabled: true as const,
storage: "assets",
maxAge: 60,
maxBodySize: 100,
swr: false,
prune: { enabled: true, onRequest: true, interval: 60 }
};

describe("Directus asset-cache prune coordinator", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
pruneAssetCache.mockReset();
pruneAssetCache.mockResolvedValue({ scanned: 0, removed: 0, retained: 0, skipped: 0 });
});

afterEach(() => {
vi.useRealTimers();
});

it("does not schedule disabled or request-disabled pruning", () => {
const state = createAssetCacheState();
expect(
scheduleAssetCachePrune(state, { ...config, prune: { ...config.prune, enabled: false } })
).toBeUndefined();
expect(
scheduleAssetCachePrune(state, { ...config, prune: { ...config.prune, onRequest: false } })
).toBeUndefined();
expect(pruneAssetCache).not.toHaveBeenCalled();
});

it("single-flights and throttles attempts per application state", async () => {
let resolve!: () => void;
pruneAssetCache.mockReturnValueOnce(new Promise<void>((done) => (resolve = done)));
const state = createAssetCacheState();
const first = scheduleAssetCachePrune(state, config);
expect(scheduleAssetCachePrune(state, config)).toBe(first);
expect(pruneAssetCache).toHaveBeenCalledOnce();
resolve();
await first;
vi.advanceTimersByTime(59_999);
expect(scheduleAssetCachePrune(state, config)).toBeUndefined();
vi.advanceTimersByTime(1);
scheduleAssetCachePrune(state, config);
expect(pruneAssetCache).toHaveBeenCalledTimes(2);
expect(scheduleAssetCachePrune(createAssetCacheState(), config)).toBeDefined();
});

it("contains failures and releases the single-flight state", async () => {
const error = new Error("storage failed");
pruneAssetCache.mockRejectedValueOnce(error);
const log = vi.spyOn(console, "error").mockImplementation(() => undefined);
const state = createAssetCacheState();
await scheduleAssetCachePrune(state, config);
expect(log).toHaveBeenCalledWith("[directus-client] Asset cache pruning failed.", error);
expect(state.prune.promise).toBeUndefined();
log.mockRestore();
});
});
57 changes: 57 additions & 0 deletions modules/directus-client/__tests__/asset-prune-task.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const runtime = vi.hoisted(() => ({
config: { directusClient: { assets: { cache: { enabled: false } } } },
pruneAssetCache: vi.fn()
}));

vi.mock("nitropack/runtime", () => ({
defineTask: (task: unknown) => task,
useRuntimeConfig: () => runtime.config
}));
vi.mock("../src/runtime/assets/prune", () => ({
pruneAssetCache: runtime.pruneAssetCache
}));

const task = (await import("../src/runtime/tasks/prune")).default as {
run: () => Promise<unknown>;
};

describe("Directus asset-cache prune task", () => {
beforeEach(() => runtime.pruneAssetCache.mockReset());

it("does not touch storage when caching or pruning is disabled", async () => {
runtime.config = { directusClient: { assets: { cache: { enabled: false } } } };
await expect(task.run()).resolves.toEqual({
result: { scanned: 0, removed: 0, retained: 0, skipped: 0 }
});
runtime.config = {
directusClient: {
assets: { cache: { enabled: true, prune: { enabled: false } } }
}
};
await expect(task.run()).resolves.toEqual({
result: { scanned: 0, removed: 0, retained: 0, skipped: 0 }
});
expect(runtime.pruneAssetCache).not.toHaveBeenCalled();
});

it("returns the shared prune summary and propagates failures", async () => {
const config = {
enabled: true as const,
storage: "assets",
maxAge: 60,
maxBodySize: 100,
swr: false,
prune: { enabled: true, onRequest: true, interval: 60 }
};
const summary = { scanned: 2, removed: 1, retained: 1, skipped: 0 };
runtime.config = { directusClient: { assets: { cache: config } } };
runtime.pruneAssetCache.mockResolvedValue(summary);
await expect(task.run()).resolves.toEqual({ result: summary });
runtime.pruneAssetCache.mockImplementationOnce(async () => {
throw new Error("backend failed");
});
await expect(task.run()).rejects.toThrow("backend failed");
});
});
85 changes: 85 additions & 0 deletions modules/directus-client/__tests__/asset-prune.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "node:http";
import { afterAll, describe, expect, it } from "vitest";
import { $fetch, setupFixture } from "../../../packages/test-utils/src";

const cacheDirectory = mkdtempSync(join(tmpdir(), "nuxt-directus-prune-"));
const upstream = createServer((request, response) => {
const asset = request.url?.split("/", 3)[2];
if (asset === "asset-a" || asset === "asset-b") {
response.writeHead(200, { "cache-control": "public", "content-type": "text/plain" });
response.end(asset);
return;
}
response.writeHead(404);
response.end();
});

await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, "127.0.0.1", resolve);
});
const address = upstream.address();
if (!address || typeof address === "string") throw new Error("Mock asset server did not start");
process.env.DIRECTUS_PRUNE_E2E_URL = `http://127.0.0.1:${address.port}`;
process.env.DIRECTUS_PRUNE_E2E_CACHE_DIR = cacheDirectory;

await setupFixture(import.meta.url, "prune", { dev: false });

async function getKeys(): Promise<string[]> {
return await $fetch<string[]>("/api/directus-asset-cache-keys");
}

async function waitFor(condition: () => Promise<boolean>, timeout = 2_000): Promise<void> {
const deadline = Date.now() + timeout;
while (!(await condition())) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for cache state: ${(await getKeys()).join(", ")}`);
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}

describe("Directus asset-cache pruning end to end", () => {
afterAll(async () => {
delete process.env.DIRECTUS_PRUNE_E2E_URL;
delete process.env.DIRECTUS_PRUNE_E2E_CACHE_DIR;
await new Promise<void>((resolve, reject) => {
upstream.close((error) => (error ? reject(error) : resolve()));
});
rmSync(cacheDirectory, { force: true, recursive: true });
});

it("removes stale entries while retaining fresh and foreign storage data", async () => {
await expect($fetch<string>("/_directus/assets/asset-a")).resolves.toBe("asset-a");
await waitFor(async () => (await getKeys()).some((key) => key !== "foreign-key"));
const keysAfterA = await getKeys();
expect(keysAfterA.length, keysAfterA.join(", ")).toBeGreaterThan(0);

await expect($fetch("/api/directus-asset-cache-foreign", { method: "POST" })).resolves.toEqual({
created: true
});

await new Promise((resolve) => setTimeout(resolve, 1_100));
await expect($fetch<string>("/_directus/assets/asset-b")).resolves.toBe("asset-b");

await waitFor(async () => {
const keys = (await getKeys()).filter((key) => key !== "foreign-key");
return keys.some((key) => !keysAfterA.includes(key));
});
const keysForB = (await getKeys()).filter(
(key) => key !== "foreign-key" && !keysAfterA.includes(key)
);

await waitFor(async () => {
const keys = await getKeys();
return keysAfterA.every((key) => !keys.includes(key));
});

const finalKeys = await getKeys();
expect(finalKeys).toEqual(expect.arrayContaining(keysForB));
expect(finalKeys).toContain("foreign-key");
});
});
Loading
Loading