feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View 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());
});
}

View 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,
};
}
}

View 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;
}

View 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
View 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';

View 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);
}
}
}

View 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);
});
});