diff --git a/GUIDELINES_NEST_REFERENCE_APP.md b/GUIDELINES_NEST_REFERENCE_APP.md index e35ac44..6ad749c 100644 --- a/GUIDELINES_NEST_REFERENCE_APP.md +++ b/GUIDELINES_NEST_REFERENCE_APP.md @@ -1,11 +1,11 @@ # GUIDELINES_NEST_REFERENCE_APP.md -## Core Philosophy - This app SERVES the eight nest-native libraries +## Core Philosophy - This app SERVES the nine nest-native libraries This is `nest-native/reference-app`: a production-shaped, multi-tenant -work-tracking SaaS whose only job is to put **all eight +work-tracking SaaS whose only job is to put **all nine [nest-native](https://github.com/nest-native) libraries** — `drizzle`, `trpc`, -`messaging`, `kafka`, `jobs`, `asyncapi`, `ai-sdk`, and `lockout` — under +`messaging`, `kafka`, `jobs`, `asyncapi`, `ai-sdk`, `lockout`, and `cache` — under realistic backend pressure and show how they compose. It is **read first and run second**. Every decision optimizes for a reader who wants to lift a pattern into their own app. @@ -15,10 +15,10 @@ app's constitution. ### 1. What This App Is (and Is Not) -- It **serves the libraries**. Every module exists to exercise one of the eight +- It **serves the libraries**. Every module exists to exercise one of the nine under realistic pressure — persistence, a typed API, reliable domain events, an event backbone, deferred jobs, a documented event catalog, a streaming AI - assistant, and brute-force login lockout. + assistant, brute-force login lockout, and tag-invalidated read caching. - It is **not** a product, **not** a starter or scaffold, and **not** a generic NestJS boilerplate. There is no CLI, no second frontend, no multi-database story, no GraphQL — those are deliberate omissions, not gaps to fill. diff --git a/README.md b/README.md index ed17027..997a43c 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@

License Node version - All eight nest-native libraries + All nine nest-native libraries

