From b4ca71b030fec70e56d57b4aac5beb243eb379c0 Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 17:30:46 +0200 Subject: [PATCH 1/8] feat(directus-client): prune stale asset cache entries --- .changeset/directus-asset-cache-pruning.md | 6 + modules/directus-client/README.md | 17 ++ .../__tests__/asset-cache.test.ts | 45 ++++- .../__tests__/asset-handlers.test.ts | 3 +- .../directus-client/__tests__/options.test.ts | 3 +- modules/directus-client/package.json | 4 + .../src/runtime/assets/cache.ts | 29 +++- .../src/runtime/assets/cached-handler.ts | 3 + .../src/runtime/assets/prune.ts | 158 ++++++++++++++++++ .../src/runtime/tasks/prune.ts | 11 ++ .../src/runtime/typegen/config.d.ts | 6 + modules/directus-config/README.md | 4 + modules/directus-config/src/schema/client.ts | 15 +- skills/nuxt-directus-client/SKILL.md | 11 ++ 14 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 .changeset/directus-asset-cache-pruning.md create mode 100644 modules/directus-client/src/runtime/assets/prune.ts create mode 100644 modules/directus-client/src/runtime/tasks/prune.ts diff --git a/.changeset/directus-asset-cache-pruning.md b/.changeset/directus-asset-cache-pruning.md new file mode 100644 index 00000000..9820c9f3 --- /dev/null +++ b/.changeset/directus-asset-cache-pruning.md @@ -0,0 +1,6 @@ +--- +"@onderwijsin/nuxt-directus-client": minor +"@onderwijsin/nuxt-directus-config": minor +--- + +Add opt-in pruning for stale Directus asset-cache entries, including throttled request cleanup and a reusable Nitro task handler. diff --git a/modules/directus-client/README.md b/modules/directus-client/README.md index deb79ffb..dc55370d 100644 --- a/modules/directus-client/README.md +++ b/modules/directus-client/README.md @@ -196,6 +196,10 @@ 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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | | `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. | @@ -234,6 +238,19 @@ 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. To use the optional Nitro task, enable `prune.task.enabled` and create a +consumer-owned task file: + +```ts +// server/tasks/directus-assets/prune.ts +export { default } from "@onderwijsin/nuxt-directus-client/runtime/prune-task"; +``` + +Enable Nitro's experimental tasks and schedule `directus-assets:prune` in the consumer application. +The module does not enable task infrastructure or add a schedule automatically. + ## Version previews Directus Content Versions are independent, unpublished changes to a main item. A version has a diff --git a/modules/directus-client/__tests__/asset-cache.test.ts b/modules/directus-client/__tests__/asset-cache.test.ts index c9eaa74b..89a40bdc 100644 --- a/modules/directus-client/__tests__/asset-cache.test.ts +++ b/modules/directus-client/__tests__/asset-cache.test.ts @@ -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 }; }); @@ -25,6 +28,7 @@ vi.mock("nitropack/runtime", () => ({ const { createAssetCacheState, createAssetCacheStorage, getOrCreateAssetCacheHandler } = await import("../src/runtime/assets/cache"); +const { pruneAssetCache } = await import("../src/runtime/assets/prune"); const { fetchDirectusAsset } = await import("../src/runtime/assets/transport"); let resolveAnonymous: (event: HTTPEvent) => Promise; @@ -35,7 +39,8 @@ const cacheConfig = { storage: "directus-assets", maxAge: 60, maxBodySize: 10 * 1024 * 1024, - swr: false + swr: false, + prune: { enabled: false, onRequest: true, interval: 3600, task: { enabled: false } } }; describe("Directus asset cache", () => { @@ -148,4 +153,38 @@ describe("Directus asset cache", () => { ); } }); + + it("prunes only expired entries in the asset namespace", async () => { + const storage = createAssetCacheStorage("directus-assets"); + const now = 10_000_000; + await storage.set("/cache:handlers:directus-assets:expired.json", { + mtime: now - 61_000, + maxAge: 60 + }); + await storage.set("/cache:handlers:directus-assets:fresh.json", { + mtime: now - 59_000, + maxAge: 60 + }); + await storage.set("/cache:other:unrelated.json", { mtime: now - 100_000, maxAge: 1 }); + + const result = await pruneAssetCache( + { + ...cacheConfig, + prune: { + enabled: true, + onRequest: true, + interval: 1, + task: { enabled: false } + } + }, + now + ); + + expect(result).toEqual({ scanned: 2, removed: 1, retained: 1, skipped: 0 }); + expect(await storage.get("/cache:handlers:directus-assets:expired.json")).toBeNull(); + expect(await storage.get("/cache:other:unrelated.json")).toEqual({ + mtime: now - 100_000, + maxAge: 1 + }); + }); }); diff --git a/modules/directus-client/__tests__/asset-handlers.test.ts b/modules/directus-client/__tests__/asset-handlers.test.ts index 56a82770..b715d845 100644 --- a/modules/directus-client/__tests__/asset-handlers.test.ts +++ b/modules/directus-client/__tests__/asset-handlers.test.ts @@ -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, task: { enabled: false } } } } }, diff --git a/modules/directus-client/__tests__/options.test.ts b/modules/directus-client/__tests__/options.test.ts index 8f2a3469..ba8e01c3 100644 --- a/modules/directus-client/__tests__/options.test.ts +++ b/modules/directus-client/__tests__/options.test.ts @@ -203,7 +203,8 @@ describe("Directus module options", () => { storage: "assets", maxAge: 60, maxBodySize: 10 * 1024 * 1024, - swr: false + swr: false, + prune: { enabled: false, onRequest: true, interval: 3600, task: { enabled: false } } }); expect(() => directusClientOptionsSchema.parse({ diff --git a/modules/directus-client/package.json b/modules/directus-client/package.json index 66ecb21b..cf74a194 100644 --- a/modules/directus-client/package.json +++ b/modules/directus-client/package.json @@ -30,6 +30,10 @@ "./runtime/server": { "types": "./dist/runtime/server/index.d.ts", "import": "./dist/runtime/server/index.js" + }, + "./runtime/prune-task": { + "types": "./dist/runtime/tasks/prune.d.ts", + "import": "./dist/runtime/tasks/prune.js" } }, "publishConfig": { diff --git a/modules/directus-client/src/runtime/assets/cache.ts b/modules/directus-client/src/runtime/assets/cache.ts index 14480eb1..fc2541be 100644 --- a/modules/directus-client/src/runtime/assets/cache.ts +++ b/modules/directus-client/src/runtime/assets/cache.ts @@ -8,11 +8,34 @@ import type { H3Event } from "h3"; import type { ResolvedDirectusAssetCacheOptions } from "@onderwijsin/nuxt-directus-config/schema"; import { useNitroApp, useStorage } from "nitropack/runtime"; -type AssetCacheConfig = Extract; +type AssetCacheConfig = Omit< + Extract, + "prune" +> & { + prune?: { + enabled: boolean; + onRequest: boolean; + interval: number; + task: { enabled: boolean; schedule?: string }; + }; +}; + +export const DIRECTUS_ASSET_CACHE_BASE = "/cache"; +export const DIRECTUS_ASSET_CACHE_GROUP = "handlers"; +export const DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; /** Nitro-application-owned lazy state for one immutable asset cache handler. */ export interface DirectusAssetCacheState { handler?: CachedEventHandler; + lastPruneAttemptAt?: number; + prunePromise?: Promise; +} + +export interface AssetCachePruneSummary { + scanned: number; + removed: number; + retained: number; + skipped: number; } /** @@ -85,7 +108,9 @@ export function getOrCreateAssetCacheHandler( fetchAnonymous: (event: HTTPEvent) => Promise ): CachedEventHandler { state.handler ??= defineCachedHandler(fetchAnonymous, { - name: "directus-assets", + name: DIRECTUS_ASSET_CACHE_NAME, + base: DIRECTUS_ASSET_CACHE_BASE, + group: DIRECTUS_ASSET_CACHE_GROUP, storage: () => createAssetCacheStorage(config.storage), maxAge: config.maxAge, maxBodySize: config.maxBodySize, diff --git a/modules/directus-client/src/runtime/assets/cached-handler.ts b/modules/directus-client/src/runtime/assets/cached-handler.ts index b0b64cb5..e5127400 100644 --- a/modules/directus-client/src/runtime/assets/cached-handler.ts +++ b/modules/directus-client/src/runtime/assets/cached-handler.ts @@ -5,6 +5,7 @@ import { getAssetCacheHandler, createAssetCacheEvent } from "./cache"; import { resolveAssetWithSessionFallback, type AssetAuthenticationOptions } from "./authentication"; import { fetchDirectusAsset, getAssetRequestHeaders, type AssetRequestMethod } from "./transport"; import { resolveDirectusAssetUrl } from "./url"; +import { getAssetCacheState, maybePruneAssetCache } from "./prune"; function fetchAnonymousAsset(cachedEvent: HTTPEvent): Promise { return fetchDirectusAsset(cachedEvent.req.url, { @@ -39,6 +40,8 @@ export default defineEventHandler(async (event) => { if (!(cachedResponse instanceof Response)) { throw new Error("Directus asset cache returned an invalid response"); } + const prunePromise = maybePruneAssetCache(getAssetCacheState(), cache); + if (prunePromise) event.waitUntil(prunePromise); const authentication: AssetAuthenticationOptions = { authEnabled: config.directusClient.auth.enabled, publicOnly: config.directusClient.assets.publicOnly diff --git a/modules/directus-client/src/runtime/assets/prune.ts b/modules/directus-client/src/runtime/assets/prune.ts new file mode 100644 index 00000000..09ccf357 --- /dev/null +++ b/modules/directus-client/src/runtime/assets/prune.ts @@ -0,0 +1,158 @@ +import type { ResolvedDirectusAssetCacheOptions } from "@onderwijsin/nuxt-directus-config/schema"; +import { isFiniteNumber, isRecord } from "@onderwijsin/nuxt-module-utils/shared"; +import { useNitroApp, useRuntimeConfig, useStorage } from "nitropack/runtime"; +import type { AssetCachePruneSummary, DirectusAssetCacheState } from "./cache"; +import { + createAssetCacheStorage, + DIRECTUS_ASSET_CACHE_BASE, + DIRECTUS_ASSET_CACHE_GROUP, + DIRECTUS_ASSET_CACHE_NAME +} from "./cache"; + +type AssetCacheConfig = Omit< + Extract, + "prune" +> & { + prune?: { + enabled: boolean; + onRequest: boolean; + interval: number; + task: { enabled: boolean; schedule?: string }; + }; +}; + +export const DIRECTUS_ASSET_CACHE_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; + +function isCacheDuration(value: unknown): value is number { + return isFiniteNumber(value) && value >= 0; +} + +function isExpiredEntry( + entry: Record, + config: AssetCacheConfig, + now: number +): boolean | undefined { + if (!isFiniteNumber(entry.mtime)) return undefined; + const maxAge = isCacheDuration(entry.maxAge) ? entry.maxAge : config.maxAge; + if (config.swr !== true) return entry.mtime + maxAge * 1000 <= now; + + const staleMaxAge = isCacheDuration(entry.staleMaxAge) ? entry.staleMaxAge : config.staleMaxAge; + if (staleMaxAge === undefined) return false; + return entry.mtime + (maxAge + staleMaxAge) * 1000 <= now; +} + +/** Removes expired Directus asset entries from the configured cache namespace. + * + * @param config Resolved asset-cache configuration. + * @param now Current Unix timestamp in milliseconds. + * @returns Counts for the sweep. + */ +export async function pruneAssetCache( + config: AssetCacheConfig, + now = Date.now() +): Promise { + const rootStorage = useStorage(); + const mount = rootStorage.getMount(config.storage); + if (!mount.base) { + throw new Error(`Directus asset cache storage mount "${config.storage}" is not configured`); + } + if (mount.driver?.flags?.ttl === true) { + return { scanned: 0, removed: 0, retained: 0, skipped: 0 }; + } + + const storage = useStorage(config.storage); + const keys = await storage.getKeys(DIRECTUS_ASSET_CACHE_PREFIX); + const cacheStorage = createAssetCacheStorage(config.storage); + const summary: AssetCachePruneSummary = { + scanned: keys.length, + removed: 0, + retained: 0, + skipped: 0 + }; + + for (const key of keys) { + try { + const entry = await cacheStorage.get(key); + if (!isRecord(entry)) { + summary.skipped++; + continue; + } + const expired = isExpiredEntry(entry, config, now); + if (expired === undefined) { + summary.skipped++; + } else if (expired) { + await cacheStorage.set(key, null); + summary.removed++; + } else { + summary.retained++; + } + } catch { + summary.skipped++; + } + } + + return summary; +} + +/** Schedules one best-effort, application-local asset-cache prune attempt. + * + * @param state Application-owned cache state. + * @param config Resolved asset-cache configuration. + * @returns The in-flight sweep, or `undefined` when throttled or disabled. + */ +export function maybePruneAssetCache( + state: DirectusAssetCacheState, + config: AssetCacheConfig +): Promise | undefined { + const prune = config.prune ?? { + enabled: false, + onRequest: true, + interval: 3600, + task: { enabled: false } + }; + if (prune.enabled !== true || prune.onRequest !== true) return undefined; + const now = Date.now(); + if (state.prunePromise) return state.prunePromise; + if ( + state.lastPruneAttemptAt !== undefined && + now - state.lastPruneAttemptAt < prune.interval * 1000 + ) { + return undefined; + } + + state.lastPruneAttemptAt = now; + state.prunePromise = pruneAssetCache(config).finally(() => { + state.prunePromise = undefined; + }); + return state.prunePromise; +} + +/** Runs the reusable asset-cache pruning operation as a Nitro task. + * + * @returns Nitro task result counts. + */ +export async function runAssetCachePruneTask(): Promise<{ result: AssetCachePruneSummary }> { + const config = useRuntimeConfig().directusClient.assets.cache; + const configRecord: Record = isRecord(config) ? config : {}; + const pruning = isRecord(configRecord.prune) ? configRecord.prune : undefined; + if ( + config.enabled !== true || + !pruning || + pruning.enabled !== true || + !isRecord(pruning.task) || + pruning.task.enabled !== true + ) { + return { result: { scanned: 0, removed: 0, retained: 0, skipped: 0 } }; + } + return { result: await pruneAssetCache(config) }; +} + +/** Returns the application-owned asset-cache state used by request orchestration. + * + * @returns The current Nitro application's asset-cache state. + */ +export function getAssetCacheState(): DirectusAssetCacheState { + const state = useNitroApp().directusAssetCache; + if (!state) throw new Error("Directus asset cache plugin is not registered"); + return state; +} diff --git a/modules/directus-client/src/runtime/tasks/prune.ts b/modules/directus-client/src/runtime/tasks/prune.ts new file mode 100644 index 00000000..9301d6e1 --- /dev/null +++ b/modules/directus-client/src/runtime/tasks/prune.ts @@ -0,0 +1,11 @@ +import { defineTask } from "nitropack/runtime"; +import { runAssetCachePruneTask } from "../assets/prune"; + +/** Prunes stale Directus asset-cache entries when explicitly enabled by the consumer. */ +export default defineTask({ + meta: { + name: "directus-assets:prune", + description: "Remove stale Directus asset-cache entries." + }, + run: runAssetCachePruneTask +}); diff --git a/modules/directus-client/src/runtime/typegen/config.d.ts b/modules/directus-client/src/runtime/typegen/config.d.ts index 8a0abff9..19c98f62 100644 --- a/modules/directus-client/src/runtime/typegen/config.d.ts +++ b/modules/directus-client/src/runtime/typegen/config.d.ts @@ -36,6 +36,12 @@ declare module "nuxt/schema" { maxBodySize: number; swr: boolean; staleMaxAge?: number; + prune?: { + enabled: boolean; + onRequest: boolean; + interval: number; + task: { enabled: boolean; schedule?: string }; + }; }; }; }; diff --git a/modules/directus-config/README.md b/modules/directus-config/README.md index 38789a2b..7557c10c 100644 --- a/modules/directus-config/README.md +++ b/modules/directus-config/README.md @@ -106,6 +106,10 @@ the application proxy. | `assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. | | `assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. | | `assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. | +| `assets.cache.prune.enabled` | `false` | Opts into best-effort pruning of expired entries in storage without a native TTL guarantee. | +| `assets.cache.prune.onRequest` | `true` | Runs throttled pruning in the background after cached asset requests. | +| `assets.cache.prune.interval` | `3600` | Minimum request-triggered prune interval in seconds. | +| `assets.cache.prune.task.enabled` | `false` | Enables a consumer-registered Nitro prune task. | | `commands` | `readItem`, `readItems` | SDK commands that the Directus client module auto-imports. | | `preview.enabled` | `false` | Enables preview query parsing; set to `true` to opt in. | | `preview.versioning` | `true` | Enables Content Version preview lookup. | diff --git a/modules/directus-config/src/schema/client.ts b/modules/directus-config/src/schema/client.ts index 79836b7b..fc47b3aa 100644 --- a/modules/directus-config/src/schema/client.ts +++ b/modules/directus-config/src/schema/client.ts @@ -34,7 +34,20 @@ const assetCacheSchema = z.discriminatedUnion("enabled", [ .positive() .default(10 * 1024 * 1024), swr: z.boolean().default(false), - staleMaxAge: z.number().int().nonnegative().optional() + staleMaxAge: z.number().int().nonnegative().optional(), + prune: z + .strictObject({ + enabled: z.boolean().default(false), + onRequest: z.boolean().default(true), + interval: z.number().int().positive().default(3600), + task: z + .strictObject({ + enabled: z.boolean().default(false), + schedule: z.string().trim().min(1).optional() + }) + .default({ enabled: false }) + }) + .default({ enabled: false, onRequest: true, interval: 3600, task: { enabled: false } }) }) ]); diff --git a/skills/nuxt-directus-client/SKILL.md b/skills/nuxt-directus-client/SKILL.md index b6269a61..0b236924 100644 --- a/skills/nuxt-directus-client/SKILL.md +++ b/skills/nuxt-directus-client/SKILL.md @@ -82,6 +82,10 @@ 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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | | `client.commands` | `[readItem, readItems]` | SDK command names 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. | @@ -120,6 +124,13 @@ that the proxy exposes and the optional asset cache keys. It does not vary on `O Cache-disabled asset requests use a streaming proxy. Cached delivery is application-scoped and anonymous-only; session-backed asset responses always receive `Cache-Control: private, no-store`. +Asset-cache pruning is disabled by default. Enable `client.assets.cache.prune.enabled` for +backends without reliable physical TTLs; request-triggered cleanup is backgrounded and throttled. +For scheduled cleanup, also enable `prune.task.enabled`, create +`server/tasks/directus-assets/prune.ts` that re-exports +`@onderwijsin/nuxt-directus-client/runtime/prune-task`, then explicitly enable Nitro experimental +tasks and schedule `directus-assets:prune`. Task registration and scheduling remain consumer-owned. + When authentication is enabled without an explicit session secret, local development uses a fixed convenience value, while `nuxt prepare` and CI generate a fresh ephemeral cryptographic value. Production has no fallback; configure `client.auth.sessionSecret` from a server-only deployment From d98242d2db9a6439a9079cb37ede71ebe66229a4 Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 17:51:38 +0200 Subject: [PATCH 2/8] style: apply repository formatting --- modules/directus-client/README.md | 90 +++++++++++------------ modules/directus-config/README.md | 74 +++++++++---------- skills/nuxt-directus-client/SKILL.md | 102 +++++++++++++-------------- 3 files changed, 133 insertions(+), 133 deletions(-) diff --git a/modules/directus-client/README.md b/modules/directus-client/README.md index dc55370d..04e07e1d 100644 --- a/modules/directus-client/README.md +++ b/modules/directus-client/README.md @@ -180,51 +180,51 @@ reference is in [Authentication](#authentication). All options are configured under `directusClient`: -| Option | Default | Contract | -| ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | Enables the module. | -| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | -| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | -| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | -| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | -| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path; uses the same safe local-path validation and cannot overlap the REST proxy or reserved auth routes. | -| `client.assets.publicOnly` | `false` | Uses anonymous Directus asset requests only; session authentication is never attempted when enabled. | -| `client.assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | -| `client.assets.cache.storage` | — | Nitro storage mount name, required when enabled; it must support raw binary values. | -| `client.assets.cache.maxAge` | — | Positive fresh cache lifetime in seconds, required when enabled. | -| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | -| `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. | -| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | -| `client.auth.enabled` | `false` | Enables cookie authentication, authentication routes, and `useDirectusAuth`. | -| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | -| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | -| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute callback URL sent upstream; required when enabled and server-only. | -| `client.auth.cookie.name` | `directus_session` | Session cookie name. | -| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | -| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | -| `client.auth.cookie.path` | `/` | Cookie path. | -| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | -| `client.auth.cookie.domain` | — | Optional cookie domain. | -| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | -| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | -| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | -| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | -| `client.auth.passwordResetUrl` | — | Required for password-request support; sent to Directus as `reset_url`. | -| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | -| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | -| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | -| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | -| `client.typegen.transform` | — | Final build-time source transform. | +| Option | Default | Contract | +| ---------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Enables the module. | +| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | +| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | +| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | +| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | +| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path; uses the same safe local-path validation and cannot overlap the REST proxy or reserved auth routes. | +| `client.assets.publicOnly` | `false` | Uses anonymous Directus asset requests only; session authentication is never attempted when enabled. | +| `client.assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | +| `client.assets.cache.storage` | — | Nitro storage mount name, required when enabled; it must support raw binary values. | +| `client.assets.cache.maxAge` | — | Positive fresh cache lifetime in seconds, required when enabled. | +| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | +| `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. | +| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | +| `client.auth.enabled` | `false` | Enables cookie authentication, authentication routes, and `useDirectusAuth`. | +| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | +| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | +| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute callback URL sent upstream; required when enabled and server-only. | +| `client.auth.cookie.name` | `directus_session` | Session cookie name. | +| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | +| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | +| `client.auth.cookie.path` | `/` | Cookie path. | +| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | +| `client.auth.cookie.domain` | — | Optional cookie domain. | +| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | +| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | +| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | +| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | +| `client.auth.passwordResetUrl` | — | Required for password-request support; sent to Directus as `reset_url`. | +| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | +| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | +| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | +| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | +| `client.typegen.transform` | — | Final build-time source transform. | The module validates options during Nuxt configuration. Production and CI type generation require both `instance.baseUrl` and `client.typegen.introspectionToken` when it is enabled. diff --git a/modules/directus-config/README.md b/modules/directus-config/README.md index 7557c10c..8354bad7 100644 --- a/modules/directus-config/README.md +++ b/modules/directus-config/README.md @@ -93,43 +93,43 @@ the application proxy. `client` contains Directus client module settings. Its nested schemas provide defaults. -| Option | Default | Description | -| ------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -| `proxy.path` | `/_directus/proxy` | Local proxy route. It cannot be root, contain traversal segments, or overlap `/_directus/auth`. | -| `assets.enabled` | `true` | Enables the dedicated Directus `/assets` proxy route. | -| `assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `assets.path` | `/_directus/assets` | Local asset proxy route using the same safe path validation as `proxy.path`. | -| `assets.publicOnly` | `false` | Uses anonymous asset requests only and never attempts session authentication when enabled. | -| `assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | -| `assets.cache.storage` | — | Nitro storage mount name; required when caching is enabled and must support raw binary values. | -| `assets.cache.maxAge` | — | Fresh cache lifetime in seconds; required to be a positive integer when caching is enabled. | -| `assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. | -| `assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. | -| `assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. | -| `assets.cache.prune.enabled` | `false` | Opts into best-effort pruning of expired entries in storage without a native TTL guarantee. | -| `assets.cache.prune.onRequest` | `true` | Runs throttled pruning in the background after cached asset requests. | -| `assets.cache.prune.interval` | `3600` | Minimum request-triggered prune interval in seconds. | -| `assets.cache.prune.task.enabled` | `false` | Enables a consumer-registered Nitro prune task. | -| `commands` | `readItem`, `readItems` | SDK commands that the Directus client module auto-imports. | -| `preview.enabled` | `false` | Enables preview query parsing; set to `true` to opt in. | -| `preview.versioning` | `true` | Enables Content Version preview lookup. | -| `preview.queryKeys` | `preview`, `token`, `version`, `id` | Preview query parameter names. | -| `auth.enabled` | `false` | Enables cookie-backed authentication. | -| `auth.turnstile.enabled` | `false` | Enables Turnstile protection for authentication requests. | -| `auth.magicLinks.enabled` | `false` | Enables optional Directus magic-link authentication routes; requires `auth.enabled`. | -| `auth.magicLinks.redirectUrl` | — | Absolute, server-only callback URL required when magic links are enabled. | -| `auth.cookie` | See below | Session-cookie settings: `name`, `secure`, `sameSite`, `path`, `maxAge`, and optional `domain`. | -| `auth.refreshSafetyWindow` | `30000` | Milliseconds before expiry when a session is refreshed. | -| `auth.sessionSecret` | — | Server-only H3 sealing secret; required for enabled auth and must contain at least 32 characters. | -| `auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during staged key rotation. | -| `auth.maskSecretsInPlayground` | `true` | Masks access and refresh tokens in the local session inspection playground. | -| `auth.passwordResetUrl` | — | URL sent to Directus for password-reset requests. | -| `typegen.enabled` | `true` | Enables generated `#directus` schema declarations. | -| `typegen.introspectionToken` | — | Server-only schema-introspection token. | -| `typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `typegen.augmentations` | All `true` | Generated-source transforms. | -| `typegen.rules` | `{}` | Collection and field type-expression overrides. | -| `typegen.transform` | — | Final executable source transform. | +| Option | Default | Description | +| --------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `proxy.path` | `/_directus/proxy` | Local proxy route. It cannot be root, contain traversal segments, or overlap `/_directus/auth`. | +| `assets.enabled` | `true` | Enables the dedicated Directus `/assets` proxy route. | +| `assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `assets.path` | `/_directus/assets` | Local asset proxy route using the same safe path validation as `proxy.path`. | +| `assets.publicOnly` | `false` | Uses anonymous asset requests only and never attempts session authentication when enabled. | +| `assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | +| `assets.cache.storage` | — | Nitro storage mount name; required when caching is enabled and must support raw binary values. | +| `assets.cache.maxAge` | — | Fresh cache lifetime in seconds; required to be a positive integer when caching is enabled. | +| `assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. | +| `assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. | +| `assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. | +| `assets.cache.prune.enabled` | `false` | Opts into best-effort pruning of expired entries in storage without a native TTL guarantee. | +| `assets.cache.prune.onRequest` | `true` | Runs throttled pruning in the background after cached asset requests. | +| `assets.cache.prune.interval` | `3600` | Minimum request-triggered prune interval in seconds. | +| `assets.cache.prune.task.enabled` | `false` | Enables a consumer-registered Nitro prune task. | +| `commands` | `readItem`, `readItems` | SDK commands that the Directus client module auto-imports. | +| `preview.enabled` | `false` | Enables preview query parsing; set to `true` to opt in. | +| `preview.versioning` | `true` | Enables Content Version preview lookup. | +| `preview.queryKeys` | `preview`, `token`, `version`, `id` | Preview query parameter names. | +| `auth.enabled` | `false` | Enables cookie-backed authentication. | +| `auth.turnstile.enabled` | `false` | Enables Turnstile protection for authentication requests. | +| `auth.magicLinks.enabled` | `false` | Enables optional Directus magic-link authentication routes; requires `auth.enabled`. | +| `auth.magicLinks.redirectUrl` | — | Absolute, server-only callback URL required when magic links are enabled. | +| `auth.cookie` | See below | Session-cookie settings: `name`, `secure`, `sameSite`, `path`, `maxAge`, and optional `domain`. | +| `auth.refreshSafetyWindow` | `30000` | Milliseconds before expiry when a session is refreshed. | +| `auth.sessionSecret` | — | Server-only H3 sealing secret; required for enabled auth and must contain at least 32 characters. | +| `auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during staged key rotation. | +| `auth.maskSecretsInPlayground` | `true` | Masks access and refresh tokens in the local session inspection playground. | +| `auth.passwordResetUrl` | — | URL sent to Directus for password-reset requests. | +| `typegen.enabled` | `true` | Enables generated `#directus` schema declarations. | +| `typegen.introspectionToken` | — | Server-only schema-introspection token. | +| `typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `typegen.augmentations` | All `true` | Generated-source transforms. | +| `typegen.rules` | `{}` | Collection and field type-expression overrides. | +| `typegen.transform` | — | Final executable source transform. | Asset caching is disabled by default. `assets.cache.storage` names a Nitro storage mount supplied by the application; the module does not create or choose its driver. Use filesystem storage for Node diff --git a/skills/nuxt-directus-client/SKILL.md b/skills/nuxt-directus-client/SKILL.md index 0b236924..04c4e96d 100644 --- a/skills/nuxt-directus-client/SKILL.md +++ b/skills/nuxt-directus-client/SKILL.md @@ -66,51 +66,51 @@ private. Do not place these values in `runtimeConfig.public` or browser code. All options are configured under `directusClient`. -| Option | Default | Contract | -| ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | Enables the module. | -| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | -| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | -| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | -| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | -| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path using the same safe validation; it cannot overlap the REST proxy or reserved auth routes. | -| `client.assets.publicOnly` | `false` | Keeps asset requests anonymous and never escalates to the current session when enabled. | -| `client.assets.cache.enabled` | `false` | Enables server-side caching of explicitly public anonymous assets. | -| `client.assets.cache.storage` | — | Name of an application-provided Nitro raw-byte storage mount; required when enabled. | -| `client.assets.cache.maxAge` | — | Positive cache lifetime in seconds; required when enabled. | -| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | -| `client.commands` | `[readItem, readItems]` | SDK command names 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. | -| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | -| `client.auth.enabled` | `false` | Enables cookie authentication and registers authentication routes plus `useDirectusAuth`. | -| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | -| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | -| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute server-only callback URL; required when magic links are enabled. | -| `client.auth.cookie.name` | `directus_session` | Session cookie name. | -| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | -| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | -| `client.auth.cookie.path` | `/` | Cookie path. | -| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | -| `client.auth.cookie.domain` | — | Optional cookie domain. | -| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | -| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | -| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | -| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | -| `client.auth.passwordResetUrl` | — | Required for password-request support; sent as Directus `reset_url`. | -| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | -| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | -| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | -| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | -| `client.typegen.transform` | — | Final build-time source transform. | +| Option | Default | Contract | +| ---------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Enables the module. | +| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | +| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | +| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | +| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | +| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path using the same safe validation; it cannot overlap the REST proxy or reserved auth routes. | +| `client.assets.publicOnly` | `false` | Keeps asset requests anonymous and never escalates to the current session when enabled. | +| `client.assets.cache.enabled` | `false` | Enables server-side caching of explicitly public anonymous assets. | +| `client.assets.cache.storage` | — | Name of an application-provided Nitro raw-byte storage mount; required when enabled. | +| `client.assets.cache.maxAge` | — | Positive cache lifetime in seconds; required when enabled. | +| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | +| `client.commands` | `[readItem, readItems]` | SDK command names 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. | +| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | +| `client.auth.enabled` | `false` | Enables cookie authentication and registers authentication routes plus `useDirectusAuth`. | +| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | +| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | +| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute server-only callback URL; required when magic links are enabled. | +| `client.auth.cookie.name` | `directus_session` | Session cookie name. | +| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | +| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | +| `client.auth.cookie.path` | `/` | Cookie path. | +| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | +| `client.auth.cookie.domain` | — | Optional cookie domain. | +| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | +| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | +| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | +| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | +| `client.auth.passwordResetUrl` | — | Required for password-request support; sent as Directus `reset_url`. | +| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | +| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | +| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | +| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | +| `client.typegen.transform` | — | Final build-time source transform. | The module validates option values during Nuxt configuration. `instance.baseUrl` is optional, but requests cannot run without it; the module skips setup during `nuxt prepare` and CI when it is @@ -124,12 +124,12 @@ that the proxy exposes and the optional asset cache keys. It does not vary on `O Cache-disabled asset requests use a streaming proxy. Cached delivery is application-scoped and anonymous-only; session-backed asset responses always receive `Cache-Control: private, no-store`. -Asset-cache pruning is disabled by default. Enable `client.assets.cache.prune.enabled` for -backends without reliable physical TTLs; request-triggered cleanup is backgrounded and throttled. -For scheduled cleanup, also enable `prune.task.enabled`, create -`server/tasks/directus-assets/prune.ts` that re-exports -`@onderwijsin/nuxt-directus-client/runtime/prune-task`, then explicitly enable Nitro experimental -tasks and schedule `directus-assets:prune`. Task registration and scheduling remain consumer-owned. +Asset-cache pruning is disabled by default. Enable `client.assets.cache.prune.enabled` for backends +without reliable physical TTLs; request-triggered cleanup is backgrounded and throttled. For +scheduled cleanup, also enable `prune.task.enabled`, create `server/tasks/directus-assets/prune.ts` +that re-exports `@onderwijsin/nuxt-directus-client/runtime/prune-task`, then explicitly enable Nitro +experimental tasks and schedule `directus-assets:prune`. Task registration and scheduling remain +consumer-owned. When authentication is enabled without an explicit session secret, local development uses a fixed convenience value, while `nuxt prepare` and CI generate a fresh ephemeral cryptographic value. From af7a0d198451791cc401fa01e9f7b53ed5318c4f Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 19:07:38 +0200 Subject: [PATCH 3/8] fix(directus-client): complete asset cache pruning --- .changeset/directus-asset-cache-pruning.md | 2 +- modules/directus-client/README.md | 116 +++++++------ .../__tests__/asset-cache.test.ts | 37 +--- .../__tests__/asset-handlers.test.ts | 2 +- .../__tests__/asset-prune-coordinator.test.ts | 73 ++++++++ .../__tests__/asset-prune-task.test.ts | 57 +++++++ .../__tests__/asset-prune.test.ts | 149 +++++++++++++++++ .../directus-client/__tests__/options.test.ts | 34 +++- .../src/runtime/assets/cache.ts | 42 ++--- .../src/runtime/assets/cached-handler.ts | 6 +- .../src/runtime/assets/prune-coordinator.ts | 34 ++++ .../src/runtime/assets/prune.ts | 158 ++++++------------ .../src/runtime/tasks/prune.ts | 12 +- .../src/runtime/typegen/config.d.ts | 3 +- modules/directus-config/README.md | 78 +++++---- modules/directus-config/src/schema/client.ts | 10 +- skills/nuxt-directus-client/SKILL.md | 100 +++++------ 17 files changed, 592 insertions(+), 321 deletions(-) create mode 100644 modules/directus-client/__tests__/asset-prune-coordinator.test.ts create mode 100644 modules/directus-client/__tests__/asset-prune-task.test.ts create mode 100644 modules/directus-client/__tests__/asset-prune.test.ts create mode 100644 modules/directus-client/src/runtime/assets/prune-coordinator.ts diff --git a/.changeset/directus-asset-cache-pruning.md b/.changeset/directus-asset-cache-pruning.md index 9820c9f3..156a85c8 100644 --- a/.changeset/directus-asset-cache-pruning.md +++ b/.changeset/directus-asset-cache-pruning.md @@ -3,4 +3,4 @@ "@onderwijsin/nuxt-directus-config": minor --- -Add opt-in pruning for stale Directus asset-cache entries, including throttled request cleanup and a reusable Nitro task handler. +Add opt-in pruning for stale Directus asset-cache entries, with throttled request cleanup and an exported consumer-owned Nitro task. diff --git a/modules/directus-client/README.md b/modules/directus-client/README.md index 04e07e1d..c83de43d 100644 --- a/modules/directus-client/README.md +++ b/modules/directus-client/README.md @@ -180,51 +180,50 @@ reference is in [Authentication](#authentication). All options are configured under `directusClient`: -| Option | Default | Contract | -| ---------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | Enables the module. | -| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | -| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | -| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | -| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | -| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path; uses the same safe local-path validation and cannot overlap the REST proxy or reserved auth routes. | -| `client.assets.publicOnly` | `false` | Uses anonymous Directus asset requests only; session authentication is never attempted when enabled. | -| `client.assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | -| `client.assets.cache.storage` | — | Nitro storage mount name, required when enabled; it must support raw binary values. | -| `client.assets.cache.maxAge` | — | Positive fresh cache lifetime in seconds, required when enabled. | -| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | -| `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. | -| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | -| `client.auth.enabled` | `false` | Enables cookie authentication, authentication routes, and `useDirectusAuth`. | -| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | -| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | -| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute callback URL sent upstream; required when enabled and server-only. | -| `client.auth.cookie.name` | `directus_session` | Session cookie name. | -| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | -| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | -| `client.auth.cookie.path` | `/` | Cookie path. | -| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | -| `client.auth.cookie.domain` | — | Optional cookie domain. | -| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | -| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | -| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | -| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | -| `client.auth.passwordResetUrl` | — | Required for password-request support; sent to Directus as `reset_url`. | -| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | -| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | -| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | -| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | -| `client.typegen.transform` | — | Final build-time source transform. | +| Option | Default | Contract | +| ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Enables the module. | +| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | +| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | +| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | +| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | +| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path; uses the same safe local-path validation and cannot overlap the REST proxy or reserved auth routes. | +| `client.assets.publicOnly` | `false` | Uses anonymous Directus asset requests only; session authentication is never attempted when enabled. | +| `client.assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | +| `client.assets.cache.storage` | — | Nitro storage mount name, required when enabled; it must support raw binary values. | +| `client.assets.cache.maxAge` | — | Positive fresh cache lifetime in seconds, required when enabled. | +| `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. | +| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | +| `client.auth.enabled` | `false` | Enables cookie authentication, authentication routes, and `useDirectusAuth`. | +| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | +| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | +| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute callback URL sent upstream; required when enabled and server-only. | +| `client.auth.cookie.name` | `directus_session` | Session cookie name. | +| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | +| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | +| `client.auth.cookie.path` | `/` | Cookie path. | +| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | +| `client.auth.cookie.domain` | — | Optional cookie domain. | +| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | +| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | +| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | +| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | +| `client.auth.passwordResetUrl` | — | Required for password-request support; sent to Directus as `reset_url`. | +| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | +| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | +| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | +| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | +| `client.typegen.transform` | — | Final build-time source transform. | The module validates options during Nuxt configuration. Production and CI type generation require both `instance.baseUrl` and `client.typegen.introspectionToken` when it is enabled. @@ -240,16 +239,35 @@ 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. To use the optional Nitro task, enable `prune.task.enabled` and create a -consumer-owned task file: +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 schedule `directus-assets:prune` in the consumer application. -The module does not enable task infrastructure or add a schedule automatically. +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 diff --git a/modules/directus-client/__tests__/asset-cache.test.ts b/modules/directus-client/__tests__/asset-cache.test.ts index 89a40bdc..0aa55693 100644 --- a/modules/directus-client/__tests__/asset-cache.test.ts +++ b/modules/directus-client/__tests__/asset-cache.test.ts @@ -28,7 +28,6 @@ vi.mock("nitropack/runtime", () => ({ const { createAssetCacheState, createAssetCacheStorage, getOrCreateAssetCacheHandler } = await import("../src/runtime/assets/cache"); -const { pruneAssetCache } = await import("../src/runtime/assets/prune"); const { fetchDirectusAsset } = await import("../src/runtime/assets/transport"); let resolveAnonymous: (event: HTTPEvent) => Promise; @@ -40,7 +39,7 @@ const cacheConfig = { maxAge: 60, maxBodySize: 10 * 1024 * 1024, swr: false, - prune: { enabled: false, onRequest: true, interval: 3600, task: { enabled: false } } + prune: { enabled: false, onRequest: true, interval: 3600 } }; describe("Directus asset cache", () => { @@ -153,38 +152,4 @@ describe("Directus asset cache", () => { ); } }); - - it("prunes only expired entries in the asset namespace", async () => { - const storage = createAssetCacheStorage("directus-assets"); - const now = 10_000_000; - await storage.set("/cache:handlers:directus-assets:expired.json", { - mtime: now - 61_000, - maxAge: 60 - }); - await storage.set("/cache:handlers:directus-assets:fresh.json", { - mtime: now - 59_000, - maxAge: 60 - }); - await storage.set("/cache:other:unrelated.json", { mtime: now - 100_000, maxAge: 1 }); - - const result = await pruneAssetCache( - { - ...cacheConfig, - prune: { - enabled: true, - onRequest: true, - interval: 1, - task: { enabled: false } - } - }, - now - ); - - expect(result).toEqual({ scanned: 2, removed: 1, retained: 1, skipped: 0 }); - expect(await storage.get("/cache:handlers:directus-assets:expired.json")).toBeNull(); - expect(await storage.get("/cache:other:unrelated.json")).toEqual({ - mtime: now - 100_000, - maxAge: 1 - }); - }); }); diff --git a/modules/directus-client/__tests__/asset-handlers.test.ts b/modules/directus-client/__tests__/asset-handlers.test.ts index b715d845..a8e3b54c 100644 --- a/modules/directus-client/__tests__/asset-handlers.test.ts +++ b/modules/directus-client/__tests__/asset-handlers.test.ts @@ -53,7 +53,7 @@ function configure(baseUrl: string, cacheEnabled: boolean) { maxBodySize: 10 * 1024 * 1024, swr: false, staleMaxAge: undefined, - prune: { enabled: false, onRequest: true, interval: 3600, task: { enabled: false } } + prune: { enabled: false, onRequest: true, interval: 3600 } } } }, diff --git a/modules/directus-client/__tests__/asset-prune-coordinator.test.ts b/modules/directus-client/__tests__/asset-prune-coordinator.test.ts new file mode 100644 index 00000000..9ae7d08a --- /dev/null +++ b/modules/directus-client/__tests__/asset-prune-coordinator.test.ts @@ -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((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(); + }); +}); diff --git a/modules/directus-client/__tests__/asset-prune-task.test.ts b/modules/directus-client/__tests__/asset-prune-task.test.ts new file mode 100644 index 00000000..c51f5a27 --- /dev/null +++ b/modules/directus-client/__tests__/asset-prune-task.test.ts @@ -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; +}; + +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"); + }); +}); diff --git a/modules/directus-client/__tests__/asset-prune.test.ts b/modules/directus-client/__tests__/asset-prune.test.ts new file mode 100644 index 00000000..77bb3ff7 --- /dev/null +++ b/modules/directus-client/__tests__/asset-prune.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => { + const values = new Map(); + const failures = new Set(); + const deleteFailures = new Set(); + let mountAvailable = true; + const storage = { + getItemRaw: async (key: string) => { + if (failures.has(key)) throw new Error("read failed"); + return values.get(key); + }, + removeItem: async (key: string) => { + if (deleteFailures.has(key)) throw new Error("delete failed"); + values.delete(key); + }, + setItemRaw: async (key: string, value: Uint8Array) => values.set(key, value), + getKeys: async (prefix?: string) => + [...values.keys()].filter((key) => !prefix || key.startsWith(prefix)) + }; + const rootStorage = { + driver: {}, + getMount: () => (mountAvailable ? { base: "/configured", driver: rootStorage.driver } : {}) + }; + return { + values, + failures, + deleteFailures, + storage, + rootStorage, + setMountAvailable: (value: boolean) => (mountAvailable = value) + }; +}); + +vi.mock("nitropack/runtime", () => ({ + useStorage: (mount?: string) => (mount ? runtime.storage : runtime.rootStorage) +})); + +const { createAssetCacheStorage } = await import("../src/runtime/assets/cache"); +const { pruneAssetCache } = await import("../src/runtime/assets/prune"); +const { DIRECTUS_ASSET_CACHE_PREFIX } = await import("../src/runtime/assets/prune"); + +const now = 10_000_000; +const config = { + enabled: true as const, + storage: "directus-assets", + maxAge: 60, + maxBodySize: 10 * 1024 * 1024, + swr: false, + staleMaxAge: undefined, + prune: { enabled: true, onRequest: true, interval: 3600 } +}; + +async function put(key: string, value: unknown) { + await createAssetCacheStorage("directus-assets").set(key, value); +} + +describe("Directus asset-cache pruning", () => { + beforeEach(() => { + runtime.values.clear(); + runtime.failures.clear(); + runtime.deleteFailures.clear(); + runtime.rootStorage.driver = {}; + runtime.setMountAvailable(true); + }); + + it.each([ + ["at the expiry boundary", 60_000, false], + ["one millisecond after expiry", 60_001, true] + ])("uses strict ocache expiry semantics %s", async (_label, age, removed) => { + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}entry.json`, { mtime: now - age }); + const result = await pruneAssetCache(config, now); + expect(result.removed).toBe(removed ? 1 : 0); + }); + + it("supports finite and unbounded SWR lifetimes", async () => { + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}finite.json`, { + mtime: now - 91_000, + staleMaxAge: 30 + }); + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}unbounded.json`, { + mtime: now - 1_000_000 + }); + const finite = await pruneAssetCache({ ...config, swr: true, staleMaxAge: undefined }, now); + expect(finite.removed).toBe(1); + expect(finite.retained).toBe(1); + }); + + it("honors stored lifetime overrides, including zero", async () => { + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}max-age.json`, { + mtime: now - 61_000, + maxAge: 120 + }); + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}stale-zero.json`, { + mtime: now - 61_000, + staleMaxAge: 0 + }); + const result = await pruneAssetCache({ ...config, swr: true, staleMaxAge: 120 }, now); + expect(result).toEqual({ scanned: 2, removed: 1, retained: 1, skipped: 0 }); + }); + + it("removes malformed frames and decoded metadata but skips backend failures", async () => { + const malformedFrame = `${DIRECTUS_ASSET_CACHE_PREFIX}frame.json`; + runtime.values.set(malformedFrame, new Uint8Array([1, 2, 3])); + const invalidMtime = `${DIRECTUS_ASSET_CACHE_PREFIX}mtime.json`; + await put(invalidMtime, { mtime: -1 }); + const readFailure = `${DIRECTUS_ASSET_CACHE_PREFIX}read.json`; + await put(readFailure, { mtime: now - 100_000 }); + runtime.failures.add(readFailure); + const deleteFailure = `${DIRECTUS_ASSET_CACHE_PREFIX}delete.json`; + await put(deleteFailure, { mtime: now - 100_000 }); + runtime.deleteFailures.add(deleteFailure); + const later = `${DIRECTUS_ASSET_CACHE_PREFIX}later.json`; + await put(later, { mtime: now - 100_000 }); + + const result = await pruneAssetCache(config, now); + expect(result).toEqual({ scanned: 5, removed: 3, retained: 0, skipped: 2 }); + expect(runtime.values.has(readFailure)).toBe(true); + expect(runtime.values.has(deleteFailure)).toBe(true); + expect(runtime.values.has(later)).toBe(false); + }); + + it("removes entries with invalid present lifetime metadata", async () => { + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}max-age.json`, { mtime: now, maxAge: -1 }); + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}stale-age.json`, { + mtime: now, + staleMaxAge: "invalid" + }); + const result = await pruneAssetCache(config, now); + expect(result.removed).toBe(2); + }); + + it("skips enumeration when the driver advertises native TTL", async () => { + runtime.rootStorage.driver = { flags: { ttl: true } }; + const result = await pruneAssetCache(config, now); + expect(result).toEqual({ scanned: 0, removed: 0, retained: 0, skipped: 0 }); + }); + + it("keeps unrelated storage keys untouched", async () => { + await put("/cache:other:key.json", { mtime: now - 100_000 }); + await pruneAssetCache(config, now); + expect(runtime.values.has("/cache:other:key.json")).toBe(true); + }); + + it("throws when the configured storage mount is missing", async () => { + runtime.setMountAvailable(false); + await expect(pruneAssetCache(config, now)).rejects.toThrow("not configured"); + }); +}); diff --git a/modules/directus-client/__tests__/options.test.ts b/modules/directus-client/__tests__/options.test.ts index ba8e01c3..cfae949f 100644 --- a/modules/directus-client/__tests__/options.test.ts +++ b/modules/directus-client/__tests__/options.test.ts @@ -204,8 +204,40 @@ describe("Directus module options", () => { maxAge: 60, maxBodySize: 10 * 1024 * 1024, swr: false, - prune: { enabled: false, onRequest: true, interval: 3600, task: { enabled: false } } + prune: { enabled: false, onRequest: true, interval: 3600 } }); + expect( + directusClientOptionsSchema.parse({ + client: { + assets: { + cache: { + enabled: true, + storage: "assets", + maxAge: 60, + prune: { enabled: true } + } + } + } + }).client.assets.cache.prune + ).toEqual({ enabled: true, onRequest: true, interval: 3600 }); + expect(() => + directusClientOptionsSchema.parse({ + client: { + assets: { + cache: { enabled: true, storage: "assets", maxAge: 60, prune: { interval: 0 } } + } + } + }) + ).toThrow(); + expect(() => + directusClientOptionsSchema.parse({ + client: { + assets: { + cache: { enabled: true, storage: "assets", maxAge: 60, prune: { interval: -1 } } + } + } + }) + ).toThrow(); expect(() => directusClientOptionsSchema.parse({ client: { assets: { cache: { enabled: true, storage: " ", maxAge: 60 } } } diff --git a/modules/directus-client/src/runtime/assets/cache.ts b/modules/directus-client/src/runtime/assets/cache.ts index fc2541be..46dfaa04 100644 --- a/modules/directus-client/src/runtime/assets/cache.ts +++ b/modules/directus-client/src/runtime/assets/cache.ts @@ -8,17 +8,10 @@ import type { H3Event } from "h3"; import type { ResolvedDirectusAssetCacheOptions } from "@onderwijsin/nuxt-directus-config/schema"; import { useNitroApp, useStorage } from "nitropack/runtime"; -type AssetCacheConfig = Omit< - Extract, - "prune" -> & { - prune?: { - enabled: boolean; - onRequest: boolean; - interval: number; - task: { enabled: boolean; schedule?: string }; - }; -}; +export type EnabledDirectusAssetCacheConfig = Extract< + ResolvedDirectusAssetCacheOptions, + { enabled: true } +>; export const DIRECTUS_ASSET_CACHE_BASE = "/cache"; export const DIRECTUS_ASSET_CACHE_GROUP = "handlers"; @@ -27,15 +20,12 @@ export const DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; /** Nitro-application-owned lazy state for one immutable asset cache handler. */ export interface DirectusAssetCacheState { handler?: CachedEventHandler; - lastPruneAttemptAt?: number; - prunePromise?: Promise; + prune: DirectusAssetCachePruneState; } -export interface AssetCachePruneSummary { - scanned: number; - removed: number; - retained: number; - skipped: number; +export interface DirectusAssetCachePruneState { + lastAttemptAt?: number; + promise?: Promise; } /** @@ -91,7 +81,7 @@ export function createAssetCacheStorage(mount: string) { * @returns Empty application-owned cache state. */ export function createAssetCacheState(): DirectusAssetCacheState { - return {}; + return { prune: {} }; } /** @@ -104,7 +94,7 @@ export function createAssetCacheState(): DirectusAssetCacheState { */ export function getOrCreateAssetCacheHandler( state: DirectusAssetCacheState, - config: AssetCacheConfig, + config: EnabledDirectusAssetCacheConfig, fetchAnonymous: (event: HTTPEvent) => Promise ): CachedEventHandler { state.handler ??= defineCachedHandler(fetchAnonymous, { @@ -144,7 +134,7 @@ export function getOrCreateAssetCacheHandler( * @returns The application-scoped cached handler. */ export function getAssetCacheHandler( - config: AssetCacheConfig, + config: EnabledDirectusAssetCacheConfig, fetchAnonymous: (event: HTTPEvent) => Promise ): CachedEventHandler { const state = useNitroApp().directusAssetCache; @@ -152,6 +142,16 @@ export function getAssetCacheHandler( return getOrCreateAssetCacheHandler(state, config, fetchAnonymous); } +/** Returns the application-owned asset-cache state used by runtime orchestration. + * + * @returns The current Nitro application's asset-cache state. + */ +export function getAssetCacheState(): DirectusAssetCacheState { + const state = useNitroApp().directusAssetCache; + if (!state) throw new Error("Directus asset cache plugin is not registered"); + return state; +} + /** * Adapts the current H3 event to ocache's portable HTTP event shape. * diff --git a/modules/directus-client/src/runtime/assets/cached-handler.ts b/modules/directus-client/src/runtime/assets/cached-handler.ts index e5127400..6ef46cab 100644 --- a/modules/directus-client/src/runtime/assets/cached-handler.ts +++ b/modules/directus-client/src/runtime/assets/cached-handler.ts @@ -1,11 +1,11 @@ import { assertMethod, defineEventHandler, getRequestHeaders, getRequestURL } from "h3"; import { useRuntimeConfig } from "#imports"; import type { HTTPEvent } from "ocache"; -import { getAssetCacheHandler, createAssetCacheEvent } from "./cache"; +import { getAssetCacheHandler, createAssetCacheEvent, getAssetCacheState } from "./cache"; import { resolveAssetWithSessionFallback, type AssetAuthenticationOptions } from "./authentication"; import { fetchDirectusAsset, getAssetRequestHeaders, type AssetRequestMethod } from "./transport"; import { resolveDirectusAssetUrl } from "./url"; -import { getAssetCacheState, maybePruneAssetCache } from "./prune"; +import { scheduleAssetCachePrune } from "./prune-coordinator"; function fetchAnonymousAsset(cachedEvent: HTTPEvent): Promise { return fetchDirectusAsset(cachedEvent.req.url, { @@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => { if (!(cachedResponse instanceof Response)) { throw new Error("Directus asset cache returned an invalid response"); } - const prunePromise = maybePruneAssetCache(getAssetCacheState(), cache); + const prunePromise = scheduleAssetCachePrune(getAssetCacheState(), cache); if (prunePromise) event.waitUntil(prunePromise); const authentication: AssetAuthenticationOptions = { authEnabled: config.directusClient.auth.enabled, diff --git a/modules/directus-client/src/runtime/assets/prune-coordinator.ts b/modules/directus-client/src/runtime/assets/prune-coordinator.ts new file mode 100644 index 00000000..d481a337 --- /dev/null +++ b/modules/directus-client/src/runtime/assets/prune-coordinator.ts @@ -0,0 +1,34 @@ +import type { EnabledDirectusAssetCacheConfig, DirectusAssetCacheState } from "./cache"; +import { pruneAssetCache } from "./prune"; + +/** Schedules one throttled, best-effort asset-cache prune attempt. + * + * @param state Application-owned cache state. + * @param config Resolved asset-cache configuration. + * @returns The in-flight background operation, or `undefined` when disabled/throttled. + */ +export function scheduleAssetCachePrune( + state: DirectusAssetCacheState, + config: EnabledDirectusAssetCacheConfig +): Promise | undefined { + if (config.prune.enabled !== true || config.prune.onRequest !== true) return undefined; + const now = Date.now(); + if (state.prune.promise) return state.prune.promise; + if ( + state.prune.lastAttemptAt !== undefined && + now - state.prune.lastAttemptAt < config.prune.interval * 1000 + ) { + return undefined; + } + + state.prune.lastAttemptAt = now; + state.prune.promise = pruneAssetCache(config) + .then(() => undefined) + .catch((error: unknown) => { + console.error("[directus-client] Asset cache pruning failed.", error); + }) + .finally(() => { + state.prune.promise = undefined; + }); + return state.prune.promise; +} diff --git a/modules/directus-client/src/runtime/assets/prune.ts b/modules/directus-client/src/runtime/assets/prune.ts index 09ccf357..6e3f5dc0 100644 --- a/modules/directus-client/src/runtime/assets/prune.ts +++ b/modules/directus-client/src/runtime/assets/prune.ts @@ -1,7 +1,6 @@ -import type { ResolvedDirectusAssetCacheOptions } from "@onderwijsin/nuxt-directus-config/schema"; -import { isFiniteNumber, isRecord } from "@onderwijsin/nuxt-module-utils/shared"; -import { useNitroApp, useRuntimeConfig, useStorage } from "nitropack/runtime"; -import type { AssetCachePruneSummary, DirectusAssetCacheState } from "./cache"; +import { attempt, isFiniteNumber, isRecord } from "@onderwijsin/nuxt-module-utils/shared"; +import { useStorage } from "nitropack/runtime"; +import type { EnabledDirectusAssetCacheConfig } from "./cache"; import { createAssetCacheStorage, DIRECTUS_ASSET_CACHE_BASE, @@ -9,46 +8,55 @@ import { DIRECTUS_ASSET_CACHE_NAME } from "./cache"; -type AssetCacheConfig = Omit< - Extract, - "prune" -> & { - prune?: { - enabled: boolean; - onRequest: boolean; - interval: number; - task: { enabled: boolean; schedule?: string }; - }; -}; +export interface AssetCachePruneSummary { + scanned: number; + removed: number; + retained: number; + skipped: number; +} export const DIRECTUS_ASSET_CACHE_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; -function isCacheDuration(value: unknown): value is number { - return isFiniteNumber(value) && value >= 0; -} +type AssetCacheEntryDisposition = "retain" | "expired" | "malformed"; -function isExpiredEntry( +function resolveDuration( entry: Record, - config: AssetCacheConfig, - now: number -): boolean | undefined { - if (!isFiniteNumber(entry.mtime)) return undefined; - const maxAge = isCacheDuration(entry.maxAge) ? entry.maxAge : config.maxAge; - if (config.swr !== true) return entry.mtime + maxAge * 1000 <= now; + key: "maxAge" | "staleMaxAge", + fallback: number | undefined +): number | undefined { + const value = entry[key]; + if (value === undefined || value === null) return fallback; + return isFiniteNumber(value) && value >= 0 ? value : undefined; +} - const staleMaxAge = isCacheDuration(entry.staleMaxAge) ? entry.staleMaxAge : config.staleMaxAge; - if (staleMaxAge === undefined) return false; - return entry.mtime + (maxAge + staleMaxAge) * 1000 <= now; +function classifyEntry( + entry: unknown, + config: EnabledDirectusAssetCacheConfig, + now: number +): AssetCacheEntryDisposition { + if (!isRecord(entry) || !isFiniteNumber(entry.mtime) || entry.mtime < 0) return "malformed"; + const maxAge = resolveDuration(entry, "maxAge", config.maxAge); + const staleMaxAge = resolveDuration(entry, "staleMaxAge", config.staleMaxAge); + if ( + maxAge === undefined || + (entry.staleMaxAge !== null && entry.staleMaxAge !== undefined && staleMaxAge === undefined) + ) { + return "malformed"; + } + const age = now - entry.mtime; + if (config.swr !== true) return age > maxAge * 1000 ? "expired" : "retain"; + if (staleMaxAge === undefined) return "retain"; + return age > (maxAge + staleMaxAge) * 1000 ? "expired" : "retain"; } -/** Removes expired Directus asset entries from the configured cache namespace. +/** Removes expired or unusable Directus asset-cache entries from owned storage. * * @param config Resolved asset-cache configuration. * @param now Current Unix timestamp in milliseconds. * @returns Counts for the sweep. */ export async function pruneAssetCache( - config: AssetCacheConfig, + config: EnabledDirectusAssetCacheConfig, now = Date.now() ): Promise { const rootStorage = useStorage(); @@ -71,88 +79,20 @@ export async function pruneAssetCache( }; for (const key of keys) { - try { - const entry = await cacheStorage.get(key); - if (!isRecord(entry)) { - summary.skipped++; - continue; - } - const expired = isExpiredEntry(entry, config, now); - if (expired === undefined) { - summary.skipped++; - } else if (expired) { - await cacheStorage.set(key, null); - summary.removed++; - } else { - summary.retained++; - } - } catch { + const read = await attempt(() => cacheStorage.get(key)); + if (read.error !== null) { summary.skipped++; + continue; + } + const disposition = classifyEntry(read.data, config, now); + if (read.data === null || disposition === "malformed" || disposition === "expired") { + const removal = await attempt(() => cacheStorage.set(key, null)); + if (removal.error !== null) summary.skipped++; + else summary.removed++; + } else { + summary.retained++; } } return summary; } - -/** Schedules one best-effort, application-local asset-cache prune attempt. - * - * @param state Application-owned cache state. - * @param config Resolved asset-cache configuration. - * @returns The in-flight sweep, or `undefined` when throttled or disabled. - */ -export function maybePruneAssetCache( - state: DirectusAssetCacheState, - config: AssetCacheConfig -): Promise | undefined { - const prune = config.prune ?? { - enabled: false, - onRequest: true, - interval: 3600, - task: { enabled: false } - }; - if (prune.enabled !== true || prune.onRequest !== true) return undefined; - const now = Date.now(); - if (state.prunePromise) return state.prunePromise; - if ( - state.lastPruneAttemptAt !== undefined && - now - state.lastPruneAttemptAt < prune.interval * 1000 - ) { - return undefined; - } - - state.lastPruneAttemptAt = now; - state.prunePromise = pruneAssetCache(config).finally(() => { - state.prunePromise = undefined; - }); - return state.prunePromise; -} - -/** Runs the reusable asset-cache pruning operation as a Nitro task. - * - * @returns Nitro task result counts. - */ -export async function runAssetCachePruneTask(): Promise<{ result: AssetCachePruneSummary }> { - const config = useRuntimeConfig().directusClient.assets.cache; - const configRecord: Record = isRecord(config) ? config : {}; - const pruning = isRecord(configRecord.prune) ? configRecord.prune : undefined; - if ( - config.enabled !== true || - !pruning || - pruning.enabled !== true || - !isRecord(pruning.task) || - pruning.task.enabled !== true - ) { - return { result: { scanned: 0, removed: 0, retained: 0, skipped: 0 } }; - } - return { result: await pruneAssetCache(config) }; -} - -/** Returns the application-owned asset-cache state used by request orchestration. - * - * @returns The current Nitro application's asset-cache state. - */ -export function getAssetCacheState(): DirectusAssetCacheState { - const state = useNitroApp().directusAssetCache; - if (!state) throw new Error("Directus asset cache plugin is not registered"); - return state; -} diff --git a/modules/directus-client/src/runtime/tasks/prune.ts b/modules/directus-client/src/runtime/tasks/prune.ts index 9301d6e1..88a3487f 100644 --- a/modules/directus-client/src/runtime/tasks/prune.ts +++ b/modules/directus-client/src/runtime/tasks/prune.ts @@ -1,5 +1,5 @@ -import { defineTask } from "nitropack/runtime"; -import { runAssetCachePruneTask } from "../assets/prune"; +import { defineTask, useRuntimeConfig } from "nitropack/runtime"; +import { pruneAssetCache } from "../assets/prune"; /** Prunes stale Directus asset-cache entries when explicitly enabled by the consumer. */ export default defineTask({ @@ -7,5 +7,11 @@ export default defineTask({ name: "directus-assets:prune", description: "Remove stale Directus asset-cache entries." }, - run: runAssetCachePruneTask + async run() { + const config = useRuntimeConfig().directusClient.assets.cache; + if (config.enabled !== true || config.prune.enabled !== true) { + return { result: { scanned: 0, removed: 0, retained: 0, skipped: 0 } }; + } + return { result: await pruneAssetCache(config) }; + } }); diff --git a/modules/directus-client/src/runtime/typegen/config.d.ts b/modules/directus-client/src/runtime/typegen/config.d.ts index 19c98f62..e556f5e7 100644 --- a/modules/directus-client/src/runtime/typegen/config.d.ts +++ b/modules/directus-client/src/runtime/typegen/config.d.ts @@ -36,11 +36,10 @@ declare module "nuxt/schema" { maxBodySize: number; swr: boolean; staleMaxAge?: number; - prune?: { + prune: { enabled: boolean; onRequest: boolean; interval: number; - task: { enabled: boolean; schedule?: string }; }; }; }; diff --git a/modules/directus-config/README.md b/modules/directus-config/README.md index 8354bad7..f98a2c92 100644 --- a/modules/directus-config/README.md +++ b/modules/directus-config/README.md @@ -93,43 +93,42 @@ the application proxy. `client` contains Directus client module settings. Its nested schemas provide defaults. -| Option | Default | Description | -| --------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------- | -| `proxy.path` | `/_directus/proxy` | Local proxy route. It cannot be root, contain traversal segments, or overlap `/_directus/auth`. | -| `assets.enabled` | `true` | Enables the dedicated Directus `/assets` proxy route. | -| `assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `assets.path` | `/_directus/assets` | Local asset proxy route using the same safe path validation as `proxy.path`. | -| `assets.publicOnly` | `false` | Uses anonymous asset requests only and never attempts session authentication when enabled. | -| `assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | -| `assets.cache.storage` | — | Nitro storage mount name; required when caching is enabled and must support raw binary values. | -| `assets.cache.maxAge` | — | Fresh cache lifetime in seconds; required to be a positive integer when caching is enabled. | -| `assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. | -| `assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. | -| `assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. | -| `assets.cache.prune.enabled` | `false` | Opts into best-effort pruning of expired entries in storage without a native TTL guarantee. | -| `assets.cache.prune.onRequest` | `true` | Runs throttled pruning in the background after cached asset requests. | -| `assets.cache.prune.interval` | `3600` | Minimum request-triggered prune interval in seconds. | -| `assets.cache.prune.task.enabled` | `false` | Enables a consumer-registered Nitro prune task. | -| `commands` | `readItem`, `readItems` | SDK commands that the Directus client module auto-imports. | -| `preview.enabled` | `false` | Enables preview query parsing; set to `true` to opt in. | -| `preview.versioning` | `true` | Enables Content Version preview lookup. | -| `preview.queryKeys` | `preview`, `token`, `version`, `id` | Preview query parameter names. | -| `auth.enabled` | `false` | Enables cookie-backed authentication. | -| `auth.turnstile.enabled` | `false` | Enables Turnstile protection for authentication requests. | -| `auth.magicLinks.enabled` | `false` | Enables optional Directus magic-link authentication routes; requires `auth.enabled`. | -| `auth.magicLinks.redirectUrl` | — | Absolute, server-only callback URL required when magic links are enabled. | -| `auth.cookie` | See below | Session-cookie settings: `name`, `secure`, `sameSite`, `path`, `maxAge`, and optional `domain`. | -| `auth.refreshSafetyWindow` | `30000` | Milliseconds before expiry when a session is refreshed. | -| `auth.sessionSecret` | — | Server-only H3 sealing secret; required for enabled auth and must contain at least 32 characters. | -| `auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during staged key rotation. | -| `auth.maskSecretsInPlayground` | `true` | Masks access and refresh tokens in the local session inspection playground. | -| `auth.passwordResetUrl` | — | URL sent to Directus for password-reset requests. | -| `typegen.enabled` | `true` | Enables generated `#directus` schema declarations. | -| `typegen.introspectionToken` | — | Server-only schema-introspection token. | -| `typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `typegen.augmentations` | All `true` | Generated-source transforms. | -| `typegen.rules` | `{}` | Collection and field type-expression overrides. | -| `typegen.transform` | — | Final executable source transform. | +| Option | Default | Description | +| ------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `proxy.path` | `/_directus/proxy` | Local proxy route. It cannot be root, contain traversal segments, or overlap `/_directus/auth`. | +| `assets.enabled` | `true` | Enables the dedicated Directus `/assets` proxy route. | +| `assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `assets.path` | `/_directus/assets` | Local asset proxy route using the same safe path validation as `proxy.path`. | +| `assets.publicOnly` | `false` | Uses anonymous asset requests only and never attempts session authentication when enabled. | +| `assets.cache.enabled` | `false` | Enables server-side caching for explicitly public anonymous asset responses. | +| `assets.cache.storage` | — | Nitro storage mount name; required when caching is enabled and must support raw binary values. | +| `assets.cache.maxAge` | — | Fresh cache lifetime in seconds; required to be a positive integer when caching is enabled. | +| `assets.cache.maxBodySize` | `10485760` | Maximum response size in bytes that may be buffered for caching. | +| `assets.cache.swr` | `false` | Enables stale-while-revalidate behavior. | +| `assets.cache.staleMaxAge` | — | Optional non-negative stale lifetime in seconds. | +| `assets.cache.prune.enabled` | `false` | Opts into best-effort pruning of expired entries in storage without a native TTL guarantee. | +| `assets.cache.prune.onRequest` | `true` | Runs throttled pruning in the background after cached asset requests. | +| `assets.cache.prune.interval` | `3600` | Minimum request-triggered prune interval in seconds. | +| `commands` | `readItem`, `readItems` | SDK commands that the Directus client module auto-imports. | +| `preview.enabled` | `false` | Enables preview query parsing; set to `true` to opt in. | +| `preview.versioning` | `true` | Enables Content Version preview lookup. | +| `preview.queryKeys` | `preview`, `token`, `version`, `id` | Preview query parameter names. | +| `auth.enabled` | `false` | Enables cookie-backed authentication. | +| `auth.turnstile.enabled` | `false` | Enables Turnstile protection for authentication requests. | +| `auth.magicLinks.enabled` | `false` | Enables optional Directus magic-link authentication routes; requires `auth.enabled`. | +| `auth.magicLinks.redirectUrl` | — | Absolute, server-only callback URL required when magic links are enabled. | +| `auth.cookie` | See below | Session-cookie settings: `name`, `secure`, `sameSite`, `path`, `maxAge`, and optional `domain`. | +| `auth.refreshSafetyWindow` | `30000` | Milliseconds before expiry when a session is refreshed. | +| `auth.sessionSecret` | — | Server-only H3 sealing secret; required for enabled auth and must contain at least 32 characters. | +| `auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during staged key rotation. | +| `auth.maskSecretsInPlayground` | `true` | Masks access and refresh tokens in the local session inspection playground. | +| `auth.passwordResetUrl` | — | URL sent to Directus for password-reset requests. | +| `typegen.enabled` | `true` | Enables generated `#directus` schema declarations. | +| `typegen.introspectionToken` | — | Server-only schema-introspection token. | +| `typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `typegen.augmentations` | All `true` | Generated-source transforms. | +| `typegen.rules` | `{}` | Collection and field type-expression overrides. | +| `typegen.transform` | — | Final executable source transform. | Asset caching is disabled by default. `assets.cache.storage` names a Nitro storage mount supplied by the application; the module does not create or choose its driver. Use filesystem storage for Node @@ -137,6 +136,11 @@ deployments and a raw-byte-capable mount such as Cloudflare R2 for Cloudflare de Cloudflare KV's text-only storage is not recommended. Authenticated or private assets are never cached. +The resolved prune configuration is `{ enabled: false, onRequest: true, interval: 3600 }`. Pruning +is opt-in and does not enable Nitro tasks. To use scheduled or manual pruning, the consumer creates +its own task file re-exporting `@onderwijsin/nuxt-directus-client/runtime/prune-task`, explicitly +enables Nitro experimental tasks, and optionally configures `nitro.scheduledTasks`. + Magic links require the `directus-magic-links-bundle` extension in Directus. The configured callback URL is server-only and is not included in the client-safe configuration. diff --git a/modules/directus-config/src/schema/client.ts b/modules/directus-config/src/schema/client.ts index fc47b3aa..4bb1e3f4 100644 --- a/modules/directus-config/src/schema/client.ts +++ b/modules/directus-config/src/schema/client.ts @@ -39,15 +39,9 @@ const assetCacheSchema = z.discriminatedUnion("enabled", [ .strictObject({ enabled: z.boolean().default(false), onRequest: z.boolean().default(true), - interval: z.number().int().positive().default(3600), - task: z - .strictObject({ - enabled: z.boolean().default(false), - schedule: z.string().trim().min(1).optional() - }) - .default({ enabled: false }) + interval: z.number().int().positive().default(3600) }) - .default({ enabled: false, onRequest: true, interval: 3600, task: { enabled: false } }) + .default({ enabled: false, onRequest: true, interval: 3600 }) }) ]); diff --git a/skills/nuxt-directus-client/SKILL.md b/skills/nuxt-directus-client/SKILL.md index 04c4e96d..b1d700e2 100644 --- a/skills/nuxt-directus-client/SKILL.md +++ b/skills/nuxt-directus-client/SKILL.md @@ -66,51 +66,50 @@ private. Do not place these values in `runtimeConfig.public` or browser code. All options are configured under `directusClient`. -| Option | Default | Contract | -| ---------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `enabled` | `true` | Enables the module. | -| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | -| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | -| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | -| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | -| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | -| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path using the same safe validation; it cannot overlap the REST proxy or reserved auth routes. | -| `client.assets.publicOnly` | `false` | Keeps asset requests anonymous and never escalates to the current session when enabled. | -| `client.assets.cache.enabled` | `false` | Enables server-side caching of explicitly public anonymous assets. | -| `client.assets.cache.storage` | — | Name of an application-provided Nitro raw-byte storage mount; required when enabled. | -| `client.assets.cache.maxAge` | — | Positive cache lifetime in seconds; required when enabled. | -| `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.assets.cache.prune.task.enabled` | `false` | Enables the exported Nitro prune task; task registration and scheduling remain consumer-owned. | -| `client.commands` | `[readItem, readItems]` | SDK command names 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. | -| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | -| `client.auth.enabled` | `false` | Enables cookie authentication and registers authentication routes plus `useDirectusAuth`. | -| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | -| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | -| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute server-only callback URL; required when magic links are enabled. | -| `client.auth.cookie.name` | `directus_session` | Session cookie name. | -| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | -| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | -| `client.auth.cookie.path` | `/` | Cookie path. | -| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | -| `client.auth.cookie.domain` | — | Optional cookie domain. | -| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | -| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | -| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | -| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | -| `client.auth.passwordResetUrl` | — | Required for password-request support; sent as Directus `reset_url`. | -| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | -| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | -| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | -| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | -| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | -| `client.typegen.transform` | — | Final build-time source transform. | +| Option | Default | Contract | +| ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true` | Enables the module. | +| `instance.baseUrl` | — | Optional Directus URL. Required before requests can run. | +| `instance.proxyToken` | — | Server-held credential delegated through the proxy; its permissions must be safe for public callers. | +| `client.proxy.path` | `/_directus/proxy` | Absolute local same-origin browser proxy path. Root paths, auth-route collisions, and overlaps with `client.assets.path` are rejected. | +| `client.assets.enabled` | `true` | Registers the dedicated Directus `/assets` proxy when enabled. | +| `client.assets.url` | — | Optional absolute upstream asset base URL; defaults to `instance.baseUrl` with `/assets`. | +| `client.assets.path` | `/_directus/assets` | Absolute local asset-proxy path using the same safe validation; it cannot overlap the REST proxy or reserved auth routes. | +| `client.assets.publicOnly` | `false` | Keeps asset requests anonymous and never escalates to the current session when enabled. | +| `client.assets.cache.enabled` | `false` | Enables server-side caching of explicitly public anonymous assets. | +| `client.assets.cache.storage` | — | Name of an application-provided Nitro raw-byte storage mount; required when enabled. | +| `client.assets.cache.maxAge` | — | Positive cache lifetime in seconds; required when enabled. | +| `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 command names 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. | +| `client.preview.queryKeys` | `preview`, `token`, `version`, `id` | Query parameter names used for preview context. | +| `client.auth.enabled` | `false` | Enables cookie authentication and registers authentication routes plus `useDirectusAuth`. | +| `client.auth.turnstile.enabled` | `false` | Registers Turnstile and protects login plus password-reset-email requests. | +| `client.auth.magicLinks.enabled` | `false` | Registers optional magic-link request and redemption routes; requires auth to be enabled. | +| `client.auth.magicLinks.redirectUrl` | — | Fixed absolute server-only callback URL; required when magic links are enabled. | +| `client.auth.cookie.name` | `directus_session` | Session cookie name. | +| `client.auth.cookie.secure` | `true` | Sends the cookie only over HTTPS. Use `false` only for local HTTP development. | +| `client.auth.cookie.sameSite` | `lax` | Cookie `SameSite` policy. | +| `client.auth.cookie.path` | `/` | Cookie path. | +| `client.auth.cookie.maxAge` | `2592000` | Cookie lifetime in seconds. | +| `client.auth.cookie.domain` | — | Optional cookie domain. | +| `client.auth.refreshSafetyWindow` | `30000` | Refreshes a session this many milliseconds before expiry. | +| `client.auth.sessionSecret` | — | Server-only H3 sealing secret; required when auth is enabled and must contain at least 32 characters. | +| `client.auth.previousSessionSecrets` | `[]` | Server-only previous sealing secrets tried during key rotation, in order. | +| `client.auth.maskSecretsInPlayground` | `true` | Masks tokens in the local sealed-session playground inspection page. | +| `client.auth.passwordResetUrl` | — | Required for password-request support; sent as Directus `reset_url`. | +| `client.typegen.enabled` | `true` | Enables generated `#directus` declarations. | +| `client.typegen.introspectionToken` | — | Server-only Directus schema introspection token. | +| `client.typegen.cache.maxAge` | `3600000` | Development type-generation cache lifetime in milliseconds. | +| `client.typegen.augmentations` | all `true` | Optional generated-output transforms. | +| `client.typegen.rules` | `{}` | Generated field type overrides keyed by collection and field. | +| `client.typegen.transform` | — | Final build-time source transform. | The module validates option values during Nuxt configuration. `instance.baseUrl` is optional, but requests cannot run without it; the module skips setup during `nuxt prepare` and CI when it is @@ -125,11 +124,12 @@ Cache-disabled asset requests use a streaming proxy. Cached delivery is applicat anonymous-only; session-backed asset responses always receive `Cache-Control: private, no-store`. Asset-cache pruning is disabled by default. Enable `client.assets.cache.prune.enabled` for backends -without reliable physical TTLs; request-triggered cleanup is backgrounded and throttled. For -scheduled cleanup, also enable `prune.task.enabled`, create `server/tasks/directus-assets/prune.ts` -that re-exports `@onderwijsin/nuxt-directus-client/runtime/prune-task`, then explicitly enable Nitro -experimental tasks and schedule `directus-assets:prune`. Task registration and scheduling remain -consumer-owned. +without reliable physical TTLs; request-triggered cleanup is backgrounded and throttled. For the +resolved configuration is `{ enabled: false, onRequest: true, interval: 3600 }`. For scheduled +cleanup, create `server/tasks/directus-assets/prune.ts` that re-exports +`@onderwijsin/nuxt-directus-client/runtime/prune-task`, then explicitly enable Nitro experimental +tasks and optionally configure `nitro.scheduledTasks`. Task registration and scheduling remain +consumer-owned; `prune.enabled` only enables the pruning logic. When authentication is enabled without an explicit session secret, local development uses a fixed convenience value, while `nuxt prepare` and CI generate a fresh ephemeral cryptographic value. From 8ac1ac3fb598a9bf654746d15279ec353565d1cc Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 19:12:46 +0200 Subject: [PATCH 4/8] refactor(directus-client): use defined guard for pruning --- .../src/runtime/assets/prune-coordinator.ts | 3 ++- .../directus-client/src/runtime/assets/prune.ts | 15 ++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/modules/directus-client/src/runtime/assets/prune-coordinator.ts b/modules/directus-client/src/runtime/assets/prune-coordinator.ts index d481a337..3dfb4400 100644 --- a/modules/directus-client/src/runtime/assets/prune-coordinator.ts +++ b/modules/directus-client/src/runtime/assets/prune-coordinator.ts @@ -1,3 +1,4 @@ +import { isDefined } from "@onderwijsin/nuxt-module-utils/shared"; import type { EnabledDirectusAssetCacheConfig, DirectusAssetCacheState } from "./cache"; import { pruneAssetCache } from "./prune"; @@ -15,7 +16,7 @@ export function scheduleAssetCachePrune( const now = Date.now(); if (state.prune.promise) return state.prune.promise; if ( - state.prune.lastAttemptAt !== undefined && + isDefined(state.prune.lastAttemptAt) && now - state.prune.lastAttemptAt < config.prune.interval * 1000 ) { return undefined; diff --git a/modules/directus-client/src/runtime/assets/prune.ts b/modules/directus-client/src/runtime/assets/prune.ts index 6e3f5dc0..75667fe3 100644 --- a/modules/directus-client/src/runtime/assets/prune.ts +++ b/modules/directus-client/src/runtime/assets/prune.ts @@ -1,4 +1,9 @@ -import { attempt, isFiniteNumber, isRecord } from "@onderwijsin/nuxt-module-utils/shared"; +import { + attempt, + isDefined, + isFiniteNumber, + isRecord +} from "@onderwijsin/nuxt-module-utils/shared"; import { useStorage } from "nitropack/runtime"; import type { EnabledDirectusAssetCacheConfig } from "./cache"; import { @@ -25,7 +30,7 @@ function resolveDuration( fallback: number | undefined ): number | undefined { const value = entry[key]; - if (value === undefined || value === null) return fallback; + if (!isDefined(value) || value === null) return fallback; return isFiniteNumber(value) && value >= 0 ? value : undefined; } @@ -38,14 +43,14 @@ function classifyEntry( const maxAge = resolveDuration(entry, "maxAge", config.maxAge); const staleMaxAge = resolveDuration(entry, "staleMaxAge", config.staleMaxAge); if ( - maxAge === undefined || - (entry.staleMaxAge !== null && entry.staleMaxAge !== undefined && staleMaxAge === undefined) + !isDefined(maxAge) || + (isDefined(entry.staleMaxAge) && entry.staleMaxAge !== null && !isDefined(staleMaxAge)) ) { return "malformed"; } const age = now - entry.mtime; if (config.swr !== true) return age > maxAge * 1000 ? "expired" : "retain"; - if (staleMaxAge === undefined) return "retain"; + if (!isDefined(staleMaxAge)) return "retain"; return age > (maxAge + staleMaxAge) * 1000 ? "expired" : "retain"; } From 6a159038c0f388c3eee7588f5ede25db3dc2ac25 Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 20:03:38 +0200 Subject: [PATCH 5/8] fix(directus-client): harden asset cache pruning --- .../__tests__/asset-prune.e2e.test.ts | 80 +++++++++++++++++++ .../__tests__/asset-prune.test.ts | 43 +++++++++- .../__tests__/fixtures/prune/nuxt.config.ts | 30 +++++++ .../api/directus-asset-cache-foreign.post.ts | 6 ++ .../api/directus-asset-cache-keys.get.ts | 3 + .../src/runtime/assets/prune.ts | 20 ++++- 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 modules/directus-client/__tests__/asset-prune.e2e.test.ts create mode 100644 modules/directus-client/__tests__/fixtures/prune/nuxt.config.ts create mode 100644 modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-foreign.post.ts create mode 100644 modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-keys.get.ts diff --git a/modules/directus-client/__tests__/asset-prune.e2e.test.ts b/modules/directus-client/__tests__/asset-prune.e2e.test.ts new file mode 100644 index 00000000..57e1cce9 --- /dev/null +++ b/modules/directus-client/__tests__/asset-prune.e2e.test.ts @@ -0,0 +1,80 @@ +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((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 { + return await $fetch("/api/directus-asset-cache-keys"); +} + +async function waitFor(condition: () => Promise, timeout = 2_000): Promise { + 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((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("/_directus/assets/asset-a")).resolves.toBe("asset-a"); + 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("/_directus/assets/asset-b")).resolves.toBe("asset-b"); + + const keysAfterB = (await getKeys()).filter((key) => key !== "foreign-key"); + const keysForB = keysAfterB.filter((key) => !keysAfterA.includes(key)); + expect(keysForB.length).toBeGreaterThan(0); + + 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"); + }); +}); diff --git a/modules/directus-client/__tests__/asset-prune.test.ts b/modules/directus-client/__tests__/asset-prune.test.ts index 77bb3ff7..fd2706d1 100644 --- a/modules/directus-client/__tests__/asset-prune.test.ts +++ b/modules/directus-client/__tests__/asset-prune.test.ts @@ -51,7 +51,15 @@ const config = { prune: { enabled: true, onRequest: true, interval: 3600 } }; -async function put(key: string, value: unknown) { +async function put(key: string, value: Record) { + await createAssetCacheStorage("directus-assets").set(key, { + value: { status: 200, headers: {}, body: "asset" }, + payload: "value.body", + ...value + }); +} + +async function putRaw(key: string, value: unknown) { await createAssetCacheStorage("directus-assets").set(key, value); } @@ -99,6 +107,39 @@ describe("Directus asset-cache pruning", () => { expect(result).toEqual({ scanned: 2, removed: 1, retained: 1, skipped: 0 }); }); + it.each([ + ["missing value", { mtime: now }], + ["empty value", { mtime: now, value: {} }], + ["unsuccessful status", { mtime: now, value: { status: 404, headers: {}, body: "asset" } }], + ["missing headers", { mtime: now, value: { status: 200, body: "asset" } }], + ["invalid body", { mtime: now, value: { status: 200, headers: {}, body: {} } }] + ])("removes decoded entries with %s", async (_label, entry) => { + const key = `${DIRECTUS_ASSET_CACHE_PREFIX}malformed.json`; + await putRaw(key, entry); + const result = await pruneAssetCache({ ...config, swr: true, staleMaxAge: undefined }, now); + expect(result).toMatchObject({ scanned: 1, removed: 1, retained: 0, skipped: 0 }); + }); + + it("retains string and binary cached response bodies", async () => { + await putRaw(`${DIRECTUS_ASSET_CACHE_PREFIX}string.json`, { + mtime: now, + value: { status: 200, headers: {}, body: "asset" } + }); + await putRaw(`${DIRECTUS_ASSET_CACHE_PREFIX}binary.json`, { + mtime: now, + value: { status: 200, headers: {}, body: new Uint8Array([1, 2, 3]) }, + payload: "value.body" + }); + const result = await pruneAssetCache({ ...config, swr: true, staleMaxAge: undefined }, now); + expect(result).toMatchObject({ scanned: 2, removed: 0, retained: 2, skipped: 0 }); + }); + + it("expires a stored maxAge of zero immediately", async () => { + await put(`${DIRECTUS_ASSET_CACHE_PREFIX}zero.json`, { mtime: now, maxAge: 0 }); + const result = await pruneAssetCache(config, now); + expect(result.removed).toBe(1); + }); + it("removes malformed frames and decoded metadata but skips backend failures", async () => { const malformedFrame = `${DIRECTUS_ASSET_CACHE_PREFIX}frame.json`; runtime.values.set(malformedFrame, new Uint8Array([1, 2, 3])); diff --git a/modules/directus-client/__tests__/fixtures/prune/nuxt.config.ts b/modules/directus-client/__tests__/fixtures/prune/nuxt.config.ts new file mode 100644 index 00000000..0a1fafd2 --- /dev/null +++ b/modules/directus-client/__tests__/fixtures/prune/nuxt.config.ts @@ -0,0 +1,30 @@ +import directusModule from "../../../src/module"; + +export default defineNuxtConfig({ + modules: [directusModule], + directusClient: { + instance: { + baseUrl: process.env.DIRECTUS_PRUNE_E2E_URL ?? "https://sandbox.directus.com" + }, + client: { + typegen: { enabled: false }, + assets: { + cache: { + enabled: true, + storage: "directus-assets", + maxAge: 1, + swr: false, + prune: { enabled: true, onRequest: true, interval: 1 } + } + } + } + }, + nitro: { + storage: { + "directus-assets": { + driver: "fs", + base: process.env.DIRECTUS_PRUNE_E2E_CACHE_DIR + } + } + } +}); diff --git a/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-foreign.post.ts b/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-foreign.post.ts new file mode 100644 index 00000000..b5d56b3f --- /dev/null +++ b/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-foreign.post.ts @@ -0,0 +1,6 @@ +import { useStorage } from "nitropack/runtime"; + +export default defineEventHandler(async () => { + await useStorage("directus-assets").setItem("foreign-key", "foreign-value"); + return { created: true }; +}); diff --git a/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-keys.get.ts b/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-keys.get.ts new file mode 100644 index 00000000..5f133124 --- /dev/null +++ b/modules/directus-client/__tests__/fixtures/prune/server/api/directus-asset-cache-keys.get.ts @@ -0,0 +1,3 @@ +import { useStorage } from "nitropack/runtime"; + +export default defineEventHandler(() => useStorage("directus-assets").getKeys()); diff --git a/modules/directus-client/src/runtime/assets/prune.ts b/modules/directus-client/src/runtime/assets/prune.ts index 75667fe3..301ef434 100644 --- a/modules/directus-client/src/runtime/assets/prune.ts +++ b/modules/directus-client/src/runtime/assets/prune.ts @@ -2,7 +2,8 @@ import { attempt, isDefined, isFiniteNumber, - isRecord + isRecord, + isString } from "@onderwijsin/nuxt-module-utils/shared"; import { useStorage } from "nitropack/runtime"; import type { EnabledDirectusAssetCacheConfig } from "./cache"; @@ -21,9 +22,15 @@ export interface AssetCachePruneSummary { } export const DIRECTUS_ASSET_CACHE_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; +const DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE.replaceAll("/", "")}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME.replace(/\W/g, "")}.`; type AssetCacheEntryDisposition = "retain" | "expired" | "malformed"; +function isUsableAssetCacheValue(value: unknown): boolean { + if (!isRecord(value) || value.status !== 200 || !isRecord(value.headers)) return false; + return isString(value.body) || ArrayBuffer.isView(value.body); +} + function resolveDuration( entry: Record, key: "maxAge" | "staleMaxAge", @@ -48,7 +55,9 @@ function classifyEntry( ) { return "malformed"; } + if (!isUsableAssetCacheValue(entry.value)) return "malformed"; const age = now - entry.mtime; + if (maxAge === 0) return "expired"; if (config.swr !== true) return age > maxAge * 1000 ? "expired" : "retain"; if (!isDefined(staleMaxAge)) return "retain"; return age > (maxAge + staleMaxAge) * 1000 ? "expired" : "retain"; @@ -74,7 +83,14 @@ export async function pruneAssetCache( } const storage = useStorage(config.storage); - const keys = await storage.getKeys(DIRECTUS_ASSET_CACHE_PREFIX); + const scopedKeys = await storage.getKeys(DIRECTUS_ASSET_CACHE_PREFIX); + const normalizedKeys = + scopedKeys.length === 0 ? await storage.getKeys(DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX) : []; + const keys = [...new Set([...scopedKeys, ...normalizedKeys])].filter( + (key) => + key.startsWith(DIRECTUS_ASSET_CACHE_PREFIX) || + key.startsWith(DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX) + ); const cacheStorage = createAssetCacheStorage(config.storage); const summary: AssetCachePruneSummary = { scanned: keys.length, From 571d7c7c66309090ba5c01cd93af5e5ba3827002 Mon Sep 17 00:00:00 2001 From: Remi Huigen <83277154+remihuigen@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:24:18 +0200 Subject: [PATCH 6/8] chore: add PR 289 follow-up agent brief --- .../tasks/pr-289-cache-pruning-follow-up.md | 867 ++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 .agents/tasks/pr-289-cache-pruning-follow-up.md diff --git a/.agents/tasks/pr-289-cache-pruning-follow-up.md b/.agents/tasks/pr-289-cache-pruning-follow-up.md new file mode 100644 index 00000000..10f284ff --- /dev/null +++ b/.agents/tasks/pr-289-cache-pruning-follow-up.md @@ -0,0 +1,867 @@ +# PR #289 final follow-up — fix asset-cache pruning namespace and complete E2E + +## Execution instructions + +You are working in `onderwijsin/nuxt-modules` on branch `feat/cache-prune`, PR #289 (`feat(directus-client): prune stale asset cache entries`), based on `fix/asset-handlers`. + +This brief is the design. Execute it exactly. Do not make alternate architectural choices unless the repository proves a stated API is unavailable. If that happens, stop and report the concrete incompatibility instead of inventing another normalization algorithm. + +Before the final implementation commit, remove this task file: + +```text +.agents/tasks/pr-289-cache-pruning-follow-up.md +``` + +Do not leave temporary diagnostics, debug routes, console logging, or this brief in the final tree. + +--- + +## 1. Scope + +PR #289 adds opt-in cleanup of stale Directus asset-cache entries for storage backends that do not physically enforce TTL. + +The feature already has the right architecture and public API. Do **not** redesign it. + +Existing runtime structure should remain: + +```text +modules/directus-client/src/runtime/assets/ + cache.ts + cached-handler.ts + prune.ts + prune-coordinator.ts + +modules/directus-client/src/runtime/tasks/ + prune.ts +``` + +Existing public prune config remains: + +```ts +prune: { + enabled: boolean; + onRequest: boolean; + interval: number; +} +``` + +Resolved defaults remain: + +```ts +{ + enabled: false, + onRequest: true, + interval: 3600 +} +``` + +Do not reintroduce task config (`task.enabled`, `task.schedule`) or automatic Nitro task registration/scheduling. + +This follow-up is specifically about: + +1. fixing real cache namespace enumeration exposed by the new filesystem E2E; +2. removing manual cache-key normalization/reconstruction; +3. making unit tests derive the namespace from ocache rather than from our own assumption; +4. hardening the E2E so it waits for asynchronous cache writes as well as deletion; +5. preserving all existing pruning semantics. + +--- + +## 2. Real bug discovered by E2E + +The new filesystem-backed E2E proved a production integration mismatch. + +The fixture successfully: + +- starts real Nitro; +- serves deterministic upstream assets; +- writes cache data to a real filesystem-backed Nitro storage mount; +- resolves the expected prune config; +- uses a driver with no native TTL flag. + +Physical/logical cache keys exist, but pruning reports: + +```text +scanned: 0 +``` + +Calling the prune utility directly against the same real storage also returns zero scanned entries. + +Therefore this is **not** a timing, `waitUntil`, coordinator, or Nitro-task problem. The failure is namespace enumeration. + +--- + +## 3. Root cause: ocache owns cache-key construction + +The Directus asset cache uses these module-owned constants: + +```ts +DIRECTUS_ASSET_CACHE_BASE = "/cache"; +DIRECTUS_ASSET_CACHE_GROUP = "handlers"; +DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; +``` + +Those constants are correct and should remain the shared source of truth. + +The mistake is assuming the actual storage namespace is simply: + +```text +/cache:handlers:directus-assets: +``` + +It is not. + +Pinned `ocache` 0.3.0 constructs keys with its own `buildCacheKey()` and `escapeKeySegment()` logic. For each `group` and `name` segment, it removes unsupported punctuation and, when a segment changes, appends a hash so the transformation is not lossy. + +Conceptually: + +```text +directus-assets +``` + +becomes: + +```text +directusassets. +``` + +not merely `directusassets`, and not the raw `directus-assets` string. + +After ocache constructs the key, Unstorage performs its own normalization (separator normalization, leading/trailing separator handling, etc.). The filesystem driver then maps `:` separators to directories internally. + +These are different layers. Our module must not reproduce either transformation. + +--- + +## 4. Remove the current manual fallback completely + +Current `prune.ts` contains an attempted workaround similar to: + +```ts +export const DIRECTUS_ASSET_CACHE_PREFIX = + `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; + +const DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX = + `${DIRECTUS_ASSET_CACHE_BASE.replaceAll("/", "")}:` + + `${DIRECTUS_ASSET_CACHE_GROUP}:` + + `${DIRECTUS_ASSET_CACHE_NAME.replace(/\W/g, "")}.`; +``` + +and then does multiple `getKeys()` calls, fallback logic, array merging, and manual `startsWith()` filtering. + +Delete that approach. + +Specifically remove: + +- `DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX`; +- manual `.replaceAll("/", "")` for namespace construction; +- manual `.replace(/\W/g, "")` for namespace construction; +- fallback `getKeys()` calls; +- `Set` merging of primary/fallback results; +- manual namespace `startsWith()` post-filtering; +- any attempt to calculate ocache's hash ourselves; +- any copied ocache hash/escape implementation. + +Do **not** deep-import ocache internals such as `escapeKeySegment` or private source paths. + +--- + +## 5. Required solution: public `ocache.resolveCacheKeys()` + +Pinned `ocache` 0.3.0 publicly exports `resolveCacheKeys` from the package root. + +Use that API. + +`resolveCacheKeys()` uses the same internal cache-key builder that the actual cached handler uses. This delegates all of the following to ocache: + +- base handling; +- group escaping; +- name escaping; +- segment hash suffixes; +- cache-key assembly. + +This is the required abstraction boundary. + +--- + +## 6. Add `resolveAssetCacheStoragePrefix()` in `cache.ts` + +Modify: + +```text +modules/directus-client/src/runtime/assets/cache.ts +``` + +Import `resolveCacheKeys` from the public package root: + +```ts +import { resolveCacheKeys } from "ocache"; +``` + +Keep the existing shared constants: + +```ts +export const DIRECTUS_ASSET_CACHE_BASE = "/cache"; +export const DIRECTUS_ASSET_CACHE_GROUP = "handlers"; +export const DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; +``` + +Add one private deterministic probe key: + +```ts +const ASSET_CACHE_NAMESPACE_PROBE = "__namespace_probe__"; +``` + +Add this exported helper in `cache.ts`: + +```ts +export async function resolveAssetCacheStoragePrefix(): Promise { + const [key] = await resolveCacheKeys({ + options: { + base: DIRECTUS_ASSET_CACHE_BASE, + group: DIRECTUS_ASSET_CACHE_GROUP, + name: DIRECTUS_ASSET_CACHE_NAME, + getKey: () => ASSET_CACHE_NAMESPACE_PROBE + } + }); + + if (!key) { + throw new Error("Could not resolve Directus asset cache storage namespace"); + } + + const separator = key.lastIndexOf(":"); + if (separator < 0) { + throw new Error("Could not resolve Directus asset cache storage namespace"); + } + + return key.slice(0, separator + 1); +} +``` + +Do not export the probe constant. + +The intended result is conceptually: + +```text +ocache generates: +/cache:handlers:directusassets.:__namespace_probe__.json + +helper returns: +/cache:handlers:directusassets.: +``` + +This small removal of the terminal segment is acceptable because ocache 0.3.0 does not expose a dedicated `resolveCachePrefix()` API. + +Do not manipulate `base`, `group`, or `name` manually anywhere else. + +--- + +## 7. Do not manually use Unstorage normalization helpers + +Unstorage exposes helpers such as `normalizeKey`, `normalizeBaseKey`, and `joinKeys`. + +Do **not** add or use them here. + +Do not add `unstorage` as a new direct dependency solely for this feature. + +Reason: + +- `storage.getKeys(prefix)` already normalizes its base internally; +- item operations normalize keys internally; +- Nitro `useStorage("directus-assets")` already returns a `prefixStorage()` view over the mounted storage. + +Required ownership is: + +```text +ocache + -> constructs cache keys + +Nitro useStorage() + -> exposes mounted storage + +Unstorage + -> normalizes and scopes keys + +filesystem/redis/kv driver + -> owns physical representation +``` + +Our module should not duplicate any of these transformations. + +--- + +## 8. Simplify `prune.ts` enumeration to one call + +Modify: + +```text +modules/directus-client/src/runtime/assets/prune.ts +``` + +Import: + +```ts +resolveAssetCacheStoragePrefix +``` + +from `./cache`. + +The namespace enumeration must reduce to: + +```ts +const storage = useStorage(config.storage); +const prefix = await resolveAssetCacheStoragePrefix(); +const keys = await storage.getKeys(prefix); +``` + +That is the complete namespace-discovery logic. + +Do not: + +- call `getKeys()` twice; +- apply fallback prefixes; +- merge key arrays; +- manually normalize returned keys; +- apply a second `startsWith()` namespace filter. + +`storage.getKeys(prefix)` itself is the namespace boundary. + +Remove `DIRECTUS_ASSET_CACHE_PREFIX` if it exists only to drive pruning/tests. Keep only the base/group/name constants that define the actual cache configuration. + +--- + +## 9. Keep all reads/deletes through the existing ocache blob adapter + +Do not change `createAssetCacheStorage(config.storage)`. + +Pruning must continue to use the ocache blob adapter for actual entry access and deletion. + +Required behavior remains: + +```text +cacheStorage.get(key) throws + -> skipped + +cacheStorage.get(key) returns null + -> remove + +decoded but structurally unusable entry + -> remove + +expired entry + -> remove + +valid unexpired entry + -> retain + +cacheStorage.set(key, null) throws + -> skipped +``` + +Do not inspect files directly in runtime code. +Do not use filesystem mtimes. +Do not decode ocache blob framing manually. + +--- + +## 10. Preserve malformed-entry validation + +The current structural validation added in the previous follow-up is correct. + +Keep behavior equivalent to: + +```ts +function isUsableAssetCacheValue(value: unknown): boolean { + if (!isRecord(value)) return false; + if (value.status !== 200) return false; + if (!isRecord(value.headers)) return false; + + return isString(value.body) || ArrayBuffer.isView(value.body); +} +``` + +Do not expand this into a copy of ocache's full HTTP validator. + +The purpose is simply to prevent decoded-but-useless cache garbage from living forever, especially under unbounded SWR. + +--- + +## 11. Preserve lifetime semantics exactly + +Do not regress existing expiry behavior. + +Required semantics: + +### `maxAge: 0` + +Immediately expired: + +```ts +if (maxAge === 0) return "expired"; +``` + +### Non-SWR + +Expired only when: + +```ts +age > maxAge * 1000 +``` + +not `>=`. + +### Finite SWR + +Expired only when: + +```ts +age > (maxAge + staleMaxAge) * 1000 +``` + +### Unbounded SWR + +If no effective `staleMaxAge` exists, a valid entry is physically retained indefinitely. + +### Per-entry overrides + +Stored `maxAge`/`staleMaxAge` override configured values when valid. + +### Invalid present lifetime metadata + +Treat as malformed and remove. + +Keep the existing tests for all of these semantics. + +--- + +## 12. Fix unit tests so they no longer hardcode the wrong namespace + +Modify: + +```text +modules/directus-client/__tests__/asset-prune.test.ts +``` + +Current tests construct keys from a manually assumed prefix, for example: + +```ts +`${DIRECTUS_ASSET_CACHE_PREFIX}entry.json` +``` + +That is why the namespace bug escaped unit coverage: the test created keys using the same incorrect assumption that pruning used. + +Change unit tests to import: + +```ts +resolveAssetCacheStoragePrefix +``` + +from: + +```text +../src/runtime/assets/cache +``` + +Resolve the prefix from ocache and use that value when constructing test entries. + +Preferred shape: + +```ts +const assetCachePrefix = await resolveAssetCacheStoragePrefix(); +``` + +Then: + +```ts +`${assetCachePrefix}entry.json` +``` + +Resolve once for the suite/module rather than rebuilding repeatedly unless test isolation requires otherwise. + +Do not create any test-only normalization algorithm. + +Remove all test imports/usages of a manually encoded `DIRECTUS_ASSET_CACHE_PREFIX` if no longer needed. + +--- + +## 13. Add one focused namespace regression test + +Add a small test for `resolveAssetCacheStoragePrefix()` in `asset-cache.test.ts` (preferred) or `asset-prune.test.ts`. + +The test must prove that the helper delegates to ocache's escaped namespace rather than our raw `directus-assets` name. + +Do **not** hardcode the current hash. + +Suitable assertions are conceptually: + +```ts +const prefix = await resolveAssetCacheStoragePrefix(); + +expect(prefix).toContain("handlers:"); +expect(prefix).not.toContain("directus-assets"); +expect(prefix).toContain("directusassets."); +expect(prefix.endsWith(":")).toBe(true); +``` + +Do not test the exact hash or exact hash algorithm. + +--- + +## 14. Keep the real filesystem E2E + +Keep: + +```text +modules/directus-client/__tests__/asset-prune.e2e.test.ts +modules/directus-client/__tests__/fixtures/prune/ +``` + +The E2E should continue to use: + +- a real Nuxt/Nitro fixture; +- a real filesystem-backed Nitro storage mount; +- a local deterministic upstream HTTP server; +- the actual Directus asset route; +- request-triggered pruning; +- an unrelated foreign key in the same storage mount. + +Do not replace the filesystem storage with memory or mocks. + +The fixture should continue using config equivalent to: + +```ts +nitro: { + storage: { + "directus-assets": { + driver: "fs", + base: process.env.DIRECTUS_PRUNE_E2E_CACHE_DIR + } + } +} +``` + +with a short cache lifetime/prune interval such as `maxAge: 1`, `interval: 1`. + +--- + +## 15. Harden E2E cache-population timing + +The current E2E reads storage immediately after asset requests. Cache fill may complete through background `waitUntil()`/streaming work, so do not assume the response returning means the write has already landed. + +Use the existing bounded `waitFor()` polling helper for cache **creation** as well as deletion. + +Required sequence: + +### A. Populate asset A + +1. Request `/_directus/assets/asset-a`. +2. Poll storage until at least one non-foreign Directus cache key exists. +3. Only then capture `keysAfterA`. + +### B. Add foreign key + +Create `foreign-key` in the same `directus-assets` storage mount using the fixture API. + +### C. Let A expire + +Wait just beyond `maxAge`/prune interval. The existing ~1100 ms wait is fine with `maxAge: 1`, `interval: 1`. + +### D. Populate asset B and trigger pruning + +1. Request `/_directus/assets/asset-b`. +2. Poll until at least one new non-foreign key exists that was not present in `keysAfterA`. +3. Capture that as the B key set. + +### E. Wait for prune completion + +Poll until all keys from `keysAfterA` are gone. + +### F. Final assertions + +Assert: + +- every stale A key is gone; +- B key(s) remain; +- `foreign-key` remains. + +Use bounded polling. Do not use unbounded loops. +Do not increase sleeps to several seconds unnecessarily. + +--- + +## 16. Keep foreign-key coverage + +The fixture endpoint that writes `foreign-key` into the same `directus-assets` mount is important. + +Keep it and keep the final assertion that `foreign-key` survives pruning. + +This is the real-world proof that `storage.getKeys(prefix)` scopes the namespace correctly even when the storage mount contains unrelated data. + +--- + +## 17. Keep deterministic local upstream + +The E2E must not contact a real external Directus instance. + +Keep the local Node HTTP server returning cacheable 200 responses for `asset-a` and `asset-b` with deterministic body content, and 404 for unknown assets. + +No external network dependency. + +--- + +## 18. Clean temporary diagnostics + +The prior debugging attempt may have left uncommitted diagnostic work. + +Before changing code, inspect: + +```bash +git status +git diff +``` + +Preserve useful final E2E/test code described above, but remove any diagnostics that are not part of the final test. + +Examples to remove if present: + +- direct/manual prune debug endpoints; +- temporary console logs; +- key-dump debug code not needed by the final fixture; +- commented exploratory code; +- temporary files. + +The final fixture should contain only routes required by the finished test, expected to include roughly: + +```text +server/api/directus-asset-cache-keys.get.ts +server/api/directus-asset-cache-foreign.post.ts +``` + +A dedicated manual prune endpoint should not be necessary. + +--- + +## 19. Preserve coordinator and task behavior + +Do not change request coordinator architecture. + +Request-triggered pruning remains: + +- opt-in via `prune.enabled`; +- optionally disabled via `prune.onRequest`; +- throttled by `prune.interval`; +- single-flight per Nitro application state; +- best-effort; +- failures caught/logged so `waitUntil()` receives a resolved promise; +- promise state cleared after completion/failure. + +The explicit Nitro prune task remains different: + +```text +request pruning failure -> log/contain +manual/scheduled task failure -> reject/propagate +``` + +Do not change this distinction. + +--- + +## 20. Do not change unrelated behavior + +Do not touch: + +- authentication/session fallback; +- public-only shared-cache security; +- Directus URL resolution; +- request/header forwarding; +- cache key resource hashing/HTTP key generation; +- conditional requests; +- cacheability rules; +- body-size behavior; +- storage adapter selection; +- Redis/Valkey behavior; +- Cloudflare behavior; +- native TTL detection; +- max-size/LRU eviction; +- distributed locks/coordination; +- refresh coordinator/auth work. + +Do not add distributed pruning locks, Redis leases, Durable Objects, or CAS. + +Do not implement cache max-size eviction in this PR. + +--- + +## 21. Expected final ownership + +Final code should read conceptually as: + +```text +cache.ts + DIRECTUS_ASSET_CACHE_BASE + DIRECTUS_ASSET_CACHE_GROUP + DIRECTUS_ASSET_CACHE_NAME + createAssetCacheStorage() + resolveAssetCacheStoragePrefix() + | + | uses public ocache.resolveCacheKeys() + v + +prune.ts + prefix = await resolveAssetCacheStoragePrefix() + keys = await useStorage(config.storage).getKeys(prefix) + prune entries + | + v + +Unstorage + normalizes/scopes keys +``` + +There should be no second namespace algorithm anywhere in this module or tests. + +--- + +## 22. Required test coverage after the fix + +Existing tests must continue covering: + +- fresh entry retained; +- exact expiry boundary retained; +- one millisecond beyond expiry removed; +- finite SWR; +- unbounded SWR; +- stored `maxAge` override; +- stored `staleMaxAge` override; +- `staleMaxAge: 0`; +- `maxAge: 0` immediate expiry; +- malformed blob frame removal; +- missing value removal; +- empty value removal; +- unsuccessful status removal; +- missing headers removal; +- invalid body removal; +- string body accepted; +- binary body accepted; +- invalid `mtime` removal; +- invalid present lifetime metadata removal; +- storage read failures skipped; +- delete failures skipped; +- later keys still processed after failures; +- driver `flags.ttl === true` skips pruning; +- foreign/unrelated keys remain untouched; +- missing mount throws; +- coordinator disabled/onRequest behavior; +- coordinator throttle; +- coordinator single-flight; +- independent app state; +- coordinator failure containment; +- task disabled behavior; +- task summary; +- task failure propagation. + +Add/retain explicit namespace coverage: + +- `resolveAssetCacheStoragePrefix()` reflects ocache escaping; +- unit prune tests use the derived prefix rather than a manually encoded one; +- real filesystem E2E passes. + +--- + +## 23. Validation + +Run the normal repository validation: + +```bash +corepack pnpm format +corepack pnpm lint:fix +corepack pnpm typecheck +corepack pnpm test +corepack pnpm build +``` + +Also run the pruning E2E explicitly if the repository has a separate E2E command. + +Known unrelated CI failures are outside scope. Do not fix unrelated CI. + +Then inspect: + +```bash +git status +git diff --check +git diff +``` + +--- + +## 24. Final manual checklist + +Before finishing, verify all of these are true: + +- [ ] `DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX` is gone. +- [ ] No manual `.replace(/\W/g, "")` is used to derive the cache namespace. +- [ ] No manual slash stripping is used to derive the cache namespace. +- [ ] No copied ocache hash logic exists. +- [ ] No deep import from ocache internals exists. +- [ ] `resolveCacheKeys` is imported from public `"ocache"`. +- [ ] `resolveAssetCacheStoragePrefix()` lives in `cache.ts`. +- [ ] Pruning performs one scoped `getKeys(prefix)` call. +- [ ] There is no post-`getKeys()` `startsWith()` namespace filter. +- [ ] No Unstorage normalization helper is called manually for this feature. +- [ ] No new `unstorage` dependency was added solely for pruning. +- [ ] Unit tests derive cache prefixes through the production helper. +- [ ] Namespace regression test does not hardcode the hash. +- [ ] Real filesystem E2E passes. +- [ ] E2E waits for A to be written before capturing A keys. +- [ ] E2E waits for B to be written before capturing B keys. +- [ ] Stale A is removed. +- [ ] Fresh B remains. +- [ ] `foreign-key` remains. +- [ ] Existing malformed-entry semantics remain intact. +- [ ] Existing expiry/SWR semantics remain intact. +- [ ] Request background failures are still contained. +- [ ] Task failures still propagate. +- [ ] No unrelated public API changes were made. +- [ ] No temporary diagnostic files/logging remain. +- [ ] This task file is removed before the final implementation commit. + +--- + +## 25. Final commit + +After all code/tests pass and this task file has been removed, create one focused implementation commit. + +Suggested commit message: + +```text +fix(directus-client): resolve asset cache prune namespace via ocache +``` + +Push to the existing branch: + +```text +feat/cache-prune +``` + +Do not open a new PR. + +--- + +## Acceptance criteria + +The task is complete only when: + +1. The real filesystem pruning E2E passes. +2. Pruning discovers entries written by the actual ocache handler. +3. Namespace construction is delegated to public `ocache.resolveCacheKeys()`. +4. Storage normalization/scoping is delegated to Nitro/Unstorage. +5. No manual reimplementation of ocache escaping/hashing remains. +6. No manual reimplementation of Unstorage normalization remains. +7. Pruning uses exactly one scoped `getKeys(prefix)` enumeration. +8. Foreign keys in the same storage mount are not pruned. +9. Existing malformed-entry and expiry semantics stay correct. +10. Unit tests no longer depend on the incorrect raw-prefix assumption. +11. E2E waits for asynchronous cache persistence before inspecting keys. +12. No temporary debug code remains. +13. No unrelated architecture/public API changes are introduced. +14. This task file is removed from the final tree. From 35d8495a718a1b62a1cda84234cbb1e9920b34a5 Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 20:35:30 +0200 Subject: [PATCH 7/8] fix(directus-client): resolve asset cache prune namespace via ocache --- .../tasks/pr-289-cache-pruning-follow-up.md | 867 ------------------ .../__tests__/asset-cache.test.ts | 17 +- .../__tests__/asset-prune.e2e.test.ts | 10 +- .../__tests__/asset-prune.test.ts | 37 +- .../src/runtime/assets/cache.ts | 29 + .../src/runtime/assets/prune.ts | 20 +- 6 files changed, 73 insertions(+), 907 deletions(-) delete mode 100644 .agents/tasks/pr-289-cache-pruning-follow-up.md diff --git a/.agents/tasks/pr-289-cache-pruning-follow-up.md b/.agents/tasks/pr-289-cache-pruning-follow-up.md deleted file mode 100644 index 10f284ff..00000000 --- a/.agents/tasks/pr-289-cache-pruning-follow-up.md +++ /dev/null @@ -1,867 +0,0 @@ -# PR #289 final follow-up — fix asset-cache pruning namespace and complete E2E - -## Execution instructions - -You are working in `onderwijsin/nuxt-modules` on branch `feat/cache-prune`, PR #289 (`feat(directus-client): prune stale asset cache entries`), based on `fix/asset-handlers`. - -This brief is the design. Execute it exactly. Do not make alternate architectural choices unless the repository proves a stated API is unavailable. If that happens, stop and report the concrete incompatibility instead of inventing another normalization algorithm. - -Before the final implementation commit, remove this task file: - -```text -.agents/tasks/pr-289-cache-pruning-follow-up.md -``` - -Do not leave temporary diagnostics, debug routes, console logging, or this brief in the final tree. - ---- - -## 1. Scope - -PR #289 adds opt-in cleanup of stale Directus asset-cache entries for storage backends that do not physically enforce TTL. - -The feature already has the right architecture and public API. Do **not** redesign it. - -Existing runtime structure should remain: - -```text -modules/directus-client/src/runtime/assets/ - cache.ts - cached-handler.ts - prune.ts - prune-coordinator.ts - -modules/directus-client/src/runtime/tasks/ - prune.ts -``` - -Existing public prune config remains: - -```ts -prune: { - enabled: boolean; - onRequest: boolean; - interval: number; -} -``` - -Resolved defaults remain: - -```ts -{ - enabled: false, - onRequest: true, - interval: 3600 -} -``` - -Do not reintroduce task config (`task.enabled`, `task.schedule`) or automatic Nitro task registration/scheduling. - -This follow-up is specifically about: - -1. fixing real cache namespace enumeration exposed by the new filesystem E2E; -2. removing manual cache-key normalization/reconstruction; -3. making unit tests derive the namespace from ocache rather than from our own assumption; -4. hardening the E2E so it waits for asynchronous cache writes as well as deletion; -5. preserving all existing pruning semantics. - ---- - -## 2. Real bug discovered by E2E - -The new filesystem-backed E2E proved a production integration mismatch. - -The fixture successfully: - -- starts real Nitro; -- serves deterministic upstream assets; -- writes cache data to a real filesystem-backed Nitro storage mount; -- resolves the expected prune config; -- uses a driver with no native TTL flag. - -Physical/logical cache keys exist, but pruning reports: - -```text -scanned: 0 -``` - -Calling the prune utility directly against the same real storage also returns zero scanned entries. - -Therefore this is **not** a timing, `waitUntil`, coordinator, or Nitro-task problem. The failure is namespace enumeration. - ---- - -## 3. Root cause: ocache owns cache-key construction - -The Directus asset cache uses these module-owned constants: - -```ts -DIRECTUS_ASSET_CACHE_BASE = "/cache"; -DIRECTUS_ASSET_CACHE_GROUP = "handlers"; -DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; -``` - -Those constants are correct and should remain the shared source of truth. - -The mistake is assuming the actual storage namespace is simply: - -```text -/cache:handlers:directus-assets: -``` - -It is not. - -Pinned `ocache` 0.3.0 constructs keys with its own `buildCacheKey()` and `escapeKeySegment()` logic. For each `group` and `name` segment, it removes unsupported punctuation and, when a segment changes, appends a hash so the transformation is not lossy. - -Conceptually: - -```text -directus-assets -``` - -becomes: - -```text -directusassets. -``` - -not merely `directusassets`, and not the raw `directus-assets` string. - -After ocache constructs the key, Unstorage performs its own normalization (separator normalization, leading/trailing separator handling, etc.). The filesystem driver then maps `:` separators to directories internally. - -These are different layers. Our module must not reproduce either transformation. - ---- - -## 4. Remove the current manual fallback completely - -Current `prune.ts` contains an attempted workaround similar to: - -```ts -export const DIRECTUS_ASSET_CACHE_PREFIX = - `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; - -const DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX = - `${DIRECTUS_ASSET_CACHE_BASE.replaceAll("/", "")}:` + - `${DIRECTUS_ASSET_CACHE_GROUP}:` + - `${DIRECTUS_ASSET_CACHE_NAME.replace(/\W/g, "")}.`; -``` - -and then does multiple `getKeys()` calls, fallback logic, array merging, and manual `startsWith()` filtering. - -Delete that approach. - -Specifically remove: - -- `DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX`; -- manual `.replaceAll("/", "")` for namespace construction; -- manual `.replace(/\W/g, "")` for namespace construction; -- fallback `getKeys()` calls; -- `Set` merging of primary/fallback results; -- manual namespace `startsWith()` post-filtering; -- any attempt to calculate ocache's hash ourselves; -- any copied ocache hash/escape implementation. - -Do **not** deep-import ocache internals such as `escapeKeySegment` or private source paths. - ---- - -## 5. Required solution: public `ocache.resolveCacheKeys()` - -Pinned `ocache` 0.3.0 publicly exports `resolveCacheKeys` from the package root. - -Use that API. - -`resolveCacheKeys()` uses the same internal cache-key builder that the actual cached handler uses. This delegates all of the following to ocache: - -- base handling; -- group escaping; -- name escaping; -- segment hash suffixes; -- cache-key assembly. - -This is the required abstraction boundary. - ---- - -## 6. Add `resolveAssetCacheStoragePrefix()` in `cache.ts` - -Modify: - -```text -modules/directus-client/src/runtime/assets/cache.ts -``` - -Import `resolveCacheKeys` from the public package root: - -```ts -import { resolveCacheKeys } from "ocache"; -``` - -Keep the existing shared constants: - -```ts -export const DIRECTUS_ASSET_CACHE_BASE = "/cache"; -export const DIRECTUS_ASSET_CACHE_GROUP = "handlers"; -export const DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; -``` - -Add one private deterministic probe key: - -```ts -const ASSET_CACHE_NAMESPACE_PROBE = "__namespace_probe__"; -``` - -Add this exported helper in `cache.ts`: - -```ts -export async function resolveAssetCacheStoragePrefix(): Promise { - const [key] = await resolveCacheKeys({ - options: { - base: DIRECTUS_ASSET_CACHE_BASE, - group: DIRECTUS_ASSET_CACHE_GROUP, - name: DIRECTUS_ASSET_CACHE_NAME, - getKey: () => ASSET_CACHE_NAMESPACE_PROBE - } - }); - - if (!key) { - throw new Error("Could not resolve Directus asset cache storage namespace"); - } - - const separator = key.lastIndexOf(":"); - if (separator < 0) { - throw new Error("Could not resolve Directus asset cache storage namespace"); - } - - return key.slice(0, separator + 1); -} -``` - -Do not export the probe constant. - -The intended result is conceptually: - -```text -ocache generates: -/cache:handlers:directusassets.:__namespace_probe__.json - -helper returns: -/cache:handlers:directusassets.: -``` - -This small removal of the terminal segment is acceptable because ocache 0.3.0 does not expose a dedicated `resolveCachePrefix()` API. - -Do not manipulate `base`, `group`, or `name` manually anywhere else. - ---- - -## 7. Do not manually use Unstorage normalization helpers - -Unstorage exposes helpers such as `normalizeKey`, `normalizeBaseKey`, and `joinKeys`. - -Do **not** add or use them here. - -Do not add `unstorage` as a new direct dependency solely for this feature. - -Reason: - -- `storage.getKeys(prefix)` already normalizes its base internally; -- item operations normalize keys internally; -- Nitro `useStorage("directus-assets")` already returns a `prefixStorage()` view over the mounted storage. - -Required ownership is: - -```text -ocache - -> constructs cache keys - -Nitro useStorage() - -> exposes mounted storage - -Unstorage - -> normalizes and scopes keys - -filesystem/redis/kv driver - -> owns physical representation -``` - -Our module should not duplicate any of these transformations. - ---- - -## 8. Simplify `prune.ts` enumeration to one call - -Modify: - -```text -modules/directus-client/src/runtime/assets/prune.ts -``` - -Import: - -```ts -resolveAssetCacheStoragePrefix -``` - -from `./cache`. - -The namespace enumeration must reduce to: - -```ts -const storage = useStorage(config.storage); -const prefix = await resolveAssetCacheStoragePrefix(); -const keys = await storage.getKeys(prefix); -``` - -That is the complete namespace-discovery logic. - -Do not: - -- call `getKeys()` twice; -- apply fallback prefixes; -- merge key arrays; -- manually normalize returned keys; -- apply a second `startsWith()` namespace filter. - -`storage.getKeys(prefix)` itself is the namespace boundary. - -Remove `DIRECTUS_ASSET_CACHE_PREFIX` if it exists only to drive pruning/tests. Keep only the base/group/name constants that define the actual cache configuration. - ---- - -## 9. Keep all reads/deletes through the existing ocache blob adapter - -Do not change `createAssetCacheStorage(config.storage)`. - -Pruning must continue to use the ocache blob adapter for actual entry access and deletion. - -Required behavior remains: - -```text -cacheStorage.get(key) throws - -> skipped - -cacheStorage.get(key) returns null - -> remove - -decoded but structurally unusable entry - -> remove - -expired entry - -> remove - -valid unexpired entry - -> retain - -cacheStorage.set(key, null) throws - -> skipped -``` - -Do not inspect files directly in runtime code. -Do not use filesystem mtimes. -Do not decode ocache blob framing manually. - ---- - -## 10. Preserve malformed-entry validation - -The current structural validation added in the previous follow-up is correct. - -Keep behavior equivalent to: - -```ts -function isUsableAssetCacheValue(value: unknown): boolean { - if (!isRecord(value)) return false; - if (value.status !== 200) return false; - if (!isRecord(value.headers)) return false; - - return isString(value.body) || ArrayBuffer.isView(value.body); -} -``` - -Do not expand this into a copy of ocache's full HTTP validator. - -The purpose is simply to prevent decoded-but-useless cache garbage from living forever, especially under unbounded SWR. - ---- - -## 11. Preserve lifetime semantics exactly - -Do not regress existing expiry behavior. - -Required semantics: - -### `maxAge: 0` - -Immediately expired: - -```ts -if (maxAge === 0) return "expired"; -``` - -### Non-SWR - -Expired only when: - -```ts -age > maxAge * 1000 -``` - -not `>=`. - -### Finite SWR - -Expired only when: - -```ts -age > (maxAge + staleMaxAge) * 1000 -``` - -### Unbounded SWR - -If no effective `staleMaxAge` exists, a valid entry is physically retained indefinitely. - -### Per-entry overrides - -Stored `maxAge`/`staleMaxAge` override configured values when valid. - -### Invalid present lifetime metadata - -Treat as malformed and remove. - -Keep the existing tests for all of these semantics. - ---- - -## 12. Fix unit tests so they no longer hardcode the wrong namespace - -Modify: - -```text -modules/directus-client/__tests__/asset-prune.test.ts -``` - -Current tests construct keys from a manually assumed prefix, for example: - -```ts -`${DIRECTUS_ASSET_CACHE_PREFIX}entry.json` -``` - -That is why the namespace bug escaped unit coverage: the test created keys using the same incorrect assumption that pruning used. - -Change unit tests to import: - -```ts -resolveAssetCacheStoragePrefix -``` - -from: - -```text -../src/runtime/assets/cache -``` - -Resolve the prefix from ocache and use that value when constructing test entries. - -Preferred shape: - -```ts -const assetCachePrefix = await resolveAssetCacheStoragePrefix(); -``` - -Then: - -```ts -`${assetCachePrefix}entry.json` -``` - -Resolve once for the suite/module rather than rebuilding repeatedly unless test isolation requires otherwise. - -Do not create any test-only normalization algorithm. - -Remove all test imports/usages of a manually encoded `DIRECTUS_ASSET_CACHE_PREFIX` if no longer needed. - ---- - -## 13. Add one focused namespace regression test - -Add a small test for `resolveAssetCacheStoragePrefix()` in `asset-cache.test.ts` (preferred) or `asset-prune.test.ts`. - -The test must prove that the helper delegates to ocache's escaped namespace rather than our raw `directus-assets` name. - -Do **not** hardcode the current hash. - -Suitable assertions are conceptually: - -```ts -const prefix = await resolveAssetCacheStoragePrefix(); - -expect(prefix).toContain("handlers:"); -expect(prefix).not.toContain("directus-assets"); -expect(prefix).toContain("directusassets."); -expect(prefix.endsWith(":")).toBe(true); -``` - -Do not test the exact hash or exact hash algorithm. - ---- - -## 14. Keep the real filesystem E2E - -Keep: - -```text -modules/directus-client/__tests__/asset-prune.e2e.test.ts -modules/directus-client/__tests__/fixtures/prune/ -``` - -The E2E should continue to use: - -- a real Nuxt/Nitro fixture; -- a real filesystem-backed Nitro storage mount; -- a local deterministic upstream HTTP server; -- the actual Directus asset route; -- request-triggered pruning; -- an unrelated foreign key in the same storage mount. - -Do not replace the filesystem storage with memory or mocks. - -The fixture should continue using config equivalent to: - -```ts -nitro: { - storage: { - "directus-assets": { - driver: "fs", - base: process.env.DIRECTUS_PRUNE_E2E_CACHE_DIR - } - } -} -``` - -with a short cache lifetime/prune interval such as `maxAge: 1`, `interval: 1`. - ---- - -## 15. Harden E2E cache-population timing - -The current E2E reads storage immediately after asset requests. Cache fill may complete through background `waitUntil()`/streaming work, so do not assume the response returning means the write has already landed. - -Use the existing bounded `waitFor()` polling helper for cache **creation** as well as deletion. - -Required sequence: - -### A. Populate asset A - -1. Request `/_directus/assets/asset-a`. -2. Poll storage until at least one non-foreign Directus cache key exists. -3. Only then capture `keysAfterA`. - -### B. Add foreign key - -Create `foreign-key` in the same `directus-assets` storage mount using the fixture API. - -### C. Let A expire - -Wait just beyond `maxAge`/prune interval. The existing ~1100 ms wait is fine with `maxAge: 1`, `interval: 1`. - -### D. Populate asset B and trigger pruning - -1. Request `/_directus/assets/asset-b`. -2. Poll until at least one new non-foreign key exists that was not present in `keysAfterA`. -3. Capture that as the B key set. - -### E. Wait for prune completion - -Poll until all keys from `keysAfterA` are gone. - -### F. Final assertions - -Assert: - -- every stale A key is gone; -- B key(s) remain; -- `foreign-key` remains. - -Use bounded polling. Do not use unbounded loops. -Do not increase sleeps to several seconds unnecessarily. - ---- - -## 16. Keep foreign-key coverage - -The fixture endpoint that writes `foreign-key` into the same `directus-assets` mount is important. - -Keep it and keep the final assertion that `foreign-key` survives pruning. - -This is the real-world proof that `storage.getKeys(prefix)` scopes the namespace correctly even when the storage mount contains unrelated data. - ---- - -## 17. Keep deterministic local upstream - -The E2E must not contact a real external Directus instance. - -Keep the local Node HTTP server returning cacheable 200 responses for `asset-a` and `asset-b` with deterministic body content, and 404 for unknown assets. - -No external network dependency. - ---- - -## 18. Clean temporary diagnostics - -The prior debugging attempt may have left uncommitted diagnostic work. - -Before changing code, inspect: - -```bash -git status -git diff -``` - -Preserve useful final E2E/test code described above, but remove any diagnostics that are not part of the final test. - -Examples to remove if present: - -- direct/manual prune debug endpoints; -- temporary console logs; -- key-dump debug code not needed by the final fixture; -- commented exploratory code; -- temporary files. - -The final fixture should contain only routes required by the finished test, expected to include roughly: - -```text -server/api/directus-asset-cache-keys.get.ts -server/api/directus-asset-cache-foreign.post.ts -``` - -A dedicated manual prune endpoint should not be necessary. - ---- - -## 19. Preserve coordinator and task behavior - -Do not change request coordinator architecture. - -Request-triggered pruning remains: - -- opt-in via `prune.enabled`; -- optionally disabled via `prune.onRequest`; -- throttled by `prune.interval`; -- single-flight per Nitro application state; -- best-effort; -- failures caught/logged so `waitUntil()` receives a resolved promise; -- promise state cleared after completion/failure. - -The explicit Nitro prune task remains different: - -```text -request pruning failure -> log/contain -manual/scheduled task failure -> reject/propagate -``` - -Do not change this distinction. - ---- - -## 20. Do not change unrelated behavior - -Do not touch: - -- authentication/session fallback; -- public-only shared-cache security; -- Directus URL resolution; -- request/header forwarding; -- cache key resource hashing/HTTP key generation; -- conditional requests; -- cacheability rules; -- body-size behavior; -- storage adapter selection; -- Redis/Valkey behavior; -- Cloudflare behavior; -- native TTL detection; -- max-size/LRU eviction; -- distributed locks/coordination; -- refresh coordinator/auth work. - -Do not add distributed pruning locks, Redis leases, Durable Objects, or CAS. - -Do not implement cache max-size eviction in this PR. - ---- - -## 21. Expected final ownership - -Final code should read conceptually as: - -```text -cache.ts - DIRECTUS_ASSET_CACHE_BASE - DIRECTUS_ASSET_CACHE_GROUP - DIRECTUS_ASSET_CACHE_NAME - createAssetCacheStorage() - resolveAssetCacheStoragePrefix() - | - | uses public ocache.resolveCacheKeys() - v - -prune.ts - prefix = await resolveAssetCacheStoragePrefix() - keys = await useStorage(config.storage).getKeys(prefix) - prune entries - | - v - -Unstorage - normalizes/scopes keys -``` - -There should be no second namespace algorithm anywhere in this module or tests. - ---- - -## 22. Required test coverage after the fix - -Existing tests must continue covering: - -- fresh entry retained; -- exact expiry boundary retained; -- one millisecond beyond expiry removed; -- finite SWR; -- unbounded SWR; -- stored `maxAge` override; -- stored `staleMaxAge` override; -- `staleMaxAge: 0`; -- `maxAge: 0` immediate expiry; -- malformed blob frame removal; -- missing value removal; -- empty value removal; -- unsuccessful status removal; -- missing headers removal; -- invalid body removal; -- string body accepted; -- binary body accepted; -- invalid `mtime` removal; -- invalid present lifetime metadata removal; -- storage read failures skipped; -- delete failures skipped; -- later keys still processed after failures; -- driver `flags.ttl === true` skips pruning; -- foreign/unrelated keys remain untouched; -- missing mount throws; -- coordinator disabled/onRequest behavior; -- coordinator throttle; -- coordinator single-flight; -- independent app state; -- coordinator failure containment; -- task disabled behavior; -- task summary; -- task failure propagation. - -Add/retain explicit namespace coverage: - -- `resolveAssetCacheStoragePrefix()` reflects ocache escaping; -- unit prune tests use the derived prefix rather than a manually encoded one; -- real filesystem E2E passes. - ---- - -## 23. Validation - -Run the normal repository validation: - -```bash -corepack pnpm format -corepack pnpm lint:fix -corepack pnpm typecheck -corepack pnpm test -corepack pnpm build -``` - -Also run the pruning E2E explicitly if the repository has a separate E2E command. - -Known unrelated CI failures are outside scope. Do not fix unrelated CI. - -Then inspect: - -```bash -git status -git diff --check -git diff -``` - ---- - -## 24. Final manual checklist - -Before finishing, verify all of these are true: - -- [ ] `DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX` is gone. -- [ ] No manual `.replace(/\W/g, "")` is used to derive the cache namespace. -- [ ] No manual slash stripping is used to derive the cache namespace. -- [ ] No copied ocache hash logic exists. -- [ ] No deep import from ocache internals exists. -- [ ] `resolveCacheKeys` is imported from public `"ocache"`. -- [ ] `resolveAssetCacheStoragePrefix()` lives in `cache.ts`. -- [ ] Pruning performs one scoped `getKeys(prefix)` call. -- [ ] There is no post-`getKeys()` `startsWith()` namespace filter. -- [ ] No Unstorage normalization helper is called manually for this feature. -- [ ] No new `unstorage` dependency was added solely for pruning. -- [ ] Unit tests derive cache prefixes through the production helper. -- [ ] Namespace regression test does not hardcode the hash. -- [ ] Real filesystem E2E passes. -- [ ] E2E waits for A to be written before capturing A keys. -- [ ] E2E waits for B to be written before capturing B keys. -- [ ] Stale A is removed. -- [ ] Fresh B remains. -- [ ] `foreign-key` remains. -- [ ] Existing malformed-entry semantics remain intact. -- [ ] Existing expiry/SWR semantics remain intact. -- [ ] Request background failures are still contained. -- [ ] Task failures still propagate. -- [ ] No unrelated public API changes were made. -- [ ] No temporary diagnostic files/logging remain. -- [ ] This task file is removed before the final implementation commit. - ---- - -## 25. Final commit - -After all code/tests pass and this task file has been removed, create one focused implementation commit. - -Suggested commit message: - -```text -fix(directus-client): resolve asset cache prune namespace via ocache -``` - -Push to the existing branch: - -```text -feat/cache-prune -``` - -Do not open a new PR. - ---- - -## Acceptance criteria - -The task is complete only when: - -1. The real filesystem pruning E2E passes. -2. Pruning discovers entries written by the actual ocache handler. -3. Namespace construction is delegated to public `ocache.resolveCacheKeys()`. -4. Storage normalization/scoping is delegated to Nitro/Unstorage. -5. No manual reimplementation of ocache escaping/hashing remains. -6. No manual reimplementation of Unstorage normalization remains. -7. Pruning uses exactly one scoped `getKeys(prefix)` enumeration. -8. Foreign keys in the same storage mount are not pruned. -9. Existing malformed-entry and expiry semantics stay correct. -10. Unit tests no longer depend on the incorrect raw-prefix assumption. -11. E2E waits for asynchronous cache persistence before inspecting keys. -12. No temporary debug code remains. -13. No unrelated architecture/public API changes are introduced. -14. This task file is removed from the final tree. diff --git a/modules/directus-client/__tests__/asset-cache.test.ts b/modules/directus-client/__tests__/asset-cache.test.ts index 0aa55693..770b80ae 100644 --- a/modules/directus-client/__tests__/asset-cache.test.ts +++ b/modules/directus-client/__tests__/asset-cache.test.ts @@ -26,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; @@ -53,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( diff --git a/modules/directus-client/__tests__/asset-prune.e2e.test.ts b/modules/directus-client/__tests__/asset-prune.e2e.test.ts index 57e1cce9..42bc05b9 100644 --- a/modules/directus-client/__tests__/asset-prune.e2e.test.ts +++ b/modules/directus-client/__tests__/asset-prune.e2e.test.ts @@ -54,6 +54,7 @@ describe("Directus asset-cache pruning end to end", () => { it("removes stale entries while retaining fresh and foreign storage data", async () => { await expect($fetch("/_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); @@ -64,9 +65,12 @@ describe("Directus asset-cache pruning end to end", () => { await new Promise((resolve) => setTimeout(resolve, 1_100)); await expect($fetch("/_directus/assets/asset-b")).resolves.toBe("asset-b"); - const keysAfterB = (await getKeys()).filter((key) => key !== "foreign-key"); - const keysForB = keysAfterB.filter((key) => !keysAfterA.includes(key)); - expect(keysForB.length).toBeGreaterThan(0); + let keysForB: string[] = []; + await waitFor(async () => { + const keys = (await getKeys()).filter((key) => key !== "foreign-key"); + keysForB = keys.filter((key) => !keysAfterA.includes(key)); + return keysForB.length > 0; + }); await waitFor(async () => { const keys = await getKeys(); diff --git a/modules/directus-client/__tests__/asset-prune.test.ts b/modules/directus-client/__tests__/asset-prune.test.ts index fd2706d1..9028fd9f 100644 --- a/modules/directus-client/__tests__/asset-prune.test.ts +++ b/modules/directus-client/__tests__/asset-prune.test.ts @@ -36,9 +36,10 @@ vi.mock("nitropack/runtime", () => ({ useStorage: (mount?: string) => (mount ? runtime.storage : runtime.rootStorage) })); -const { createAssetCacheStorage } = await import("../src/runtime/assets/cache"); +const { createAssetCacheStorage, resolveAssetCacheStoragePrefix } = + await import("../src/runtime/assets/cache"); const { pruneAssetCache } = await import("../src/runtime/assets/prune"); -const { DIRECTUS_ASSET_CACHE_PREFIX } = await import("../src/runtime/assets/prune"); +const assetCachePrefix = await resolveAssetCacheStoragePrefix(); const now = 10_000_000; const config = { @@ -76,17 +77,17 @@ describe("Directus asset-cache pruning", () => { ["at the expiry boundary", 60_000, false], ["one millisecond after expiry", 60_001, true] ])("uses strict ocache expiry semantics %s", async (_label, age, removed) => { - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}entry.json`, { mtime: now - age }); + await put(`${assetCachePrefix}entry.json`, { mtime: now - age }); const result = await pruneAssetCache(config, now); expect(result.removed).toBe(removed ? 1 : 0); }); it("supports finite and unbounded SWR lifetimes", async () => { - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}finite.json`, { + await put(`${assetCachePrefix}finite.json`, { mtime: now - 91_000, staleMaxAge: 30 }); - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}unbounded.json`, { + await put(`${assetCachePrefix}unbounded.json`, { mtime: now - 1_000_000 }); const finite = await pruneAssetCache({ ...config, swr: true, staleMaxAge: undefined }, now); @@ -95,11 +96,11 @@ describe("Directus asset-cache pruning", () => { }); it("honors stored lifetime overrides, including zero", async () => { - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}max-age.json`, { + await put(`${assetCachePrefix}max-age.json`, { mtime: now - 61_000, maxAge: 120 }); - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}stale-zero.json`, { + await put(`${assetCachePrefix}stale-zero.json`, { mtime: now - 61_000, staleMaxAge: 0 }); @@ -114,18 +115,18 @@ describe("Directus asset-cache pruning", () => { ["missing headers", { mtime: now, value: { status: 200, body: "asset" } }], ["invalid body", { mtime: now, value: { status: 200, headers: {}, body: {} } }] ])("removes decoded entries with %s", async (_label, entry) => { - const key = `${DIRECTUS_ASSET_CACHE_PREFIX}malformed.json`; + const key = `${assetCachePrefix}malformed.json`; await putRaw(key, entry); const result = await pruneAssetCache({ ...config, swr: true, staleMaxAge: undefined }, now); expect(result).toMatchObject({ scanned: 1, removed: 1, retained: 0, skipped: 0 }); }); it("retains string and binary cached response bodies", async () => { - await putRaw(`${DIRECTUS_ASSET_CACHE_PREFIX}string.json`, { + await putRaw(`${assetCachePrefix}string.json`, { mtime: now, value: { status: 200, headers: {}, body: "asset" } }); - await putRaw(`${DIRECTUS_ASSET_CACHE_PREFIX}binary.json`, { + await putRaw(`${assetCachePrefix}binary.json`, { mtime: now, value: { status: 200, headers: {}, body: new Uint8Array([1, 2, 3]) }, payload: "value.body" @@ -135,23 +136,23 @@ describe("Directus asset-cache pruning", () => { }); it("expires a stored maxAge of zero immediately", async () => { - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}zero.json`, { mtime: now, maxAge: 0 }); + await put(`${assetCachePrefix}zero.json`, { mtime: now, maxAge: 0 }); const result = await pruneAssetCache(config, now); expect(result.removed).toBe(1); }); it("removes malformed frames and decoded metadata but skips backend failures", async () => { - const malformedFrame = `${DIRECTUS_ASSET_CACHE_PREFIX}frame.json`; + const malformedFrame = `${assetCachePrefix}frame.json`; runtime.values.set(malformedFrame, new Uint8Array([1, 2, 3])); - const invalidMtime = `${DIRECTUS_ASSET_CACHE_PREFIX}mtime.json`; + const invalidMtime = `${assetCachePrefix}mtime.json`; await put(invalidMtime, { mtime: -1 }); - const readFailure = `${DIRECTUS_ASSET_CACHE_PREFIX}read.json`; + const readFailure = `${assetCachePrefix}read.json`; await put(readFailure, { mtime: now - 100_000 }); runtime.failures.add(readFailure); - const deleteFailure = `${DIRECTUS_ASSET_CACHE_PREFIX}delete.json`; + const deleteFailure = `${assetCachePrefix}delete.json`; await put(deleteFailure, { mtime: now - 100_000 }); runtime.deleteFailures.add(deleteFailure); - const later = `${DIRECTUS_ASSET_CACHE_PREFIX}later.json`; + const later = `${assetCachePrefix}later.json`; await put(later, { mtime: now - 100_000 }); const result = await pruneAssetCache(config, now); @@ -162,8 +163,8 @@ describe("Directus asset-cache pruning", () => { }); it("removes entries with invalid present lifetime metadata", async () => { - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}max-age.json`, { mtime: now, maxAge: -1 }); - await put(`${DIRECTUS_ASSET_CACHE_PREFIX}stale-age.json`, { + await put(`${assetCachePrefix}max-age.json`, { mtime: now, maxAge: -1 }); + await put(`${assetCachePrefix}stale-age.json`, { mtime: now, staleMaxAge: "invalid" }); diff --git a/modules/directus-client/src/runtime/assets/cache.ts b/modules/directus-client/src/runtime/assets/cache.ts index 46dfaa04..bdc9b42e 100644 --- a/modules/directus-client/src/runtime/assets/cache.ts +++ b/modules/directus-client/src/runtime/assets/cache.ts @@ -1,6 +1,7 @@ import { createBlobStorage, defineCachedHandler, + resolveCacheKeys, type CachedEventHandler, type HTTPEvent } from "ocache"; @@ -16,6 +17,7 @@ export type EnabledDirectusAssetCacheConfig = Extract< export const DIRECTUS_ASSET_CACHE_BASE = "/cache"; export const DIRECTUS_ASSET_CACHE_GROUP = "handlers"; export const DIRECTUS_ASSET_CACHE_NAME = "directus-assets"; +const ASSET_CACHE_NAMESPACE_PROBE = "__namespace_probe__"; /** Nitro-application-owned lazy state for one immutable asset cache handler. */ export interface DirectusAssetCacheState { @@ -75,6 +77,33 @@ export function createAssetCacheStorage(mount: string) { }); } +/** + * Resolves the storage namespace used by ocache for Directus asset entries. + * + * @returns The ocache-owned storage prefix for Directus asset entries. + */ +export async function resolveAssetCacheStoragePrefix(): Promise { + const [key] = await resolveCacheKeys({ + options: { + base: DIRECTUS_ASSET_CACHE_BASE, + group: DIRECTUS_ASSET_CACHE_GROUP, + name: DIRECTUS_ASSET_CACHE_NAME, + getKey: () => ASSET_CACHE_NAMESPACE_PROBE + } + }); + + if (!key) { + throw new Error("Could not resolve Directus asset cache storage namespace"); + } + + const separator = key.lastIndexOf(":"); + if (separator < 0) { + throw new Error("Could not resolve Directus asset cache storage namespace"); + } + + return key.slice(0, separator + 1); +} + /** * Creates empty state for one Nitro application's asset cache handler. * diff --git a/modules/directus-client/src/runtime/assets/prune.ts b/modules/directus-client/src/runtime/assets/prune.ts index 301ef434..80d193f5 100644 --- a/modules/directus-client/src/runtime/assets/prune.ts +++ b/modules/directus-client/src/runtime/assets/prune.ts @@ -7,12 +7,7 @@ import { } from "@onderwijsin/nuxt-module-utils/shared"; import { useStorage } from "nitropack/runtime"; import type { EnabledDirectusAssetCacheConfig } from "./cache"; -import { - createAssetCacheStorage, - DIRECTUS_ASSET_CACHE_BASE, - DIRECTUS_ASSET_CACHE_GROUP, - DIRECTUS_ASSET_CACHE_NAME -} from "./cache"; +import { createAssetCacheStorage, resolveAssetCacheStoragePrefix } from "./cache"; export interface AssetCachePruneSummary { scanned: number; @@ -21,9 +16,6 @@ export interface AssetCachePruneSummary { skipped: number; } -export const DIRECTUS_ASSET_CACHE_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME}:`; -const DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX = `${DIRECTUS_ASSET_CACHE_BASE.replaceAll("/", "")}:${DIRECTUS_ASSET_CACHE_GROUP}:${DIRECTUS_ASSET_CACHE_NAME.replace(/\W/g, "")}.`; - type AssetCacheEntryDisposition = "retain" | "expired" | "malformed"; function isUsableAssetCacheValue(value: unknown): boolean { @@ -83,14 +75,8 @@ export async function pruneAssetCache( } const storage = useStorage(config.storage); - const scopedKeys = await storage.getKeys(DIRECTUS_ASSET_CACHE_PREFIX); - const normalizedKeys = - scopedKeys.length === 0 ? await storage.getKeys(DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX) : []; - const keys = [...new Set([...scopedKeys, ...normalizedKeys])].filter( - (key) => - key.startsWith(DIRECTUS_ASSET_CACHE_PREFIX) || - key.startsWith(DIRECTUS_ASSET_CACHE_NORMALIZED_PREFIX) - ); + const prefix = await resolveAssetCacheStoragePrefix(); + const keys = await storage.getKeys(prefix); const cacheStorage = createAssetCacheStorage(config.storage); const summary: AssetCachePruneSummary = { scanned: keys.length, From e35462758973fd1b6a5443e7ebc0756d56f52645 Mon Sep 17 00:00:00 2001 From: Remi Huigen Date: Sun, 6 Sep 2026 20:57:14 +0200 Subject: [PATCH 8/8] chore(directus-client): clean up prune e2e and docs --- modules/directus-client/__tests__/asset-prune.e2e.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/directus-client/__tests__/asset-prune.e2e.test.ts b/modules/directus-client/__tests__/asset-prune.e2e.test.ts index 42bc05b9..75b0e1c3 100644 --- a/modules/directus-client/__tests__/asset-prune.e2e.test.ts +++ b/modules/directus-client/__tests__/asset-prune.e2e.test.ts @@ -65,12 +65,13 @@ describe("Directus asset-cache pruning end to end", () => { await new Promise((resolve) => setTimeout(resolve, 1_100)); await expect($fetch("/_directus/assets/asset-b")).resolves.toBe("asset-b"); - let keysForB: string[] = []; await waitFor(async () => { const keys = (await getKeys()).filter((key) => key !== "foreign-key"); - keysForB = keys.filter((key) => !keysAfterA.includes(key)); - return keysForB.length > 0; + 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();