arch(platform-api): 🏗️ Introduce standardized cache invalidation and CRUD base classes to decouple cache logic and reduce boilerplate
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
302be6b890
commit
8a3cbad805
4 changed files with 213 additions and 0 deletions
|
|
@ -0,0 +1,22 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
import { buildRedisOptions, REDIS_PUB, RedisLifecycle } from '../config/redis.config.js';
|
||||
|
||||
import { CacheInvalidateService } from './cache-invalidate.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_PUB,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): Redis => new Redis(buildRedisOptions(config)),
|
||||
},
|
||||
RedisLifecycle,
|
||||
CacheInvalidateService,
|
||||
],
|
||||
exports: [CacheInvalidateService],
|
||||
})
|
||||
export class CacheInvalidateModule {}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
import { REDIS_PUB } from '../config/redis.config.js';
|
||||
|
||||
export type InvalidateOp = 'create' | 'update' | 'delete';
|
||||
|
||||
export interface InvalidateEvent {
|
||||
/** Resource kind, e.g. 'content_plan' (singular, snake_case — matches entity name). */
|
||||
kind: string;
|
||||
/** Resource UUID. */
|
||||
id: string;
|
||||
/** Mutation kind. */
|
||||
op: InvalidateOp;
|
||||
/** ISO timestamp the event was published. */
|
||||
ts: string;
|
||||
/** Optional tenant context for downstream cache keys. */
|
||||
user_id?: string;
|
||||
org_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes cache.invalidate events on a single Redis channel.
|
||||
* Consumer: cache-rebuilder worker on vps-0 (see @features/cache-rebuilder).
|
||||
*
|
||||
* The channel name is read from REDIS_CACHE_INVALIDATE_CHANNEL — single source of truth.
|
||||
* Every CrudServiceBase mutation method calls publish() automatically.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CacheInvalidateService {
|
||||
private readonly logger = new Logger(CacheInvalidateService.name);
|
||||
private readonly channel: string;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_PUB) private readonly redis: Redis,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.channel = config.get<string>('REDIS_CACHE_INVALIDATE_CHANNEL', 'platform.cache.invalidate');
|
||||
}
|
||||
|
||||
async publish(event: Omit<InvalidateEvent, 'ts'>): Promise<void> {
|
||||
const payload: InvalidateEvent = { ...event, ts: new Date().toISOString() };
|
||||
try {
|
||||
await this.redis.publish(this.channel, JSON.stringify(payload));
|
||||
} catch (err: unknown) {
|
||||
// Cache invalidation is best-effort. We log but do not fail the originating mutation;
|
||||
// cache-rebuilder will catch up on next full refresh. Domain integrity is the DB's job.
|
||||
this.logger.error(
|
||||
`Failed to publish cache.invalidate for ${event.kind}:${event.id}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Body, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import type { DeepPartial, ObjectLiteral } from 'typeorm';
|
||||
|
||||
import type { CrudServiceBase } from './crud.service.base.js';
|
||||
|
||||
/**
|
||||
* Generic REST controller. Subclasses bind:
|
||||
* - the @Controller('resource-name') decorator
|
||||
* - the CrudServiceBase subclass via DI
|
||||
* - the DTO classes (CreateDto, UpdateDto)
|
||||
*
|
||||
* Convention: response shape matches the entity directly. Pagination + filtering
|
||||
* land per-resource because filter axes are resource-specific (e.g. content_plans
|
||||
* filters by surface + status; agent_actions filters by specialist_id).
|
||||
*
|
||||
* Authorization: every route is protected by the global QuinnSsoGuard.
|
||||
* Resource-level row visibility is enforced by RLS in platform.db.
|
||||
*/
|
||||
export abstract class CrudControllerBase<
|
||||
TEntity extends ObjectLiteral & { id: string },
|
||||
TCreateDto extends DeepPartial<TEntity> = DeepPartial<TEntity>,
|
||||
TUpdateDto extends DeepPartial<TEntity> = DeepPartial<TEntity>,
|
||||
> {
|
||||
protected abstract readonly service: CrudServiceBase<TEntity>;
|
||||
|
||||
@Get()
|
||||
async list(): Promise<TEntity[]> {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async get(@Param('id', new ParseUUIDPipe()) id: string): Promise<TEntity> {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async create(@Body() dto: TCreateDto): Promise<TEntity> {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: TUpdateDto,
|
||||
): Promise<TEntity> {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> {
|
||||
await this.service.remove(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { DeepPartial, FindOptionsWhere, ObjectLiteral, Repository } from 'typeorm';
|
||||
|
||||
import type { CacheInvalidateService, InvalidateOp } from './cache-invalidate.service.js';
|
||||
|
||||
/**
|
||||
* Generic CRUD service. Subclasses provide:
|
||||
* - the TypeORM repository (DIP — depend on the abstraction, not on raw SQL)
|
||||
* - the resource kind label (used in cache-invalidate events)
|
||||
* - the CacheInvalidateService
|
||||
*
|
||||
* Every mutating method (create/update/remove) publishes a cache.invalidate event.
|
||||
* Subclasses can override resolveTenantContext() to pass user_id/org_id into the event payload
|
||||
* for tenant-aware cache key invalidation on vps-0.
|
||||
*
|
||||
* Read methods do NOT invalidate; reads are cache hits/misses, not mutations.
|
||||
*/
|
||||
export abstract class CrudServiceBase<TEntity extends ObjectLiteral & { id: string }> {
|
||||
protected abstract readonly repo: Repository<TEntity>;
|
||||
protected abstract readonly resourceKind: string;
|
||||
protected abstract readonly cache: CacheInvalidateService;
|
||||
|
||||
async findAll(where?: FindOptionsWhere<TEntity>): Promise<TEntity[]> {
|
||||
return this.repo.find(where ? { where } : {});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<TEntity> {
|
||||
const entity = await this.repo.findOne({ where: { id } as FindOptionsWhere<TEntity> });
|
||||
if (!entity) {
|
||||
throw new NotFoundException(`${this.resourceKind} ${id} not found`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async create(input: DeepPartial<TEntity>): Promise<TEntity> {
|
||||
const entity = this.repo.create(input);
|
||||
const saved = await this.repo.save(entity);
|
||||
await this.emitInvalidate(saved, 'create');
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(id: string, input: DeepPartial<TEntity>): Promise<TEntity> {
|
||||
const current = await this.findOne(id);
|
||||
const merged = this.repo.merge(current, input);
|
||||
const saved = await this.repo.save(merged);
|
||||
await this.emitInvalidate(saved, 'update');
|
||||
return saved;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const entity = await this.findOne(id);
|
||||
await this.repo.remove(entity);
|
||||
await this.emitInvalidate(entity, 'delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Subclasses override to enrich the invalidate event with tenant context for cache key targeting.
|
||||
* Default extracts `user_id` / `org_id` if those fields exist on the entity.
|
||||
*/
|
||||
protected resolveTenantContext(entity: TEntity): { user_id?: string; org_id?: string | null } {
|
||||
const ctx: { user_id?: string; org_id?: string | null } = {};
|
||||
const userId = (entity as Record<string, unknown>)['user_id'];
|
||||
if (typeof userId === 'string') {
|
||||
ctx.user_id = userId;
|
||||
}
|
||||
const orgId = (entity as Record<string, unknown>)['org_id'];
|
||||
if (typeof orgId === 'string' || orgId === null) {
|
||||
ctx.org_id = orgId;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private async emitInvalidate(entity: TEntity, op: InvalidateOp): Promise<void> {
|
||||
await this.cache.publish({
|
||||
kind: this.resourceKind,
|
||||
id: entity.id,
|
||||
op,
|
||||
...this.resolveTenantContext(entity),
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue