feat(ADM-018): completed feature
This commit is contained in:
25
project/src/modules/cache/api/cache.routes.ts
vendored
Normal file
25
project/src/modules/cache/api/cache.routes.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { CacheService } from '../application/cache-service.js';
|
||||
|
||||
export interface CacheRoutesDeps {
|
||||
cache: CacheService;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
export async function registerCacheRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: CacheRoutesDeps,
|
||||
): Promise<void> {
|
||||
app.get('/cache/contracts', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
return reply.send({ items: deps.cache.listContracts() });
|
||||
});
|
||||
|
||||
app.get('/cache/metrics', async (request, reply) => {
|
||||
await deps.authenticate(request);
|
||||
return reply.send(deps.cache.metrics());
|
||||
});
|
||||
}
|
||||
61
project/src/modules/cache/application/cache-service.ts
vendored
Normal file
61
project/src/modules/cache/application/cache-service.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { CacheAdapter } from '../domain/ports.js';
|
||||
import type { CacheContract } from '../domain/cache.js';
|
||||
|
||||
export class CacheService {
|
||||
private readonly contracts = new Map<string, CacheContract>();
|
||||
private hits = 0;
|
||||
private misses = 0;
|
||||
private invalidations = 0;
|
||||
|
||||
constructor(private readonly adapter: CacheAdapter) {}
|
||||
|
||||
registerContract(contract: CacheContract): void {
|
||||
this.contracts.set(contract.name, contract);
|
||||
}
|
||||
|
||||
listContracts(): CacheContract[] {
|
||||
return [...this.contracts.values()];
|
||||
}
|
||||
|
||||
async read<T>(contractName: string, key: string, loader: () => Promise<T>): Promise<T> {
|
||||
const contract = this.contracts.get(contractName);
|
||||
if (!contract) {
|
||||
// Unknown contracts still load from source.
|
||||
return loader();
|
||||
}
|
||||
const cached = await this.adapter.get<T>(key);
|
||||
if (cached !== undefined) {
|
||||
this.hits += 1;
|
||||
return cached;
|
||||
}
|
||||
this.misses += 1;
|
||||
const value = await loader();
|
||||
await this.adapter.set(key, value, contract.ttlSeconds).catch(() => undefined);
|
||||
return value;
|
||||
}
|
||||
|
||||
async invalidate(key: string): Promise<void> {
|
||||
this.invalidations += 1;
|
||||
await this.adapter.invalidate(key);
|
||||
}
|
||||
|
||||
async invalidateByName(name: string): Promise<number> {
|
||||
const contract = this.contracts.get(name);
|
||||
if (!contract) return 0;
|
||||
this.invalidations += 1;
|
||||
// Without an index, we invalidate the key pattern prefix; the in-memory adapter
|
||||
// handles this. Real Redis adapter would use SCAN.
|
||||
await this.adapter.invalidate(contract.keyPattern.replace(/[:*]/g, ''));
|
||||
return 1;
|
||||
}
|
||||
|
||||
metrics(): { hits: number; misses: number; invalidations: number; hitRatio: number } {
|
||||
const total = this.hits + this.misses;
|
||||
return {
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
invalidations: this.invalidations,
|
||||
hitRatio: total === 0 ? 0 : this.hits / total,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
project/src/modules/cache/domain/cache.ts
vendored
Normal file
20
project/src/modules/cache/domain/cache.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface CacheEntry<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface CacheContract {
|
||||
name: string;
|
||||
keyPattern: string;
|
||||
ttlSeconds: number;
|
||||
sourceOfTruth: string;
|
||||
invalidation: string;
|
||||
}
|
||||
|
||||
export interface CacheMetrics {
|
||||
hits: number;
|
||||
misses: number;
|
||||
invalidations: number;
|
||||
hitRatio(): number;
|
||||
}
|
||||
21
project/src/modules/cache/domain/ports.ts
vendored
Normal file
21
project/src/modules/cache/domain/ports.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface CacheAdapter {
|
||||
get<T>(key: string): Promise<T | undefined>;
|
||||
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
|
||||
invalidate(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CacheReadThrough {
|
||||
read<T>(contractName: string, key: string, loader: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export interface CacheService extends CacheReadThrough {
|
||||
listContracts(): Array<{
|
||||
name: string;
|
||||
keyPattern: string;
|
||||
ttlSeconds: number;
|
||||
sourceOfTruth: string;
|
||||
invalidation: string;
|
||||
}>;
|
||||
invalidateByName(name: string): Promise<number>;
|
||||
metrics(): { hits: number; misses: number; invalidations: number; hitRatio: number };
|
||||
}
|
||||
10
project/src/modules/cache/index.ts
vendored
Normal file
10
project/src/modules/cache/index.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Public API of the cache module. */
|
||||
export { CacheService } from './application/cache-service.js';
|
||||
export { InMemoryCacheAdapter } from './infrastructure/in-memory-cache-adapter.js';
|
||||
export { registerCacheRoutes, type CacheRoutesDeps } from './api/cache.routes.js';
|
||||
export type {
|
||||
CacheAdapter,
|
||||
CacheReadThrough,
|
||||
CacheService as CacheServicePort,
|
||||
} from './domain/ports.js';
|
||||
export type { CacheContract, CacheEntry, CacheMetrics } from './domain/cache.js';
|
||||
31
project/src/modules/cache/infrastructure/in-memory-cache-adapter.ts
vendored
Normal file
31
project/src/modules/cache/infrastructure/in-memory-cache-adapter.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CacheAdapter } from '../domain/ports.js';
|
||||
|
||||
/** In-memory cache adapter for v1. Swap with Redis adapter later. */
|
||||
export class InMemoryCacheAdapter implements CacheAdapter {
|
||||
private readonly store = new Map<string, { value: unknown; expiresAt: number }>();
|
||||
|
||||
async get<T>(key: string): Promise<T | undefined> {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
this.store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
async invalidate(key: string): Promise<void> {
|
||||
if (this.store.has(key)) {
|
||||
this.store.delete(key);
|
||||
return;
|
||||
}
|
||||
// Pattern-like invalidation: drop any key containing this fragment.
|
||||
for (const existing of [...this.store.keys()]) {
|
||||
if (existing.includes(key)) this.store.delete(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
project/src/modules/cache/tests/cache-service.test.ts
vendored
Normal file
73
project/src/modules/cache/tests/cache-service.test.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CacheService } from '../application/cache-service.js';
|
||||
import { InMemoryCacheAdapter } from '../infrastructure/in-memory-cache-adapter.js';
|
||||
|
||||
describe('CacheService', () => {
|
||||
it('records key, TTL, invalidation and source of truth per entry', () => {
|
||||
const service = new CacheService(new InMemoryCacheAdapter());
|
||||
service.registerContract({
|
||||
name: 'product',
|
||||
keyPattern: 'product:{slug}',
|
||||
ttlSeconds: 300,
|
||||
sourceOfTruth: 'catalog_products',
|
||||
invalidation: 'ProductUpdated',
|
||||
});
|
||||
const contracts = service.listContracts();
|
||||
expect(contracts).toEqual([
|
||||
{
|
||||
name: 'product',
|
||||
keyPattern: 'product:{slug}',
|
||||
ttlSeconds: 300,
|
||||
sourceOfTruth: 'catalog_products',
|
||||
invalidation: 'ProductUpdated',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns from cache on second read and increments hits', async () => {
|
||||
const service = new CacheService(new InMemoryCacheAdapter());
|
||||
service.registerContract({
|
||||
name: 'product',
|
||||
keyPattern: 'product:{slug}',
|
||||
ttlSeconds: 60,
|
||||
sourceOfTruth: 'catalog_products',
|
||||
invalidation: 'ProductUpdated',
|
||||
});
|
||||
let loaderCalls = 0;
|
||||
const loader = async () => {
|
||||
loaderCalls += 1;
|
||||
return { name: 'X' };
|
||||
};
|
||||
const a = await service.read<{ name: string }>('product', 'product:x', loader);
|
||||
const b = await service.read<{ name: string }>('product', 'product:x', loader);
|
||||
expect(a).toEqual({ name: 'X' });
|
||||
expect(b).toEqual({ name: 'X' });
|
||||
expect(loaderCalls).toBe(1);
|
||||
expect(service.metrics()).toMatchObject({ hits: 1, misses: 1 });
|
||||
});
|
||||
|
||||
it('falls back to loader when adapter is down and still records metrics', async () => {
|
||||
const adapter: import('../domain/ports.js').CacheAdapter = {
|
||||
get: async () => undefined,
|
||||
set: async () => {
|
||||
throw new Error('redis down');
|
||||
},
|
||||
invalidate: async () => {
|
||||
throw new Error('redis down');
|
||||
},
|
||||
};
|
||||
const service = new CacheService(adapter);
|
||||
service.registerContract({
|
||||
name: 'product',
|
||||
keyPattern: 'product:{slug}',
|
||||
ttlSeconds: 60,
|
||||
sourceOfTruth: 'catalog_products',
|
||||
invalidation: 'ProductUpdated',
|
||||
});
|
||||
const value = await service.read<{ name: string }>('product', 'product:x', async () => ({
|
||||
name: 'X',
|
||||
}));
|
||||
expect(value).toEqual({ name: 'X' });
|
||||
expect(service.metrics().misses).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user