-A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all eight [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, a streaming AI assistant, and brute-force login lockout. +A production-shaped **multi-tenant work-tracking SaaS** — think a small Linear/Asana — that puts **all nine [nest-native](https://github.com/nest-native) libraries** under realistic backend pressure and composes them the way a real product would: persistence, a typed API, reliable domain events, an event backbone, deferred background jobs, a documented event catalog, a streaming AI assistant, brute-force login lockout, and tag-invalidated read caching. It is not a product. It **serves the libraries** — a credible demo a team could adapt, and a feedback loop: if something feels awkward here, that's signal to add API in a separate PR to the relevant library (that's how, for example, `MessagingModule.forRoot`'s `imports` option and `KafkaInboxConsumer`'s `dedupKey` argument came to exist). @@ -21,7 +21,7 @@ Follow one journey through the code and every library shows up where a real syst 5. **The event contracts are published.** An **AsyncAPI 3.0 catalog** at `/asyncapi` documents every event so another team could subscribe to your streams. 6. **AI reads the activity.** A streaming **project assistant** turns a project's recent activity into a status update, token by token. -## Eight libraries, eight chapters +## Nine libraries, nine chapters | Library | Its job in the story | Where in the code | | --- | --- | --- | @@ -32,6 +32,7 @@ Follow one journey through the code and every library shows up where a real syst | [`@nest-native/jobs`](https://github.com/nest-native/jobs) | **Deferred work** — the assignment reminder: enqueued in the same transaction as the `task.assigned` projection (`uniqueKey` = the event's dedup key), executed exactly once by the worker | `src/modules/reminders/`, `TaskAssignedProjection` in `src/modules/activity/`, `src/database/schema/jobs.ts` | | [`@nest-native/asyncapi`](https://github.com/nest-native/asyncapi) | **The event catalog** — an AsyncAPI 3.0 doc so other teams integrate with your streams | `src/modules/events-catalog/`, served at `/asyncapi` from `src/main.ts` | | [`@nest-native/ai-sdk`](https://github.com/nest-native/ai-sdk) | **AI over your data** — a streaming assistant that summarizes a project's activity | `src/modules/assistant/` (`@AiStream`), `POST /projects/:projectId/assistant` | +| [`@nest-native/cache`](https://github.com/nest-native/cache) | **Read caching without Redis** — expensive reads (`projects.*`, `activity.list`) cached in an in-memory L1 with **tag-based invalidation**: mutations evict by tag (`project:42`), the activity projection invalidates its own feed at the write site, and every entry's TTL is only the backstop. Coherence travels through `@stalefree/core`'s bus seam (in-process by default; set `CACHE_SOCKET_PATH` for the app + worker split) | `src/cache/cache.setup.ts` (`CacheModule.forRootAsync`), `projects.router.ts` + `activity.router.ts` (`wrap` + tags), `activity.service.ts` (write-site invalidation) | | [`@nest-native/lockout`](https://github.com/nest-native/lockout) | **Brute-force defense** — failed logins lock an identity (by email OR source IP) *before* the credential is checked; a Drizzle-backed counter on the same SQLite DB, `Retry-After` on the lock, reset on success. tRPC has no HTTP body for the guard's extractor to read, so the login handler calls `LockoutService` directly — the library's documented non-HTTP-transport recipe | `src/auth/lockout.setup.ts` (`LockoutModule.forRootAsync`), `src/auth/auth.service.ts` (`check` → `reportFailure`/`reportSuccess`) | Underneath, [`@nestjs-cls/transactional`](https://www.npmjs.com/package/@nestjs-cls/transactional) (with the official Drizzle adapter) ties the outbox write to the business write — its `transactionMode: 'auto'` runs the same `@Transactional()` code against better-sqlite3's synchronous driver locally and an async driver in production. @@ -92,6 +93,7 @@ src/ config/env.ts loadEnv() — single source of truth (incl. the optional kafka block) database/ DrizzleModule wiring + schema (orgs/users/projects/tasks/activity/...) + migrations auth/ scrypt passwords, HS256 JWT, AuthGuard, middleware; @nest-native/lockout login lockout (lockout.setup.ts) + cache/ @nest-native/cache read caching — tag invalidation through @stalefree/core (cache.setup.ts) context/ request-scoped CURRENT_USER / CURRENT_ORGANIZATION modules/ organizations/ users/ projects/ repos + services + tRPC routers (tenant-scoped) @@ -117,7 +119,7 @@ npm run test:cov # with c8 coverage npm run ci # typecheck, lint, complexity (≤15), test:cov, security:audit, build ``` -Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, the AI stream, and the login-lockout gate (fail N times → 429, even the right password is refused while locked) all have explicit tests. CI runs on **Node 22**. +Coverage here is **pragmatic, not 100%** — the 100% bar belongs to the libraries. The transactional workflow, the outbox worker, the inbox dedup, the reminder job's exactly-once scheduling and execution, the AsyncAPI catalog, the AI stream, and the login-lockout gate (fail N times → 429, even the right password is refused while locked), and cache coherence (mutations refresh cached reads long before TTL — tag invalidation, not expiry) all have explicit tests. CI runs on **Node 22**. Two **optional, local-only** layers sit on top (neither runs in CI, and forks work without them): @@ -143,6 +145,7 @@ ritual, and agent instructions — in | `@nest-native/drizzle` | `0.3.x` · `@nest-native/trpc` `0.6.x` · `@nest-native/kafka` `0.2.x` | | `@nest-native/messaging` | `0.2.x` · `@nest-native/asyncapi` `0.1.x` · `@nest-native/ai-sdk` `0.4.x` (on `ai@7`) | | `@nest-native/lockout` | `0.3.x` (on `@authlock/core` `0.3.x`) | +| `@nest-native/cache` | `0.1.x` (on `@stalefree/core` `0.1.x`) | | Drizzle ORM | `0.45.x` · `@nestjs-cls/transactional` `^3` | ## Philosophy diff --git a/package-lock.json b/package-lock.json index 183ddf9..1f0c930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@authlock/core": "^0.3.0", "@nest-native/ai-sdk": "^0.5.0", "@nest-native/asyncapi": "^0.2.0", + "@nest-native/cache": "^0.1.0", "@nest-native/drizzle": "^0.4.0", "@nest-native/jobs": "^0.1.0", "@nest-native/kafka": "^0.3.0", @@ -23,6 +24,7 @@ "@nestjs/common": "11.1.28", "@nestjs/core": "11.1.28", "@nestjs/platform-express": "11.1.28", + "@stalefree/core": "^0.1.0", "@trpc/server": "11.18.0", "ai": "^7.0.22", "better-sqlite3": "12.11.1", @@ -2425,6 +2427,24 @@ } } }, + "node_modules/@nest-native/cache": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@nest-native/cache/-/cache-0.1.0.tgz", + "integrity": "sha512-SGX1qr4Ln5bPVG8S/ST5xa6Ecjgm1QqSlflVUvT/lFIsh30S8prtOaSRp5WGukWz1GhWGyvrE3Yfpf9q7aws7g==", + "license": "MIT", + "dependencies": { + "@stalefree/core": "0.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.0.0" + } + }, "node_modules/@nest-native/drizzle": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@nest-native/drizzle/-/drizzle-0.4.0.tgz", @@ -2829,6 +2849,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stalefree/core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@stalefree/core/-/core-0.1.0.tgz", + "integrity": "sha512-s/q9Ctd9Ba1pZSJQbeWrca02GB2zVRT/2DLdK0Yd5d8PUaMMPmm4K2jnJFyg7g8BCY2d2Dtxx1Wih9I9SAVQ8A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "drizzle-orm": "^0.44.0 || ^0.45.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/package.json b/package.json index 1dfd581..049c9e8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@authlock/core": "^0.3.0", "@nest-native/ai-sdk": "^0.5.0", "@nest-native/asyncapi": "^0.2.0", + "@nest-native/cache": "^0.1.0", "@nest-native/drizzle": "^0.4.0", "@nest-native/jobs": "^0.1.0", "@nest-native/kafka": "^0.3.0", @@ -58,6 +59,7 @@ "@nestjs/common": "11.1.28", "@nestjs/core": "11.1.28", "@nestjs/platform-express": "11.1.28", + "@stalefree/core": "^0.1.0", "@trpc/server": "11.18.0", "ai": "^7.0.22", "better-sqlite3": "12.11.1", diff --git a/src/app.module.ts b/src/app.module.ts index 52a0310..8e7d083 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -32,6 +32,7 @@ import { ProjectsModule } from './modules/projects/projects.module'; import { TasksModule } from './modules/tasks/tasks.module'; import { UsersModule } from './modules/users/users.module'; import { AppTrpcModule } from './trpc/trpc.module'; +import { AppCacheModule } from './cache/cache.setup'; // Reliable-messaging wiring, now built on `@nest-native/messaging`. Kafka is an // opt-in profile: with KAFKA_BROKERS unset, the engine's claimer publishes @@ -97,6 +98,7 @@ function messagingImports(): NonNullable { @Module({ imports: [ DatabaseModule, + AppCacheModule, ClsModule.forRoot({ global: true, plugins: [ diff --git a/src/cache/cache.setup.ts b/src/cache/cache.setup.ts new file mode 100644 index 0000000..f3dd8f8 --- /dev/null +++ b/src/cache/cache.setup.ts @@ -0,0 +1,82 @@ +import { + Inject, + Injectable, + Logger, + Module, + type OnApplicationShutdown, +} from '@nestjs/common'; +import { CacheModule } from '@nest-native/cache'; +import type { InvalidationBus } from '@stalefree/core'; +import { SocketInvalidationBus } from '@stalefree/core/socket'; +import { loadEnv } from '../config/env'; + +/** + * Wires @stalefree/core into the app: an in-memory L1 read cache with + * tag-based invalidation. TTL (CACHE_TTL_MS, default 30s) is the delivery + * backstop — a missed invalidation can only serve stale until the TTL, never + * forever. + * + * Coherence: in the DEFAULT profile the whole app is one process, so the + * cache is exactly coherent with no bus at all. When the outbox/jobs worker + * runs as a SEPARATE process (`npm run start:worker`) it writes activity rows + * the app has cached — set CACHE_SOCKET_PATH (the same value in both + * processes) and the socket bus carries invalidations across: the worker's + * projection writes evict the app's L1. No L2 store: the database is a local + * SQLite file, so a shared warm tier would just duplicate it. + */ +export const CACHE_BUS = Symbol.for('reference-app:cache-bus'); + +const logger = new Logger('Cache'); + +@Injectable() +class CacheBusLifecycle implements OnApplicationShutdown { + constructor( + @Inject(CACHE_BUS) private readonly bus: InvalidationBus | undefined, + ) {} + + async onApplicationShutdown(): Promise { + await this.bus?.close?.(); + } +} + +@Module({ + providers: [ + { + provide: CACHE_BUS, + useFactory: async (): Promise => { + const env = loadEnv(); + if (!env.cacheSocketPath) { + return undefined; // single-process profile: L1 is coherent as-is + } + const bus = new SocketInvalidationBus({ + path: env.cacheSocketPath, + onError: (error) => logger.error('cache bus error', error), + }); + await bus.start(); + return bus; + }, + }, + CacheBusLifecycle, + ], + exports: [CACHE_BUS], +}) +class CacheBusModule {} + +@Module({ + imports: [ + CacheModule.forRootAsync({ + imports: [CacheBusModule], + inject: [CACHE_BUS], + useFactory: (bus: InvalidationBus | undefined) => { + const env = loadEnv(); + return { + defaultTtlMs: env.cacheTtlMs, + bus, + onError: (error, context) => + logger.error(`cache error (${context})`, error), + }; + }, + }), + ], +}) +export class AppCacheModule {} diff --git a/src/config/env.ts b/src/config/env.ts index 40ec48f..6c740d3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -25,6 +25,10 @@ export interface AppEnv { lockoutLimit: number; /** Login lockout: how long a locked identity stays locked, in ms. */ lockoutCooloffMs: number; + /** Read cache: TTL for cached reads, in ms (the delivery backstop). */ + cacheTtlMs: number; + /** Read cache: unix-socket path for cross-process invalidation (app + worker). Unset = in-process only. */ + cacheSocketPath: string | undefined; outbox: { pollIntervalMs: number; batchSize: number; @@ -131,6 +135,8 @@ export function loadEnv(): AppEnv { authTtlSeconds: Number.parseInt(process.env.AUTH_TTL_SECONDS ?? '3600', 10), lockoutLimit: readIntFromEnv('LOCKOUT_LIMIT', 5), lockoutCooloffMs: readIntFromEnv('LOCKOUT_COOLOFF_MS', 15 * 60_000), + cacheTtlMs: readIntFromEnv('CACHE_TTL_MS', 30_000), + cacheSocketPath: process.env.CACHE_SOCKET_PATH, outbox: { pollIntervalMs: readIntFromEnv('OUTBOX_POLL_MS', 2_000), batchSize: readIntFromEnv('OUTBOX_BATCH_SIZE', 32), diff --git a/src/modules/activity/activity.router.ts b/src/modules/activity/activity.router.ts index 8a48e04..bf8d17d 100644 --- a/src/modules/activity/activity.router.ts +++ b/src/modules/activity/activity.router.ts @@ -1,5 +1,6 @@ import { Inject, NotFoundException, UseGuards } from '@nestjs/common'; import { Input, Query, Router } from '@nest-native/trpc'; +import { CacheService } from '@nest-native/cache'; import { z } from 'zod'; import { AuthGuard } from '../../auth/auth.guard'; import type { CurrentOrganizationContext } from '../../auth/auth-context'; @@ -35,17 +36,24 @@ const ListActivityInputSchema = z.object({ export class ActivityRouter { constructor( @Inject(ActivityService) private readonly service: ActivityService, + @Inject(CacheService) private readonly cache: CacheService, @Inject(CURRENT_ORGANIZATION) private readonly currentOrg: CurrentOrganizationContext | null, ) {} @Query({ input: ListActivityInputSchema, output: z.array(ActivityEventSchema) }) - list(@Input('projectId') projectId: number) { + async list(@Input('projectId') projectId: number) { if (!this.currentOrg) { throw new NotFoundException('No active organization for this session'); } - return this.service - .list(this.currentOrg.id, projectId) - .map((event) => ({ ...event, createdAt: new Date(event.createdAt) })); + // The RAW rows are what gets cached; the Date mapping runs per response + // (mapping before caching would hand every hit the same mapped objects — + // cached values must be treated as immutable). + const rows = await this.cache.wrap( + `org:${this.currentOrg.id}:project:${projectId}:activity`, + () => this.service.list(this.currentOrg!.id, projectId), + { tags: [`project:${projectId}:activity`] }, + ); + return rows.map((event) => ({ ...event, createdAt: new Date(event.createdAt) })); } } diff --git a/src/modules/activity/activity.service.ts b/src/modules/activity/activity.service.ts index 802c3bd..5919888 100644 --- a/src/modules/activity/activity.service.ts +++ b/src/modules/activity/activity.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectTransaction } from '@nestjs-cls/transactional'; +import { CacheService } from '@nest-native/cache'; import { and, desc, eq } from 'drizzle-orm'; import type { AppDatabase } from '../../database/database'; import { type ActivityEvent, activityEvents } from '../../database/schema'; @@ -23,10 +24,13 @@ export interface RecordActivityInput { */ @Injectable() export class ActivityService { - constructor(@InjectTransaction() private readonly db: AppDatabase) {} + constructor( + @InjectTransaction() private readonly db: AppDatabase, + @Inject(CacheService) private readonly cache: CacheService, + ) {} record(input: RecordActivityInput): ActivityEvent | undefined { - return this.db + const row = this.db .insert(activityEvents) .values({ orgId: input.orgId, @@ -42,6 +46,16 @@ export class ActivityService { }) .returning() .get(); + if (row && row.projectId !== null) { + // Evict the project's cached feed everywhere a row was actually written + // (duplicates are no-ops and evict nothing). Fire-and-forget: `record` + // stays synchronous for the better-sqlite3 transaction it runs inside, + // and the sync driver means no other request can interleave mid-tx, so + // the pre-commit timing is not observable here; on an async driver this + // would move to an after-commit hook, with the TTL as the backstop. + void this.cache.invalidateTags([`project:${row.projectId}:activity`]); + } + return row; } list(orgId: number, projectId: number): ActivityEvent[] { diff --git a/src/modules/projects/projects.router.ts b/src/modules/projects/projects.router.ts index 2e367a4..a94666e 100644 --- a/src/modules/projects/projects.router.ts +++ b/src/modules/projects/projects.router.ts @@ -1,7 +1,10 @@ import { Inject, UseGuards } from '@nestjs/common'; import { Input, Mutation, Query, Router } from '@nest-native/trpc'; +import { CacheService } from '@nest-native/cache'; import { z } from 'zod'; import { AuthGuard } from '../../auth/auth.guard'; +import type { CurrentOrganizationContext } from '../../auth/auth-context'; +import { CURRENT_ORGANIZATION } from '../../context/request-context.module'; import { ProjectsService } from './projects.service'; const ProjectSchema = z.object({ @@ -20,25 +23,53 @@ const GetProjectInputSchema = z.object({ id: z.number().int().positive(), }); +/** + * Project reads are cached at the API seam (the service stays synchronous and + * cache-free): keys carry the org dimension for tenancy, and every entry is + * tagged so the create mutation — and any future project mutation — evicts + * precisely. The TTL is only the backstop; the tags do the real work. + */ @Router('projects') @UseGuards(AuthGuard) export class ProjectsRouter { constructor( @Inject(ProjectsService) private readonly service: ProjectsService, + @Inject(CacheService) private readonly cache: CacheService, + @Inject(CURRENT_ORGANIZATION) + private readonly currentOrg: CurrentOrganizationContext | null, ) {} @Query({ output: z.array(ProjectSchema) }) list() { - return this.service.list(); + const orgId = this.currentOrg?.id; + if (orgId === undefined) { + return this.service.list(); // service raises the canonical error + } + return this.cache.wrap( + `org:${orgId}:projects`, + () => this.service.list(), + { tags: [`org:${orgId}:projects`] }, + ); } @Query({ input: GetProjectInputSchema, output: ProjectSchema }) get(@Input('id') id: number) { - return this.service.get(id); + const orgId = this.currentOrg?.id; + if (orgId === undefined) { + return this.service.get(id); + } + return this.cache.wrap( + `org:${orgId}:project:${id}`, + () => this.service.get(id), + { tags: [`org:${orgId}:projects`, `project:${id}`] }, + ); } @Mutation({ input: CreateProjectInputSchema, output: ProjectSchema }) - create(@Input() input: z.infer) { - return this.service.create(input); + async create(@Input() input: z.infer) { + const project = this.service.create(input); + // Evict the org's project reads everywhere (tags fan out over the bus). + await this.cache.invalidateTags([`org:${project.orgId}:projects`]); + return project; } } diff --git a/test/e2e/stalefree-cache.spec.ts b/test/e2e/stalefree-cache.spec.ts new file mode 100644 index 0000000..2ca6a7c --- /dev/null +++ b/test/e2e/stalefree-cache.spec.ts @@ -0,0 +1,125 @@ +import 'reflect-metadata'; +import { strict as assert } from 'node:assert'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, before, test } from 'node:test'; +import type { INestApplication } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { OutboxClaimer } from '@nest-native/messaging'; +import superjson from 'superjson'; +import type { SuperJSONResult } from 'superjson'; +import { seedDatabase } from '../../scripts/seed'; + +// The cache chapter's e2e: reads are cached with a TEN-MINUTE TTL, so if tag +// invalidation ever failed, every assertion below would see stale data for the +// rest of the run — freshness within the TTL window IS the proof that the +// invalidation (not expiry) did the work. +const trpcPath = '/trpc'; +const AUTH_SECRET = 'e2e-cache-secret-must-be-at-least-32-characters-x'; + +let app: INestApplication; +let baseUrl: string; +let token: string; + +before(async () => { + const dbPath = join( + tmpdir(), + `nest-native-reference-app-e2e-cache-${process.pid}-${Date.now()}.db`, + ); + process.env.DATABASE_URL = dbPath; + process.env.TRPC_PATH = trpcPath; + process.env.AUTH_SECRET = AUTH_SECRET; + process.env.CACHE_TTL_MS = String(10 * 60_000); // staleness would be glaring + + seedDatabase(dbPath); + + const { AppModule } = await import('../../src/app.module'); + app = await NestFactory.create(AppModule, { logger: false }); + await app.listen(0, '127.0.0.1'); + baseUrl = await app.getUrl(); + + const login = await call<{ token: string }>('auth.login', { + email: 'admin@acme.test', + password: 'admin123!', + }); + token = login.token; +}); + +after(async () => { + await app.close(); +}); + +interface TrpcSuccess { + result: { data: SuperJSONResult }; +} + +async function call(name: string, input?: unknown): Promise { + const isQuery = input === undefined || name.includes('.list') || name.includes('.get'); + const headers: Record = { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + let response: Response; + if (name.startsWith('auth.login') || name.includes('create') || name.includes('assign') || name.includes('complete')) { + response = await fetch(`${baseUrl}${trpcPath}/${name}`, { + method: 'POST', + headers, + body: JSON.stringify(superjson.serialize(input)), + }); + } else if (isQuery && input !== undefined) { + const encoded = encodeURIComponent(JSON.stringify(superjson.serialize(input))); + response = await fetch(`${baseUrl}${trpcPath}/${name}?input=${encoded}`, { headers }); + } else { + response = await fetch(`${baseUrl}${trpcPath}/${name}`, { headers }); + } + assert.equal(response.status, 200, `${name} expected 200`); + const body = (await response.json()) as TrpcSuccess; + return superjson.deserialize(body.result.data); +} + +test('projects.list is served from cache and refreshed by create (tag invalidation, not TTL)', async () => { + const first = await call>('projects.list'); + const initialCount = first.length; + assert.ok(initialCount >= 1, 'seeded project present'); + + // Cached read: identical payload (and, with a 10-minute TTL, provably cached). + const second = await call>('projects.list'); + assert.equal(second.length, initialCount); + + // The mutation must evict the org's project tags — the next read reflects + // the new project immediately, long before the TTL could expire. + const created = await call<{ id: number; name: string }>('projects.create', { + name: 'Cache Chapter Project', + }); + const third = await call>('projects.list'); + assert.equal(third.length, initialCount + 1, 'create invalidated the cached list'); + assert.ok(third.some((p) => p.id === created.id)); + + // projects.get on the fresh project caches + serves. + const got = await call<{ id: number; name: string }>('projects.get', { id: created.id }); + assert.equal(got.name, 'Cache Chapter Project'); +}); + +test('activity.list refreshes when the projection writes (write-site invalidation)', async () => { + const projects = await call>('projects.list'); + const projectId = projects[0]!.id; + + const before = await call>('activity.list', { projectId }); + + // A task mutation emits a domain event; the claimer tick (tests drive the + // relay manually, the repo convention) runs the in-process handler, which + // writes the activity row — and ActivityService.record invalidates the + // project's feed tag at that write site. + await call<{ id: number }>('tasks.create', { + projectId, + title: 'Cache invalidation task', + }); + await app.get(OutboxClaimer).tick(); + + // Far below the 10-minute TTL — only tag invalidation can make this fresh. + const after_ = await call>('activity.list', { projectId }); + assert.ok( + after_.length > before.length, + `the cached feed refreshed within the TTL window (${before.length} -> ${after_.length})`, + ); +});