diff --git a/packages/express-context/src/loaders/__tests__/create-loader.test.ts b/packages/express-context/src/loaders/__tests__/create-loader.test.ts new file mode 100644 index 000000000..0bd730c1b --- /dev/null +++ b/packages/express-context/src/loaders/__tests__/create-loader.test.ts @@ -0,0 +1,81 @@ +import { createModuleLoader } from '../create-loader'; +import type { LoaderContext } from '../types'; + +function makeContext(databaseId: string, apiId?: string): LoaderContext { + return { + servicesPool: {} as LoaderContext['servicesPool'], + tenantPool: {} as LoaderContext['tenantPool'], + databaseId, + apiId, + dbname: `tenant_${databaseId}`, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('createModuleLoader', () => { + it('does not refresh the TTL on cache hits by default', async () => { + let calls = 0; + const loader = createModuleLoader({ + name: 'boundedDefault', + ttlMs: 200, + resolve: async () => ++calls, + }); + const ctx = makeContext('db-1'); + + await expect(loader.resolve(ctx)).resolves.toBe(1); + + await sleep(100); + await expect(loader.resolve(ctx)).resolves.toBe(1); + + await sleep(120); + await expect(loader.resolve(ctx)).resolves.toBe(2); + expect(calls).toBe(2); + }); + + it('allows loaders to opt into refreshing the TTL on cache hits', async () => { + let calls = 0; + const loader = createModuleLoader({ + name: 'slidingOverride', + ttlMs: 200, + updateAgeOnGet: true, + resolve: async () => ++calls, + }); + const ctx = makeContext('db-1'); + + await expect(loader.resolve(ctx)).resolves.toBe(1); + + await sleep(100); + await expect(loader.resolve(ctx)).resolves.toBe(1); + + await sleep(120); + await expect(loader.resolve(ctx)).resolves.toBe(1); + expect(calls).toBe(1); + }); + + it('invalidates both database and database:api cache keys for a database', async () => { + let calls = 0; + const loader = createModuleLoader({ + name: 'invalidateByDatabase', + ttlMs: 10_000, + resolve: async (ctx) => `${ctx.databaseId}:${ctx.apiId ?? 'none'}:${++calls}`, + }); + const dbOnlyCtx = makeContext('db-1'); + const apiCtx = makeContext('db-1', 'api-1'); + const otherDbCtx = makeContext('db-2'); + + await expect(loader.resolve(dbOnlyCtx)).resolves.toBe('db-1:none:1'); + await expect(loader.resolve(apiCtx)).resolves.toBe('db-1:api-1:2'); + await expect(loader.resolve(otherDbCtx)).resolves.toBe('db-2:none:3'); + expect(loader.cacheSize).toBe(3); + + loader.invalidate('db-1'); + expect(loader.cacheSize).toBe(1); + + await expect(loader.resolve(dbOnlyCtx)).resolves.toBe('db-1:none:4'); + await expect(loader.resolve(apiCtx)).resolves.toBe('db-1:api-1:5'); + await expect(loader.resolve(otherDbCtx)).resolves.toBe('db-2:none:3'); + }); +}); diff --git a/packages/express-context/src/loaders/create-loader.ts b/packages/express-context/src/loaders/create-loader.ts index db2b55a82..73984a34c 100644 --- a/packages/express-context/src/loaders/create-loader.ts +++ b/packages/express-context/src/loaders/create-loader.ts @@ -18,6 +18,12 @@ export interface CreateLoaderOptions { ttlMs?: number; /** Max cache entries before LRU eviction (default: 100) */ max?: number; + /** + * Whether cache hits refresh the TTL. + * Defaults to false so ttlMs is a bounded staleness window. + * Set true only for intentionally sliding caches. + */ + updateAgeOnGet?: boolean; /** The actual resolution function. Called on cache miss. */ resolve: (ctx: LoaderContext) => Promise; } @@ -30,7 +36,7 @@ export function createModuleLoader(opts: CreateLoaderOptions): ModuleLoade const cache = new LRUCache({ max: opts.max ?? DEFAULT_MAX, ttl: opts.ttlMs ?? DEFAULT_TTL_MS, - updateAgeOnGet: true, + updateAgeOnGet: opts.updateAgeOnGet ?? false, allowStale: false, });