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 @@
-
+
-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})`,
+ );
+});