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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<void> {
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<number>({
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<number>({
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<string>({
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');
});
});
8 changes: 7 additions & 1 deletion packages/express-context/src/loaders/create-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface CreateLoaderOptions<T> {
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<T | undefined>;
}
Expand All @@ -30,7 +36,7 @@ export function createModuleLoader<T>(opts: CreateLoaderOptions<T>): ModuleLoade
const cache = new LRUCache<string, T | undefined>({
max: opts.max ?? DEFAULT_MAX,
ttl: opts.ttlMs ?? DEFAULT_TTL_MS,
updateAgeOnGet: true,
updateAgeOnGet: opts.updateAgeOnGet ?? false,
allowStale: false,
});

Expand Down
Loading