diff --git a/packages/3-extensions/middleware-cache/README.md b/packages/3-extensions/middleware-cache/README.md index 4251761aea94..51f25d9422b3 100644 --- a/packages/3-extensions/middleware-cache/README.md +++ b/packages/3-extensions/middleware-cache/README.md @@ -1,42 +1,70 @@ -# @internal/middleware-cache +# @prisma-next/middleware-cache A family-agnostic, opt-in caching middleware for Prisma Next runtimes. -Built on the `interceptQuery` hook on `RuntimeMiddleware`: on a cache hit, the middleware short-circuits the query and returns the cached rows; the driver is never invoked. On a cache miss, the middleware buffers rows from the driver and commits them to the store on successful completion. +Built on the `intercept` hook on `RuntimeMiddleware` (added in TML-2143 M1): on a cache hit, the middleware short-circuits execution and returns the cached rows; the driver is never invoked. On a cache miss, the middleware buffers rows from the driver and commits them to the store on successful completion. -The package depends only on `@internal/framework-components/runtime` — no SQL or Mongo runtime dependency. Cache keys come from `RuntimeMiddlewareContext.contentHash(exec)`, which the family runtime populates, so SQL and Mongo runtimes both work out of the box. +The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. Cache keys come from `RuntimeMiddlewareContext.contentHash(exec)`, which the family runtime populates, so SQL and Mongo runtimes both work out of the box. ## Responsibilities -- Provide an opt-in caching `RuntimeMiddleware` that short-circuits repeated reads via the `interceptQuery` hook. +- Provide an opt-in caching `RuntimeMiddleware` that short-circuits repeated reads via the `intercept` hook. - Define the `cacheAnnotation` handle (read-only) that lane terminals (SQL DSL `.annotate(...)`, ORM read terminals) use to attach per-query cache parameters (`ttl`, `skip`, `key`). +- Define the `uncacheAnnotation` handle (write-only) for mutation-driven invalidation controls. - Resolve the cache key per execution: per-query `cacheAnnotation({ key })` override, otherwise `RuntimeMiddlewareContext.contentHash(exec)` from the family runtime. - Buffer driver rows on a miss and commit to the `CacheStore` only on successful completion (`completed: true && source: 'driver'`). - Bypass the cache when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. +- Support global read caching (`readCaching`, `defaultTtlMs`), global read miss dedupe (`readDedupe`), and global mutation invalidation (`uncacheOnMutation`). +- Support configurable store operation execution mode (`storeOperationMode`: `await` or `detached`) to balance latency and consistency. +- Support central invalidation strategy selection via `cacheStrategy.mode` (`targeted`, `broad`, `versioned`). +- Expose standalone invalidation through `middleware.uncache(...)` and helper `uncache(middleware, actions)`. - Ship a default in-memory LRU-with-TTL `CacheStore` and expose the `CacheStore` interface for pluggable backends (Redis, Memcached, etc.). ## Dependencies -- `@internal/framework-components/runtime` — the only production dependency. Provides `RuntimeMiddleware`, `RuntimeMiddlewareContext` (with `contentHash` and `scope`), `defineAnnotation`, `AfterQueryResult`, and query orchestrator integration via `runQueryWithMiddleware`. +- `@prisma-next/framework-components/runtime` - the only production dependency. Provides `RuntimeMiddleware`, `RuntimeMiddlewareContext` (with `contentHash` and `scope`), `defineAnnotation`, `AfterExecuteResult`, and the orchestrator integration via `runWithMiddleware`. -The package does **not** depend on `@internal/sql-runtime`, `@internal/mongo-runtime`, or any target adapter. It does not import `node:crypto` — hashing the canonical execution identity is the family runtime's responsibility (via `@internal/utils/hash-identity` in the SQL and Mongo runtimes today). +The package does **not** depend on `@prisma-next/sql-runtime`, `@prisma-next/mongo-runtime`, or any target adapter. It does not import `node:crypto` — hashing the canonical execution identity is the family runtime's responsibility (via `@prisma-next/utils/hash-identity` in the SQL and Mongo runtimes today). ## Quick start ```typescript -import postgres from '@internal/postgres/runtime'; +import postgres from '@prisma-next/postgres/runtime'; import { cacheAnnotation, createCacheMiddleware, -} from '@internal/middleware-cache'; + uncache, + uncacheAnnotation, +} from '@prisma-next/middleware-cache'; import type { Contract } from './contract.d'; import contractJson from './contract.json' with { type: 'json' }; +const cacheMiddleware = createCacheMiddleware({ + maxEntries: 1_000, + cacheStrategy: { + mode: 'targeted', + generation: { + bumpOn: 'uncache', + scope: 'detected-models', + guard: { + enabled: false, + maxDeletesPerBump: 500, + }, + }, + }, + readCaching: true, + readDedupe: true, + storeOperationMode: 'await', + defaultTtlMs: 60_000, + uncacheOnMutation: true, + namespace: 'app', +}); + const db = postgres({ contractJson, url: process.env['DATABASE_URL']!, - middleware: [createCacheMiddleware({ maxEntries: 1000 })], + middleware: [cacheMiddleware], }); // First call: hits the database, caches the raw rows. @@ -50,23 +78,48 @@ const second = await db.orm.User.first({ id: 1 }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })), ); -// Un-annotated queries are never cached — caching is strictly opt-in. -const fresh = await db.orm.User.first({ id: 1 }); // always hits the DB +// Un-annotated queries are also cached here because readCaching: true and +// defaultTtlMs: 60_000 are set globally. Remove those options to opt out. +const fresh = await db.orm.User.first({ id: 1 }); + +await cacheMiddleware.uncache([ + { namespace: 'app' }, + { namespace: 'tenant-a', keys: ['user:1', 'user:2'] }, + { models: ['users'] }, +]); + +await uncache(cacheMiddleware, [{ namespace: 'tenant-b' }]); + +await db.orm.User.update({ id: 1, name: 'A' }, (meta) => + meta.annotate( + uncacheAnnotation({ + uncache: [{ namespace: 'app' }, { keys: ['user:1'] }], + }), + ), +); ``` ## Opt-in by annotation -The cache middleware acts only on plans that carry a `cacheAnnotation` payload with a `ttl` set: +The cache middleware acts on read plans with either explicit `cacheAnnotation({ ttl })` or enabled global read policy (`readCaching` + `defaultTtlMs`): -| Annotation state | Behavior | +| Annotation / policy state | Behavior | |---|---| -| No `cacheAnnotation` on the plan | Pass through; never cached. | -| `cacheAnnotation({ })` (no `ttl`) | Pass through; never cached. | -| `cacheAnnotation({ skip: true })` | Pass through; never cached. | +| No `cacheAnnotation` and global read policy disabled | Pass through; never cached. | +| `cacheAnnotation({ })` (no `ttl`) and no global `defaultTtlMs` | Pass through; never cached. | +| `cacheAnnotation({ skip: true })` or `cacheAnnotation({ enabled: false })` | Pass through; never cached. | | `cacheAnnotation({ ttl })` | Cache lookup; commit on miss + success. | | `cacheAnnotation({ ttl, key })` | As above, but use the supplied key verbatim. | +| No annotation, but `readCaching: true` and `defaultTtlMs` set | Cache lookup; commit on miss + success. | + -The annotation is **read-only**: it declares `applicableTo: ['read']`, so the lane gate (TML-2143 M2) rejects passing it to write terminals at both type and runtime levels. "Cache a mutation" is structurally impossible without an `as any` cast bypass at both the type and runtime levels — the cache middleware itself ships without any mutation classifier. +The cache annotation is **read-only**: it declares `applicableTo: ['read']`, so the lane gate (TML-2143 M2) rejects passing it to write terminals at both type and runtime levels. "Cache a mutation" is structurally impossible without an `as any` cast bypass at both the type and runtime levels — the cache middleware itself ships without any mutation classifier. + +Single-flight miss dedupe is configurable both globally and per query: + +- Global default: `readDedupe` (defaults to `false`). +- Per-query override: `cacheAnnotation({ dedupe: true | false })`. +- Precedence: annotation `dedupe` overrides the global `readDedupe` value. ```typescript // ✓ ORM read terminal accepts the read-only annotation via the meta callback. @@ -92,20 +145,159 @@ const plan = db.sql Two-tier resolution: -1. **Per-query override.** `cacheAnnotation({ key })` — the supplied string is used verbatim. The cache middleware does **not** rehash user-supplied keys; the caller is responsible for keeping the string bounded in size and free of sensitive data they do not want flowing into debug logs, Redis `KEYS` output, persistence dumps, or any user-supplied `CacheStore`. User-supplied keys also bypass the storage-hash discrimination below — if you fix a key, prefix it with something tied to your schema version (e.g. `` `${storageHash}:my-key` ``) to avoid serving stale-schema entries after a migration. -2. **Default.** `RuntimeMiddlewareContext.contentHash(exec)` — the family runtime owns this. The SQL and Mongo runtimes today compose `meta.storageHash + '|' + …` and pipe the result through `hashContent` (SHA-512), producing a bounded, opaque digest of the form `sha512:HEXDIGEST`. The cache middleware uses the returned string directly as the `Map` key. +1. **Per-query override.** `cacheAnnotation({ key })`. +2. **Default.** `RuntimeMiddlewareContext.contentHash(exec)`. + +With `cacheStrategy.mode = 'versioned'`, the middleware appends per-model generation tokens (`|g:model@version,...`) to the base key. Mutations invalidate by bumping generations, which avoids broad key scans for model-level invalidation. + +## Mutation invalidation and strategy controls + +Precedence for mutation invalidation: + +1. `uncacheAnnotation` on the mutation +2. global middleware config `uncacheOnMutation` + +### Uncache workflow + +When a write executes, the middleware follows this order: + +1. Inspect the execution plan and skip invalidation entirely when the write is not eligible. + - `uncacheAnnotation({ skip: true })` or `enabled: false` stops here. + - When neither the annotation nor `uncacheOnMutation` enables invalidation, the write returns without touching the cache. +2. Read the invalidation payload. + - If `uncacheAnnotation({ uncache: [...] })` is present, those actions are used exactly as provided. + - If no explicit `uncache` list exists, the middleware synthesizes a default action from the global policy, usually scoped to the current namespace. +3. Derive the affected models and exact entity selectors from the execution AST when the strategy supports it. + - Simple CRUD writes can resolve a model name directly from the AST. + - Exact entity invalidation uses the primary-key columns when the contract exposes them; otherwise it falls back to the current `id` heuristic. + - Composite primary keys are supported when all PK columns are constrained by equality conditions. +4. Choose the invalidation strategy. + - `targeted` tries entity-targeted deletes first and falls back to broader model invalidation when the write is not an exact PK match. + - `broad` deletes all cached keys for the affected model. + - `versioned` bumps the model generation token so old cache entries become unreachable. + - Automatic fallback: when `targeted`/`broad` is configured but the store does not provide `del`/`list`, metadata-driven model/entity invalidation automatically switches to generation bumping. +5. Check store capabilities before issuing deletes. + - If the chosen path needs key deletion, the store must implement `del`. + - If the invalidation path needs namespace or model scans, the store must implement `list` as well. + - In `versioned` mode, the middleware can avoid broad scans for normal model invalidation, but `incr` improves cross-instance generation bumps. + - With automatic fallback enabled, metadata-driven uncache paths (detected model/entity invalidation) no longer require `del`/`list`; explicit key deletes still require `del`. +6. Apply the invalidation. + - Explicit `keys` are deleted after optional namespace prefixing. + - Model invalidation deletes the indexed keys for that model. + - Entity invalidation deletes only the exact entity cache entry when the PK selector is complete. +7. Emit telemetry and finish. + - Generation mode emits bump and cleanup events. + - Detached store operation mode may schedule the store work in the background, but the invalidation decision itself is still made before the response leaves the middleware. + +Effective rules: + +- `uncacheAnnotation({ skip: true })` or `enabled: false` disables invalidation for that mutation. +- `uncacheAnnotation({ enabled: true })` forces invalidation even when global invalidation is disabled. +- `uncache` action list takes precedence when provided and runs each action in order. +- Affected models are derived automatically from execution plan AST where possible. + +Strategy comparison: + +| Mode | Invalidation behavior | Read hit behavior after mutation | Store requirements | Trade-off | +|---|---|---|---|---| +| `targeted` | entity-targeted for simple id CRUD, else model-level | stale keys are actively deleted where targeted | `del` and often `list` required | best precision, more index bookkeeping | +| `broad` | broad model-level key deletion | broad invalidation per model | `del` and often `list` required | simple and safe, lower hit-rate on hot models | +| `versioned` | bump model version; no broad model key scan | old keys become unreachable through generation suffix | model invalidation works without `del/list` | robust under fanout; old keys live until TTL or eviction | + +When `targeted` or `broad` is selected but `del`/`list` is missing, the middleware automatically falls back to generation invalidation for metadata-driven paths. This keeps uncache behavior available in constrained stores while preserving explicit-key safety checks. + +Generation options: + +Exact entity invalidation is driven by primary-key columns when the middleware can resolve them from the runtime contract. When that metadata is not available, the middleware falls back to the current `id`-based heuristic. + +This works for both single-column and composite primary keys. When a write operation targets an entity via an equality filter on all PK columns, only the cached rows for that exact entity are invalidated — other entities in the same table remain cached. + +```typescript +// Single-column PK (e.g. users.id) +await db.orm.User.delete({ id: 42 }); +// → invalidates only the cache entry for user 42 + +// Composite PK (e.g. kv: { ns, key }) +await db.sql + .from(tables.kv) + .delete() + .where( + eq(tables.kv.columns.ns, 'tenant-a'), + eq(tables.kv.columns.key, 'feature-x'), + ) + .build(); +// → invalidates only the cache entry for { ns: 'tenant-a', key: 'feature-x' } +// → leaves all other kv entries in the cache untouched +``` + +Queries that do not match exactly on all PK columns (list queries, range filters, partial matches) fall through to broad or versioned invalidation depending on `cacheStrategy.mode`. -Two consequences worth pinning (both properties of the **default** key path — user-supplied keys above opt out of both): +- `cacheStrategy.generation.bumpOn`: + - `uncache` (default): bump only on uncache path. + - `all-writes`: bump on every successful write. +- `cacheStrategy.generation.scope`: + - `detected-models` (default): use models from write AST. + - `action-models-preferred`: prefer annotation action models when present. +- `cacheStrategy.generation.guard`: + - `enabled`: best-effort stale-key cleanup (requires `store.del`). + - `maxDeletesPerBump`: cleanup cap per bump. -- **Storage-hash discrimination.** A schema migration changes `meta.storageHash`, which changes `contentHash`, which invalidates cached entries automatically. Stale-schema reads cannot leak across migrations. -- **AST rewrites are part of the key.** Middleware that rewrite the plan via `beforeCompile` (e.g. soft-delete) run **upstream** of the cache. The cache sees the post-lowering plan, so the rewritten SQL is part of the content hash. Adding or removing a `beforeCompile` middleware changes which entries hit. +The implementation is table-driven internally, so future strategies can be added by extending the strategy definition map instead of spreading new branches through the middleware. + +For a shared backend across multiple Node.js services, the important store contract is: + +- `broad` / `targeted` need `list` and `del` so the shared reverse indexes can be discovered and cleared across instances. +- `versioned` additionally benefits from `incr` so generation bumps are shared robustly instead of relying on get & set by default. + +Telemetry emitted in generation mode: + +- `middleware.cache.generation.bump` with `models` and `deletedKeys` +- `middleware.cache.generation.guard.cleanup` with `models`, `deletedKeys`, and `maxDeletesPerBump` + +## Detached store operations + +`storeOperationMode` controls whether cache store writes/deletes are part of the request critical path: + +- `await` (default): middleware waits for cache store `set`/`del` work to finish. +- `detached`: middleware schedules store work in the background and does not wait. + +```typescript +const middleware = createCacheMiddleware({ + store, + storeOperationMode: 'detached', +}); +``` + +Pros (`detached`): + +- Lower response-time impact from slow cache backends. +- Mutations and cache commits are less likely to inherit cache backend latency spikes. + +Cons (`detached`): + +- Eventual consistency window: invalidation/commit may complete shortly after the response. +- Best-effort error handling: detached store failures are logged (`ctx.log.warn`) but do not fail the request. +- Operational visibility becomes more important; monitor warning logs for detached task failures. + +## Hit and dedupe signals + +The runtime already exposes whether an execution was served by middleware or by the driver through `AfterExecuteResult.source`: + +- `source: 'middleware'` covers both cache hits and deduped followers. +- `source: 'driver'` means the query executed normally and the rows came from the driver. + +If you need to distinguish cache hits from dedupe followers, use the middleware logs: + +- `middleware.cache.hit` marks a direct cache hit. +- `middleware.cache.dedupe.wait` marks a follower waiting for an in-flight miss. +- `middleware.cache.dedupe.hit` marks a follower that reused the leader's result. ## `CacheStore` pluggability The default in-memory store is per-process and **not** coherent across replicas. For shared caching, supply a custom `CacheStore`: ```typescript -import type { CacheStore, CachedEntry } from '@internal/middleware-cache'; +import type { CacheStore, CachedEntry } from '@prisma-next/middleware-cache'; const redis: CacheStore = { async get(key) { @@ -115,21 +307,52 @@ const redis: CacheStore = { async set(key, entry, ttlMs) { await redisClient.set(key, JSON.stringify(entry), 'PX', ttlMs); }, + async list(prefix) { + const match = prefix ? `${prefix}*` : '*'; + const keys: string[] = []; + for await (const key of redisClient.scanIterator({ MATCH: match, COUNT: 500 })) { + keys.push(key as string); + } + return keys; + }, + async del(key) { + await redisClient.del(key); + }, + async incr(key) { + return redisClient.incr(key); + }, }; const middleware = createCacheMiddleware({ store: redis }); ``` -The interface is intentionally minimal — `get` returns the entry if present and not expired (implementations gating on TTL should treat expired as absent), `set` writes the entry under the key with the per-call `ttlMs`. Both are async to leave room for I/O-backed stores; the default in-memory store completes synchronously and wraps results in `Promise.resolve` for type conformance. +`CacheStore` interface: + +```typescript +export interface CacheStore { + get(key: string): Promise; + set(key: string, entry: CachedEntry, ttlMs: number): Promise; + list?(prefix?: string): Promise; + del?(key: string): Promise; + delByTag?(tags: readonly string[]): Promise; + incr?(key: string, delta?: number): Promise; +} +``` + +For key-deletion-based invalidation paths (`uncacheOnMutation` with model/key deletes, `uncacheAnnotation` actions, or `middleware.uncache`), your store should implement `del` and, for full namespace scans, `list`. + +For generation mode, implement `incr` so model generation bumps can be shared robustly across processes instead of falling back to a process-local counter. + +In generation mode, model invalidation can work without `list`/`del` because invalidation is performed by generation bumps; `incr` improves cross-instance coherence. ## Transaction-scope guard -The middleware bypasses the cache entirely when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. Only top-level `runtime.query` (`scope === 'runtime'`) consults the store. +The middleware bypasses the cache entirely when `RuntimeMiddlewareContext.scope` is `'connection'` or `'transaction'`. Only top-level `runtime.execute` (`scope === 'runtime'`) consults the store. This avoids two surprises: - Inside a transaction, the caller expects read-after-write coherence with their own writes — the cache cannot meaningfully serve those reads without tracking the transaction's pending writes, which is out of scope for this milestone. -- On a checked-out connection (`runtime.connection().query(...)`), the caller has explicitly stepped outside the shared runtime surface and likely does not expect the global cache to inject results. +- On a checked-out connection (`runtime.connection().execute(...)`), the caller has explicitly stepped outside the shared runtime surface and likely does not expect the global cache to inject results. ## TTL and LRU semantics @@ -137,16 +360,17 @@ The default `createInMemoryCacheStore({ maxEntries, clock? })`: - **TTL.** Each entry is committed with the per-query `ttl` (in milliseconds). The store evaluates expiry against its injected clock (defaults to `Date.now`); reads of expired entries return `undefined` and drop the entry as a side effect. - **LRU.** Iteration order is the LRU order. Reads and writes both bump recency. When the live count would exceed `maxEntries`, the oldest entry is evicted. -- **Failure handling.** The middleware commits to the store only when `afterQuery` reports `completed: true && source: 'driver'`. Driver errors mid-stream and middleware-served queries never populate the cache. +- **Failure handling.** The middleware commits to the store only when `afterExecute` reports `completed: true && source: 'driver'`. Driver errors mid-stream and middleware-served executions never populate the cache. ## Caveats - **Default store is not coherent across replicas.** Multiple processes / pods do not share state. Use a custom `CacheStore` (Redis, etc.) for cross-process coherence. -- **Concurrent misses both populate the store.** Two concurrent first-time reads of the same key both run the driver and both commit; last writer wins. Single-flight / coalescing semantics are deferred to a follow-up. +- **Concurrent misses both populate by default.** Two concurrent first-time reads of the same key both run the driver and both commit; last writer wins. Enable single-flight/coalescing with `readDedupe: true` globally or `cacheAnnotation({ dedupe: true })` per query. - **Reads of stale-on-arrival entries.** With a custom replicated store, a follower may serve a stale entry for a brief window after the writer commits. Use the storage-hash discrimination plus a sensible TTL. -- **No invalidation beyond TTL.** Entries are not invalidated by writes; tag-based or event-based invalidation is out of scope for this milestone. If a write invalidates a cached read, choose a TTL short enough to bound the staleness window, or pass `cacheAnnotation({ skip: true })` on the read that needs to be authoritative. +- **No invalidation beyond TTL.** Entries are not invalidated by writes by default; mutation invalidation requires `uncacheOnMutation`, `uncacheAnnotation`, or explicit `middleware.uncache(...)`. If a write invalidates a cached read, choose a TTL short enough to bound the staleness window, or pass `cacheAnnotation({ skip: true })` on reads that must be authoritative. +- **Versioned mode keeps old keys until TTL/eviction.** In `cacheStrategy.mode = 'versioned'`, old-generation keys become unreachable after bump but may remain physically present until TTL or eviction; guard cleanup is best-effort and optional. ## See also -- [Runtime & Middleware Framework](../../../docs/architecture%20docs/subsystems/4.%20Runtime%20&%20Middleware%20Framework.md) for the SPI and middleware lifecycle (including the `interceptQuery` hook the cache uses). +- [Runtime & Middleware Framework](../../../docs/architecture%20docs/subsystems/4.%20Runtime%20&%20Middleware%20Framework.md) for the SPI and middleware lifecycle (including the `intercept` hook the cache uses). - [ADR 204 — Single-tier runtime](../../../docs/architecture%20docs/adrs/ADR%20204%20-%20Single-tier%20runtime.md) for why the cache middleware is family-agnostic by construction. diff --git a/packages/3-extensions/middleware-cache/package.json b/packages/3-extensions/middleware-cache/package.json index 8296bb68d670..c190b113ed3e 100644 --- a/packages/3-extensions/middleware-cache/package.json +++ b/packages/3-extensions/middleware-cache/package.json @@ -1,7 +1,6 @@ { - "name": "@internal/middleware-cache", - "private": true, - "version": "8.0.0-rc.7", + "name": "@prisma-next/middleware-cache", + "version": "0.14.0", "license": "Apache-2.0", "type": "module", "sideEffects": false, @@ -9,6 +8,7 @@ "scripts": { "build": "tsdown", "test": "vitest run", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --project tsconfig.json --noEmit", "lint": "biome check . --error-on-warnings", "lint:fix": "biome check --write .", @@ -16,12 +16,13 @@ "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output" }, "dependencies": { - "@internal/framework-components": "workspace:8.0.0-rc.7" + "@prisma-next/framework-components": "workspace:0.14.0", + "@prisma-next/utils": "workspace:0.14.0" }, "devDependencies": { - "@internal/contract": "workspace:8.0.0-rc.7", - "@repo/tsconfig": "workspace:8.0.0-rc.7", - "@repo/tsdown": "workspace:8.0.0-rc.7", + "@prisma-next/contract": "workspace:0.14.0", + "@prisma-next/tsconfig": "workspace:0.14.0", + "@prisma-next/tsdown": "workspace:0.14.0", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" @@ -48,7 +49,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/prisma/prisma.git", + "url": "https://github.com/prisma/prisma-next.git", "directory": "packages/3-extensions/middleware-cache" } } diff --git a/packages/3-extensions/middleware-cache/src/cache-annotation.ts b/packages/3-extensions/middleware-cache/src/cache-annotation.ts index 7fda34c6e13b..865240866d4e 100644 --- a/packages/3-extensions/middleware-cache/src/cache-annotation.ts +++ b/packages/3-extensions/middleware-cache/src/cache-annotation.ts @@ -17,11 +17,24 @@ import { defineAnnotation } from '@internal/framework-components/runtime'; * **not** rehash it, so the caller is responsible for ensuring the * string is bounded in size and free of sensitive data they do not * want flowing into logs / Redis `KEYS` / persistence dumps. + * - `dedupe` — Per-query toggle for in-process miss deduplication + * (single-flight). When `true`, concurrent identical misses for the + * same effective key in the same process share one leader execution; + * followers wait for the leader's result. When `false`, each miss + * executes independently. + * - `tags` — Optional list of cache tags to associate with this entry. + * Tags enable bulk cache invalidation: uncache by tag(s) will clear + * all entries that have those tags, regardless of key pattern. */ export interface CachePayload { + readonly enabled?: boolean; readonly ttl?: number; readonly skip?: boolean; readonly key?: string; + readonly namespace?: string; + readonly dedupe?: boolean; + readonly tags?: readonly string[]; + readonly store?: string; } /** diff --git a/packages/3-extensions/middleware-cache/src/cache-middleware.ts b/packages/3-extensions/middleware-cache/src/cache-middleware.ts index 142a6f51d1f6..38707091a1a9 100644 --- a/packages/3-extensions/middleware-cache/src/cache-middleware.ts +++ b/packages/3-extensions/middleware-cache/src/cache-middleware.ts @@ -1,11 +1,90 @@ import type { - AfterQueryResult, + AfterExecuteResult, CrossFamilyMiddleware, ExecutionPlan, RuntimeMiddlewareContext, -} from '@internal/framework-components/runtime'; +} from '@prisma-next/framework-components/runtime'; +import { blindCast } from '@prisma-next/utils/casts'; import { type CachePayload, cacheAnnotation } from './cache-annotation'; -import { type CacheStore, createInMemoryCacheStore } from './cache-store'; +import { + CACHE_INTERNAL_GENERATION_PREFIX, + type CacheStore, + createInMemoryCacheStore, +} from './cache-store'; +import { type UncacheAction, uncacheAnnotation } from './uncache-annotation'; + +/** + * The value returned by `createCacheMiddleware`. + * + * Extends `CrossFamilyMiddleware` with a standalone `uncache` method + * that lets callers invalidate cache entries outside of an annotated + * mutation — useful for manual/batch invalidation flows. + */ +export type CacheMiddleware = CrossFamilyMiddleware & { + readonly uncache: (actions: readonly UncacheAction[]) => Promise; +}; + +export type CacheStrategyMode = 'broad' | 'targeted' | 'versioned'; + +export type GenerationScope = 'detected-models' | 'action-models-preferred'; +export type GenerationBumpOn = 'uncache' | 'all-writes'; + +export interface GenerationGuardConfig { + readonly enabled?: boolean; + readonly maxDeletesPerBump?: number; +} + +export interface GenerationStrategyConfig { + readonly scope?: GenerationScope; + readonly bumpOn?: GenerationBumpOn; + readonly guard?: GenerationGuardConfig; +} + +export interface CacheStrategyConfig { + readonly mode?: CacheStrategyMode; + readonly generation?: GenerationStrategyConfig; +} + +export type CacheStoreOperationMode = 'await' | 'detached'; + +/** + * A string that names a cache namespace. Supports: + * - Exact strings: `"tenant-a"` + * - Glob wildcards: `"organization:*"` — `*` matches any sequence of characters. + * - RegExp syntax: `/pattern/` — patterns wrapped in `/…/` are treated as a + * regular expression applied via `new RegExp(inner).test(namespace)`. + */ +export type NamespacePattern = string; + +/** + * Per-namespace settings that override the global `CacheMiddlewareOptions` + * defaults for every execution whose effective namespace matches the pattern. + * + * All fields are optional. When a field is absent the global option is used + * as the fallback. + * + * - `store` — name of a registered store from `CacheMiddlewareOptions.stores`. + * When set, executions that match this namespace read from and write to the + * named store instead of the default store. + * - All other fields mirror the corresponding `CacheMiddlewareOptions` fields + * and override them for matching namespaces. + */ +export interface NamespaceConfig { + readonly store?: string; + readonly readCaching?: boolean; + readonly readDedupe?: boolean; + readonly defaultTtlMs?: number; + readonly uncacheOnMutation?: boolean; + readonly storeOperationMode?: CacheStoreOperationMode; + readonly cacheStrategy?: CacheStrategyConfig; +} + +export async function uncache( + middleware: Pick, + actions: readonly UncacheAction[], +): Promise { + await middleware.uncache(actions); +} /** * Options accepted by `createCacheMiddleware`. @@ -21,11 +100,36 @@ import { type CacheStore, createInMemoryCacheStore } from './cache-store'; * clock to make commit-time observable. Note: TTL math lives inside * the store, not the middleware — supplying a clock here only affects * the `storedAt` field on committed `CachedEntry` values. + * - `storeOperationMode` — controls whether store-side write/delete work + * is awaited on the execution path. `await` (default) preserves strict + * completion semantics. `detached` runs store work in the background to + * reduce response-time impact at the cost of eventual consistency and + * best-effort error handling. */ export interface CacheMiddlewareOptions { readonly store?: CacheStore; readonly maxEntries?: number; readonly clock?: () => number; + readonly storeOperationMode?: CacheStoreOperationMode; + readonly cacheStrategy?: CacheStrategyConfig; + readonly readCaching?: boolean; + readonly readDedupe?: boolean; + readonly defaultTtlMs?: number; + readonly namespace?: string; + readonly uncacheOnMutation?: boolean; + /** + * Named store registry. Keys are arbitrary store identifiers used in + * `NamespaceConfig.store` and `cacheAnnotation({ store })`. When a name + * cannot be resolved the middleware falls back to the default store. + */ + readonly stores?: Record; + /** + * Per-namespace configuration. Keys are `NamespacePattern` strings (exact, + * glob `*`, or `/regex/`). The most specific matching pattern (longest key + * first, exact match wins over patterns) is merged over the global options + * for every execution whose effective namespace matches. + */ + readonly namespaces?: Record; } /** @@ -35,8 +139,8 @@ export interface CacheMiddlewareOptions { * The plan-identity invariant required by this `WeakMap` correlation is * documented in the runtime subsystem doc and pinned by a regression * test: family runtimes produce a fresh, frozen `exec` per call (SQL - * `prepareExecution` constructs `Object.freeze({...lowered, ...})` on each - * invocation; Mongo lowers fresh per call). If a future plan- + * `executeAgainstQueryable` constructs `Object.freeze({...lowered, ...})` + * on each invocation; Mongo lowers fresh per call). If a future plan- * memoization change ever recycles `exec` objects across calls, this * correlation would silently leak rows between concurrent executions * — which is exactly what the regression test catches. @@ -44,9 +148,111 @@ export interface CacheMiddlewareOptions { interface PendingMiss { readonly key: string; readonly ttlMs: number; + readonly models: readonly string[]; + readonly entitySelectors: readonly EntitySelector[]; + readonly tags?: readonly string[] | undefined; readonly buffer: Record[]; + readonly resolvedConfig: ResolvedExecConfig; +} + +interface InflightMiss { + readonly promise: Promise[] | undefined>; + readonly resolve: (rows: readonly Record[] | undefined) => void; } +/** + * A named cache backend together with the in-process mutable state that + * belongs to it (key indexes, generation counters, in-flight dedup map). + * One `StoreHandle` is created per entry in `CacheMiddlewareOptions.stores` + * plus one for the default store. + */ +interface StoreHandle { + readonly name: string; + readonly store: CacheStore; + readonly modelKeyIndex: Map>; + readonly entityKeyIndex: Map>; + readonly modelGenerations: Map; + readonly inflightMisses: Map; +} + +/** + * All per-execution resolved settings, merging global `CacheMiddlewareOptions` + * with the winning `NamespaceConfig` entry (if any) and the annotation-level + * store override. + */ +interface ResolvedExecConfig { + readonly storeHandle: StoreHandle; + readonly strategy: CacheStrategyDefinition; + readonly useGenerationKeys: boolean; + readonly useGenerationCleanup: boolean; + readonly generationScope: GenerationScope; + readonly generationBumpOn: GenerationBumpOn; + readonly generationGuardEnabled: boolean; + readonly generationGuardMaxDeletes: number; + readonly storeOperationMode: CacheStoreOperationMode; + readonly readCaching: boolean; + readonly readDedupe: boolean; + readonly defaultTtlMs: number | undefined; + readonly uncacheOnMutation: boolean; +} + +interface EntitySelector { + readonly model: string; + readonly tableName: string; + readonly id: string; + readonly columns: readonly string[]; +} + +interface GenerationBumpResult { + readonly models: readonly string[]; + readonly deletedKeys: number; +} + +interface CacheStrategyDefinition { + readonly mode: CacheStrategyMode; + readonly useReadEntitySelectors: boolean; + readonly useWriteEntitySelectors: boolean; + readonly useGenerationKeys: boolean; + readonly useGenerationCleanup: boolean; +} + +const CACHE_STRATEGY_DEFINITIONS: Record = { + broad: { + mode: 'broad', + useReadEntitySelectors: false, + useWriteEntitySelectors: false, + useGenerationKeys: false, + useGenerationCleanup: false, + }, + targeted: { + mode: 'targeted', + useReadEntitySelectors: true, + useWriteEntitySelectors: true, + useGenerationKeys: false, + useGenerationCleanup: false, + }, + versioned: { + mode: 'versioned', + useReadEntitySelectors: false, + useWriteEntitySelectors: false, + useGenerationKeys: true, + useGenerationCleanup: true, + }, +}; + +function resolveConfiguredStrategyMode(mode: CacheStrategyMode | undefined): CacheStrategyMode { + if (mode === undefined) { + return 'targeted'; + } + return mode; +} + +function resolveConfiguredStrategyDefinition(mode: CacheStrategyMode): CacheStrategyDefinition { + return CACHE_STRATEGY_DEFINITIONS[mode]; +} + +const CACHE_INTERNAL_INDEX_PREFIX = '__prisma_next_cache:index:'; + /** * Default `maxEntries` for the built-in in-memory store. Bounded so a * runaway producer cannot exhaust process memory; users who need @@ -67,6 +273,333 @@ function readCachePayload(plan: ExecutionPlan): CachePayload | undefined { return cacheAnnotation.read(plan); } +function readUncachePayload(plan: ExecutionPlan) { + return uncacheAnnotation.read(plan); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? blindCast, 'type guard verified: value is object & non-null'>(value) + : undefined; +} + +function getAst(exec: ExecutionPlan): Record | undefined { + return asRecord( + blindCast< + Record, + 'ExecutionPlan internally carries an ast field accessed during model detection' + >(exec)['ast'], + ); +} + +function collectTablesFromSource(source: unknown, out: Set): void { + const src = asRecord(source); + if (src === undefined) { + return; + } + if (src['kind'] === 'table-source') { + const name = src['name']; + if (typeof name === 'string' && name.length > 0) { + out.add(name); + } + return; + } + if (src['kind'] === 'derived-table-source') { + collectModelsFromAst(src['query'], out); + } +} + +function collectModelsFromAst(astValue: unknown, out: Set): void { + const ast = asRecord(astValue); + if (ast === undefined) { + return; + } + + const kind = ast['kind']; + if (kind === 'insert' || kind === 'update' || kind === 'delete') { + const table = asRecord(ast['table']); + const tableName = table?.['name']; + if (typeof tableName === 'string' && tableName.length > 0) { + out.add(tableName); + } + return; + } + + if (kind === 'select') { + collectTablesFromSource(ast['from'], out); + const joins = ast['joins']; + if (Array.isArray(joins)) { + for (const joinValue of joins) { + const join = asRecord(joinValue); + if (join !== undefined) { + collectTablesFromSource(join['source'], out); + } + } + } + return; + } +} + +function detectModels(exec: ExecutionPlan): readonly string[] { + const out = new Set(); + collectModelsFromAst(getAst(exec), out); + return [...out]; +} + +function readTableName(ast: Record): string | undefined { + const table = asRecord(ast['table']); + const name = table?.['name']; + return typeof name === 'string' && name.length > 0 ? name : undefined; +} + +function asEntityValue(value: unknown): string | undefined { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'boolean' + ) { + return String(value); + } + if (value === null) { + return 'null'; + } + return undefined; +} + +function collectEqualityConditions(whereValue: unknown, out: Map): void { + const where = asRecord(whereValue); + if (where === undefined) { + return; + } + + if (where['kind'] === 'binary' && where['op'] === 'eq') { + const left = asRecord(where['left']); + const right = asRecord(where['right']); + if (left?.['kind'] === 'column-ref' && right?.['kind'] === 'literal') { + const column = left['column']; + const value = asEntityValue(right['value']); + if (typeof column === 'string' && column.length > 0 && value !== undefined) { + out.set(column, value); + } + return; + } + if (right?.['kind'] === 'column-ref' && left?.['kind'] === 'literal') { + const column = right['column']; + const value = asEntityValue(left['value']); + if (typeof column === 'string' && column.length > 0 && value !== undefined) { + out.set(column, value); + } + return; + } + return; + } + + if (where['kind'] === 'and') { + const exprs = where['exprs']; + if (!Array.isArray(exprs)) { + return; + } + for (const expr of exprs) { + collectEqualityConditions(expr, out); + } + } +} + +function resolveContractRecord(contract: unknown): Record | undefined { + return asRecord(contract); +} + +function resolveStorageTableRecord( + contract: unknown, + tableName: string, +): Record | undefined { + const contractRecord = resolveContractRecord(contract); + const storage = asRecord(contractRecord?.['storage']); + if (storage === undefined) { + return undefined; + } + + const directTables = asRecord(storage['tables']); + if (directTables !== undefined) { + const table = asRecord(directTables[tableName]); + if (table !== undefined) { + return table; + } + } + + const namespaces = asRecord(storage['namespaces']); + if (namespaces === undefined) { + return undefined; + } + + for (const namespace of Object.values(namespaces)) { + const namespaceRecord = asRecord(namespace); + const tables = asRecord(namespaceRecord?.['tables']); + if (tables === undefined) { + continue; + } + const table = asRecord(tables[tableName]); + if (table !== undefined) { + return table; + } + } + + return undefined; +} + +function resolveModelNameForTable(contract: unknown, tableName: string): string | undefined { + const contractRecord = resolveContractRecord(contract); + const domain = asRecord(contractRecord?.['domain']); + const namespaces = asRecord(domain?.['namespaces']); + if (namespaces === undefined) { + return undefined; + } + + for (const namespace of Object.values(namespaces)) { + const namespaceRecord = asRecord(namespace); + const models = asRecord(namespaceRecord?.['models']); + if (models === undefined) { + continue; + } + for (const [modelName, modelValue] of Object.entries(models)) { + const model = asRecord(modelValue); + const storage = asRecord(model?.['storage']); + if (storage?.['table'] === tableName) { + return modelName; + } + } + } + + return undefined; +} + +function resolvePrimaryKeyColumnsForTable(contract: unknown, tableName: string): readonly string[] { + const table = resolveStorageTableRecord(contract, tableName); + const primaryKey = asRecord(table?.['primaryKey']); + const columns = primaryKey?.['columns']; + if (!Array.isArray(columns)) { + return []; + } + return columns.filter((value): value is string => typeof value === 'string' && value.length > 0); +} + +function readEntitySelectorFromWhere( + contract: unknown, + tableName: string, + whereValue: unknown, +): EntitySelector | undefined { + const primaryKeyColumns = resolvePrimaryKeyColumnsForTable(contract, tableName); + const selectorColumns = primaryKeyColumns.length > 0 ? primaryKeyColumns : ['id']; + const conditions = new Map(); + collectEqualityConditions(whereValue, conditions); + + for (const column of selectorColumns) { + if (!conditions.has(column)) { + return undefined; + } + } + + const model = resolveModelNameForTable(contract, tableName) ?? tableName; + const key = selectorColumns + .map((column) => `${column}=${JSON.stringify(conditions.get(column))}`) + .join('|'); + + return { + model, + tableName, + id: key, + columns: selectorColumns, + }; +} + +function detectReadEntitySelectors( + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, + models: readonly string[], +): readonly EntitySelector[] { + const ast = getAst(exec); + if (ast?.['kind'] !== 'select' || models.length !== 1) { + return []; + } + const from = asRecord(ast['from']); + if (from?.['kind'] !== 'table-source') { + return []; + } + const tableName = from['name']; + if (tableName === undefined) { + return []; + } + if (typeof tableName !== 'string' || tableName.length === 0) { + return []; + } + const selector = readEntitySelectorFromWhere(ctx.contract, tableName, ast['where']); + if (selector === undefined) { + return []; + } + return [selector]; +} + +function detectWriteEntitySelectors( + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, +): readonly EntitySelector[] { + const ast = getAst(exec); + if (ast === undefined) { + return []; + } + + const kind = ast['kind']; + if (kind !== 'update' && kind !== 'delete') { + return []; + } + + const model = readTableName(ast); + if (model === undefined) { + return []; + } + + const selector = readEntitySelectorFromWhere(ctx.contract, model, ast['where']); + if (selector === undefined) { + return []; + } + + return [selector]; +} + +function isReadExecution(exec: ExecutionPlan): boolean { + const ast = getAst(exec); + if (ast?.['kind'] === 'select') { + return true; + } + return readCachePayload(exec) !== undefined; +} + +function isWriteExecution(exec: ExecutionPlan): boolean { + const ast = getAst(exec); + const kind = ast?.['kind']; + if (kind === 'insert' || kind === 'update' || kind === 'delete') { + return true; + } + return readUncachePayload(exec) !== undefined; +} + +function applyNamespace(key: string, namespace: string | undefined): string { + return namespace === undefined ? key : `${namespace}:${key}`; +} + +function uniqSorted(values: readonly string[]): readonly string[] { + return [...new Set(values)].sort(); +} + +function createInflightMiss(): InflightMiss { + let resolve!: (rows: readonly Record[] | undefined) => void; + const promise = new Promise[] | undefined>((res) => { + resolve = res; + }); + return { promise, resolve }; +} + /** * Computes the cache key for an execution. * @@ -94,42 +627,132 @@ async function resolveCacheKey( return ctx.contentHash(exec); } +function resolveUncacheActions( + exec: ExecutionPlan, + uncacheOnMutation: boolean, +): readonly UncacheAction[] | undefined { + const payload = readUncachePayload(exec); + if (payload?.skip === true || payload?.enabled === false) { + return undefined; + } + if (payload?.uncache !== undefined) { + return payload.uncache; + } + if (payload?.enabled === true || uncacheOnMutation) { + return [payload?.namespace !== undefined ? { namespace: payload.namespace } : {}]; + } + return undefined; +} + +function matchesNamespacePattern(namespace: string, pattern: string): boolean { + if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) { + try { + return new RegExp(pattern.slice(1, -1)).test(namespace); + } catch { + return false; + } + } + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`).test(namespace); +} + +function lookupNamespaceConfig( + namespace: string | undefined, + patterns: Record | undefined, +): NamespaceConfig | undefined { + if (namespace === undefined || patterns === undefined) return undefined; + if (patterns[namespace] !== undefined) return patterns[namespace]; + const sorted = Object.keys(patterns) + .filter((p) => p !== namespace) + .sort((a, b) => b.length - a.length); + for (const pattern of sorted) { + if (matchesNamespacePattern(namespace, pattern)) return patterns[pattern]; + } + return undefined; +} + +function makeStoreHandle(name: string, store: CacheStore): StoreHandle { + return { + name, + store, + modelKeyIndex: new Map(), + entityKeyIndex: new Map(), + modelGenerations: new Map(), + inflightMisses: new Map(), + }; +} + +function generationKey(model: string): string { + return `${CACHE_INTERNAL_GENERATION_PREFIX}${model}`; +} + +function sharedModelIndexPrefix(model: string): string { + return `${CACHE_INTERNAL_INDEX_PREFIX}model:${model}:`; +} + +function sharedEntityIndexPrefix(selector: EntitySelector): string { + return `${CACHE_INTERNAL_INDEX_PREFIX}entity:${selector.model}:${selector.id}:`; +} + +function parseIndexedCacheKey(indexKey: string, prefix: string): string | undefined { + if (!indexKey.startsWith(prefix)) { + return undefined; + } + const cacheKey = indexKey.slice(prefix.length); + return cacheKey.length > 0 ? cacheKey : undefined; +} + +function isKeyInNamespace(key: string, namespace: string): boolean { + if (key.startsWith(`${namespace}:`)) return true; + // Index keys embed the cache key as a suffix after the model/entity prefix. + // Check whether that embedded cache key belongs to the namespace. + if (key.startsWith(CACHE_INTERNAL_INDEX_PREFIX)) { + const afterPrefix = key.slice(CACHE_INTERNAL_INDEX_PREFIX.length); + return afterPrefix.includes(`:${namespace}:`); + } + return false; +} + /** * Creates a family-agnostic caching middleware. * * The middleware uses three hooks: * - * - `interceptQuery` — on each execution, checks the cache. On a hit, returns - * the cached raw rows; the runtime skips `runDriver` and `onRow` - * (`beforeQuery` is not affected — it has already run for every - * middleware before any `interceptQuery` is consulted) and yields the - * cached rows to the consumer (which, in the SQL runtime, sees them - * after the standard `decodeRow` pass — i.e. the cache stores - * wire-format values). On a miss, records a pending buffer keyed on - * the `exec` object identity and returns `undefined` (passthrough). + * - `intercept` — on each execution, checks the cache. On a hit, returns + * the cached raw rows; the runtime skips `beforeExecute`, `runDriver`, + * and `onRow`, and yields the cached rows to the consumer (which, in + * the SQL runtime, sees them after the standard `decodeRow` pass — + * i.e. the cache stores wire-format values). On a miss, records a + * pending buffer keyed on the `exec` object identity and returns + * `undefined` (passthrough). * - `onRow` — on the miss path, appends each row yielded by the driver * to the pending buffer. - * - `afterQuery` — on the miss path, commits the buffer to the store + * - `afterExecute` — on the miss path, commits the buffer to the store * if and only if `result.completed === true && result.source === 'driver'`. * Failed executions and middleware-served executions never populate * the cache. The pending buffer is cleared in all branches so a stale * `WeakMap` entry cannot leak between executions sharing an `exec`. * - * The middleware bypasses the cache entirely when: - * - the plan has no `cache` annotation, or - * - the annotation has `skip: true`, or - * - the annotation has no `ttl`, or + * Read caching can be enabled either by `cacheAnnotation` on the plan + * or globally via middleware options. `cacheAnnotation` payload fields + * override global defaults per query. Mutation-triggered invalidation + * can be enabled globally (`uncacheOnMutation`) or per mutation via + * `uncacheAnnotation`. + * + * The middleware bypasses cache lookup when: + * - effective caching is disabled, + * - no effective TTL can be resolved, or * - `ctx.scope !== 'runtime'` (connection / transaction scopes opt out). * * Returns a cross-family `RuntimeMiddleware` (no `familyId` / * `targetId`). The package depends only on - * `@internal/framework-components/runtime`; cache keys come from + * `@prisma-next/framework-components/runtime`; cache keys come from * `ctx.contentHash(exec)`, populated by the family runtime, so SQL and * Mongo runtimes both work out of the box. * * @example * ```typescript - * import { createCacheMiddleware, cacheAnnotation } from '@internal/middleware-cache'; + * import { createCacheMiddleware, cacheAnnotation } from '@prisma-next/middleware-cache'; * * const db = postgres({ * contractJson, @@ -143,54 +766,467 @@ async function resolveCacheKey( * ); * ``` */ -export function createCacheMiddleware(options?: CacheMiddlewareOptions): CrossFamilyMiddleware { - const store = +export function createCacheMiddleware(options?: CacheMiddlewareOptions): CacheMiddleware { + const defaultHandle = makeStoreHandle( + '__default__', options?.store ?? - createInMemoryCacheStore({ - maxEntries: options?.maxEntries ?? DEFAULT_MAX_ENTRIES, - }); + createInMemoryCacheStore({ maxEntries: options?.maxEntries ?? DEFAULT_MAX_ENTRIES }), + ); + const namedHandles = new Map(); + if (options?.stores !== undefined) { + for (const [name, s] of Object.entries(options.stores)) { + namedHandles.set(name, makeStoreHandle(name, s)); + } + } const clock = options?.clock ?? Date.now; - // Per-execution scratch space, keyed on the post-lowering `exec` - // object identity. WeakMap keeps cleanup automatic: if an execution is - // dropped without `afterQuery` firing (e.g. an early throw before - // the middleware lifecycle starts), the entry is GC'd alongside the exec - // object. + function resolveStoreHandle(storeName: string | undefined): StoreHandle { + if (storeName === undefined) return defaultHandle; + return namedHandles.get(storeName) ?? defaultHandle; + } + + function buildExecConfig( + namespace: string | undefined, + annotationStoreName: string | undefined, + ): ResolvedExecConfig { + const nsConfig = lookupNamespaceConfig(namespace, options?.namespaces); + const effectiveStoreName = annotationStoreName ?? nsConfig?.store; + const storeHandle = resolveStoreHandle(effectiveStoreName); + const strategyMode = resolveConfiguredStrategyMode( + nsConfig?.cacheStrategy?.mode ?? options?.cacheStrategy?.mode, + ); + const strategy = resolveConfiguredStrategyDefinition(strategyMode); + const hasDeleteAndList = + storeHandle.store.del !== undefined && storeHandle.store.list !== undefined; + const useAutomaticGenerationFallback = strategy.mode !== 'versioned' && !hasDeleteAndList; + return { + storeHandle, + strategy, + useGenerationKeys: strategy.useGenerationKeys || useAutomaticGenerationFallback, + useGenerationCleanup: strategy.useGenerationCleanup || useAutomaticGenerationFallback, + generationScope: + nsConfig?.cacheStrategy?.generation?.scope ?? + options?.cacheStrategy?.generation?.scope ?? + 'detected-models', + generationBumpOn: + nsConfig?.cacheStrategy?.generation?.bumpOn ?? + options?.cacheStrategy?.generation?.bumpOn ?? + 'uncache', + generationGuardEnabled: + (nsConfig?.cacheStrategy?.generation?.guard?.enabled ?? + options?.cacheStrategy?.generation?.guard?.enabled) === true, + generationGuardMaxDeletes: + nsConfig?.cacheStrategy?.generation?.guard?.maxDeletesPerBump ?? + options?.cacheStrategy?.generation?.guard?.maxDeletesPerBump ?? + 500, + storeOperationMode: nsConfig?.storeOperationMode ?? options?.storeOperationMode ?? 'await', + readCaching: nsConfig?.readCaching ?? options?.readCaching ?? false, + readDedupe: nsConfig?.readDedupe ?? options?.readDedupe ?? false, + defaultTtlMs: nsConfig?.defaultTtlMs ?? options?.defaultTtlMs, + uncacheOnMutation: nsConfig?.uncacheOnMutation ?? options?.uncacheOnMutation ?? false, + }; + } + const pending = new WeakMap(); - async function interceptQuery( - exec: ExecutionPlan, - ctx: RuntimeMiddlewareContext, - ): Promise<{ readonly rows: Iterable> } | undefined> { - if (ctx.scope !== 'runtime') { - return undefined; + function entityTag(selector: EntitySelector): string { + return `${selector.model}:${selector.id}`; + } + + async function getModelGeneration(h: StoreHandle, model: string): Promise { + if (h.store.incr !== undefined) { + return h.store.incr(generationKey(model), 0); } + return h.modelGenerations.get(model) ?? 0; + } - const payload = readCachePayload(exec); - if (payload === undefined) { - return undefined; + async function bumpModelGeneration(h: StoreHandle, model: string): Promise { + if (h.store.incr !== undefined) { + return h.store.incr(generationKey(model)); } - if (payload.skip === true) { - return undefined; + const next = (h.modelGenerations.get(model) ?? 0) + 1; + h.modelGenerations.set(model, next); + return next; + } + + async function withGenerationKey( + h: StoreHandle, + useGenerationKeys: boolean, + baseKey: string, + models: readonly string[], + ): Promise { + if (!useGenerationKeys || models.length === 0) return baseKey; + const token = await Promise.all( + uniqSorted(models).map(async (model) => `${model}@${await getModelGeneration(h, model)}`), + ); + return `${baseKey}|g:${token.join(',')}`; + } + + async function bumpGenerationForModels( + h: StoreHandle, + models: readonly string[], + ): Promise { + const versions: number[] = []; + for (const model of uniqSorted(models)) { + versions.push(await bumpModelGeneration(h, model)); } - if (payload.ttl === undefined) { - return undefined; + return versions; + } + + async function collectIndexedCacheKeysForModel( + h: StoreHandle, + model: string, + ): Promise> { + const keysToDelete = new Set(h.modelKeyIndex.get(model) ?? []); + if (h.store.list === undefined) return keysToDelete; + const prefix = sharedModelIndexPrefix(model); + const indexedKeys = await h.store.list(prefix); + for (const indexKey of indexedKeys) { + keysToDelete.add(indexKey); + const cacheKey = parseIndexedCacheKey(indexKey, prefix); + if (cacheKey !== undefined) keysToDelete.add(cacheKey); + } + return keysToDelete; + } + + async function collectIndexedCacheKeysForEntity( + h: StoreHandle, + selector: EntitySelector, + ): Promise> { + const keysToDelete = new Set(h.entityKeyIndex.get(entityTag(selector)) ?? []); + if (h.store.list === undefined) return keysToDelete; + const prefix = sharedEntityIndexPrefix(selector); + const indexedKeys = await h.store.list(prefix); + for (const indexKey of indexedKeys) { + keysToDelete.add(indexKey); + const cacheKey = parseIndexedCacheKey(indexKey, prefix); + if (cacheKey !== undefined) keysToDelete.add(cacheKey); + } + return keysToDelete; + } + + function removeKeyFromIndex(h: StoreHandle, key: string): void { + for (const [model, keys] of h.modelKeyIndex) { + keys.delete(key); + if (keys.size === 0) h.modelKeyIndex.delete(model); + } + for (const [tag, keys] of h.entityKeyIndex) { + keys.delete(key); + if (keys.size === 0) h.entityKeyIndex.delete(tag); + } + } + + async function cleanupStaleGenerationKeys( + h: StoreHandle, + config: ResolvedExecConfig, + models: readonly string[], + ): Promise { + if (!config.generationGuardEnabled || !config.useGenerationCleanup) return 0; + if (h.store.del === undefined) return 0; + let remaining = config.generationGuardMaxDeletes; + if (remaining <= 0) return 0; + let deleted = 0; + for (const model of uniqSorted(models)) { + const keys = await collectIndexedCacheKeysForModel(h, model); + if (keys.size === 0) continue; + for (const key of keys) { + if (remaining <= 0) return deleted; + await h.store.del(key); + removeKeyFromIndex(h, key); + remaining--; + deleted++; + } + } + return deleted; + } + + async function bumpGenerationAndCleanup( + h: StoreHandle, + config: ResolvedExecConfig, + models: readonly string[], + ): Promise { + const uniqueModels = uniqSorted(models); + if (uniqueModels.length === 0) return undefined; + await bumpGenerationForModels(h, uniqueModels); + const deletedKeys = await cleanupStaleGenerationKeys(h, config, uniqueModels); + return { models: uniqueModels, deletedKeys }; + } + + function resolveGenerationModelsForWrite( + config: ResolvedExecConfig, + detectedModels: readonly string[], + actions: readonly UncacheAction[] | undefined, + ): readonly string[] { + if (config.generationScope === 'action-models-preferred') { + const fromActions: string[] = []; + if (actions !== undefined) { + for (const action of actions) { + if (action.models !== undefined) fromActions.push(...action.models); + } + } + if (fromActions.length > 0) return uniqSorted(fromActions); + } + return uniqSorted(detectedModels); + } + + async function indexKeyForModels( + h: StoreHandle, + key: string, + models: readonly string[], + ttlMs: number, + ): Promise { + for (const model of models) { + if (!h.modelKeyIndex.has(model)) h.modelKeyIndex.set(model, new Set()); + h.modelKeyIndex.get(model)!.add(key); + await h.store.set( + `${sharedModelIndexPrefix(model)}${key}`, + { rows: [], storedAt: clock() }, + ttlMs, + ); + } + } + + async function indexKeyForEntities( + h: StoreHandle, + key: string, + selectors: readonly EntitySelector[], + ttlMs: number, + ): Promise { + for (const selector of selectors) { + const tag = entityTag(selector); + if (!h.entityKeyIndex.has(tag)) h.entityKeyIndex.set(tag, new Set()); + h.entityKeyIndex.get(tag)!.add(key); + await h.store.set( + `${sharedEntityIndexPrefix(selector)}${key}`, + { rows: [], storedAt: clock() }, + ttlMs, + ); + } + } + + async function invalidateForEntitySelectors( + h: StoreHandle, + config: ResolvedExecConfig, + selectors: readonly EntitySelector[], + namespace: string | undefined, + allowGenerationBump = true, + ): Promise { + if (config.useGenerationCleanup && allowGenerationBump && selectors.length > 0) { + return bumpGenerationAndCleanup( + h, + config, + selectors.map((s) => s.model), + ); + } + if (h.store.del === undefined) { + throw new Error( + 'cache middleware: the configured CacheStore does not implement `del`. ' + + 'Implement `del` (and `list`) on your store to enable uncache/invalidation.', + ); + } + const keysToDelete = new Set(); + for (const selector of selectors) { + for (const key of await collectIndexedCacheKeysForEntity(h, selector)) { + keysToDelete.add(key); + } + } + for (const key of keysToDelete) { + if (namespace !== undefined && !isKeyInNamespace(key, namespace)) continue; + await h.store.del(key); + removeKeyFromIndex(h, key); + } + return undefined; + } + + async function invalidateForModels( + h: StoreHandle, + config: ResolvedExecConfig, + models: readonly string[], + namespace: string | undefined, + allowGenerationBump = true, + ): Promise { + if (config.useGenerationCleanup && allowGenerationBump && models.length > 0) { + return bumpGenerationAndCleanup(h, config, models); + } + if (h.store.del === undefined) { + throw new Error( + 'cache middleware: the configured CacheStore does not implement `del`. ' + + 'Implement `del` (and `list`) on your store to enable uncache/invalidation.', + ); + } + const keysToDelete = new Set(); + for (const model of models) { + for (const key of await collectIndexedCacheKeysForModel(h, model)) { + keysToDelete.add(key); + } + } + if (keysToDelete.size === 0 && models.length === 0) { + if (h.store.list === undefined) { + throw new Error( + 'cache middleware: the configured CacheStore does not implement `list`. ' + + 'Implement `list` (and `del`) on your store to enable uncache/invalidation.', + ); + } + const all = await h.store.list(namespace === undefined ? undefined : `${namespace}:`); + for (const key of all) keysToDelete.add(key); + } + for (const key of keysToDelete) { + if (namespace !== undefined && !isKeyInNamespace(key, namespace)) continue; + await h.store.del(key); + removeKeyFromIndex(h, key); + } + return undefined; + } + + async function invalidateForAction( + h: StoreHandle, + config: ResolvedExecConfig, + action: UncacheAction, + models: readonly string[] = [], + entitySelectors: readonly EntitySelector[] = [], + allowGenerationBump = true, + ): Promise { + let generationBump: GenerationBumpResult | undefined; + + if (action.models !== undefined && action.models.length > 0) { + generationBump = await invalidateForModels( + h, + config, + action.models, + action.namespace, + allowGenerationBump, + ); } - const key = await resolveCacheKey(payload, exec, ctx); - const hit = await store.get(key); + if (action.keys !== undefined && action.keys.length > 0) { + if (h.store.del === undefined) { + throw new Error( + 'cache middleware: the configured CacheStore does not implement `del`. ' + + 'Implement `del` (and `list`) on your store to enable uncache/invalidation.', + ); + } + for (const key of action.keys) { + const resolvedKey = applyNamespace(key, action.namespace); + await h.store.del(resolvedKey); + removeKeyFromIndex(h, resolvedKey); + } + } else if (action.tags !== undefined && action.tags.length > 0) { + if (h.store.delByTag === undefined) { + throw new Error( + 'cache middleware: the configured CacheStore does not implement `delByTag`. ' + + 'Implement `delByTag` on your store to enable tag-based cache invalidation.', + ); + } + await h.store.delByTag(action.tags); + } else if (action.models === undefined || action.models.length === 0) { + if (entitySelectors.length > 0) { + generationBump = await invalidateForEntitySelectors( + h, + config, + entitySelectors, + action.namespace, + allowGenerationBump, + ); + } else { + generationBump = await invalidateForModels( + h, + config, + models, + action.namespace, + allowGenerationBump, + ); + } + } + + return generationBump; + } + + async function runStoreTask( + task: () => Promise, + ctx: RuntimeMiddlewareContext, + event: string, + storeOperationMode: CacheStoreOperationMode, + ): Promise { + if (storeOperationMode === 'detached') { + void task().catch((error) => { + ctx.log.warn?.({ + event, + middleware: 'cache', + mode: 'detached', + error, + }); + }); + return; + } + await task(); + } + + async function intercept( + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, + ): Promise<{ readonly rows: Iterable> } | undefined> { + if (ctx.scope !== 'runtime') return undefined; + + const payload = readCachePayload(exec); + const namespace = payload?.namespace ?? options?.namespace; + const execConfig = buildExecConfig(namespace, payload?.store); + + const hasAnnotation = payload !== undefined; + if (!hasAnnotation && !execConfig.readCaching) return undefined; + if (!isReadExecution(exec)) return undefined; + if (payload?.skip === true || payload?.enabled === false) return undefined; + + const ttlMs = payload?.ttl ?? execConfig.defaultTtlMs; + if (ttlMs === undefined) return undefined; + + const dedupeEnabled = payload?.dedupe ?? execConfig.readDedupe; + const resolvedKey = await resolveCacheKey(payload ?? {}, exec, ctx); + const tags = payload?.tags; + const models = detectModels(exec); + const entitySelectors = execConfig.strategy.useReadEntitySelectors + ? detectReadEntitySelectors(exec, ctx, models) + : []; + const baseKey = applyNamespace(resolvedKey, namespace); + const h = execConfig.storeHandle; + const effectiveKey = await withGenerationKey(h, execConfig.useGenerationKeys, baseKey, models); + const hit = await h.store.get(effectiveKey); if (hit !== undefined) { - ctx.log.debug?.({ event: 'middleware.cache.hit', middleware: 'cache', key }); - // Hit path leaves no WeakMap entry — afterQuery's lookup will - // return undefined and short-circuit. + ctx.log.debug?.({ event: 'middleware.cache.hit', middleware: 'cache', key: effectiveKey }); return { rows: hit.rows }; } - // Miss: record the pending buffer so onRow / afterExecute can - // commit on success. The TTL is captured here so a later mutation - // of the annotation (defensive) cannot change the commit window. - pending.set(exec, { key, ttlMs: payload.ttl, buffer: [] }); - ctx.log.debug?.({ event: 'middleware.cache.miss', middleware: 'cache', key }); + if (dedupeEnabled) { + const inflight = h.inflightMisses.get(effectiveKey); + if (inflight !== undefined) { + ctx.log.debug?.({ + event: 'middleware.cache.dedupe.wait', + middleware: 'cache', + key: effectiveKey, + }); + const rows = await inflight.promise; + if (rows !== undefined) { + ctx.log.debug?.({ + event: 'middleware.cache.dedupe.hit', + middleware: 'cache', + key: effectiveKey, + }); + return { rows }; + } + return undefined; + } + h.inflightMisses.set(effectiveKey, createInflightMiss()); + } + + pending.set(exec, { + key: effectiveKey, + ttlMs, + models, + entitySelectors, + tags, + buffer: [], + resolvedConfig: execConfig, + }); + ctx.log.debug?.({ event: 'middleware.cache.miss', middleware: 'cache', key: effectiveKey }); return undefined; } @@ -200,37 +1236,138 @@ export function createCacheMiddleware(options?: CacheMiddlewareOptions): CrossFa _ctx: RuntimeMiddlewareContext, ): Promise { const slot = pending.get(exec); - if (slot === undefined) { - return; - } + if (slot === undefined) return; slot.buffer.push(row); } - async function afterQuery( + async function afterExecute( exec: ExecutionPlan, - result: AfterQueryResult, + result: AfterExecuteResult, ctx: RuntimeMiddlewareContext, ): Promise { + const logGenerationTelemetry = (bump: GenerationBumpResult | undefined): void => { + if (bump === undefined) return; + ctx.log.debug?.({ + event: 'middleware.cache.generation.bump', + middleware: 'cache', + models: bump.models, + deletedKeys: bump.deletedKeys, + }); + if (bump.deletedKeys > 0) { + ctx.log.debug?.({ + event: 'middleware.cache.generation.guard.cleanup', + middleware: 'cache', + models: bump.models, + deletedKeys: bump.deletedKeys, + }); + } + }; + + const successfulDriverExecution = result.completed && result.source === 'driver'; const slot = pending.get(exec); - if (slot === undefined) { - return; - } - // Always release the WeakMap entry — the exec is single-use and - // any state we leave behind is dead weight on the GC. - pending.delete(exec); - if (!result.completed || result.source !== 'driver') { - return; + if (slot !== undefined) { + pending.delete(exec); + const { resolvedConfig } = slot; + const h = resolvedConfig.storeHandle; + const inflight = h.inflightMisses.get(slot.key); + + try { + if (successfulDriverExecution) { + const commitTask = async () => { + await h.store.set( + slot.key, + { rows: slot.buffer, storedAt: clock(), tags: slot.tags }, + slot.ttlMs, + ); + await indexKeyForModels(h, slot.key, slot.models, slot.ttlMs); + if (resolvedConfig.strategy.useWriteEntitySelectors) { + await indexKeyForEntities(h, slot.key, slot.entitySelectors, slot.ttlMs); + } + }; + await runStoreTask( + commitTask, + ctx, + 'middleware.cache.store.detached.error', + resolvedConfig.storeOperationMode, + ); + inflight?.resolve(slot.buffer); + ctx.log.debug?.({ event: 'middleware.cache.store', middleware: 'cache', key: slot.key }); + } else { + inflight?.resolve(undefined); + } + } catch (error) { + inflight?.resolve(undefined); + throw error; + } finally { + h.inflightMisses.delete(slot.key); + } } - await store.set(slot.key, { rows: slot.buffer, storedAt: clock() }, slot.ttlMs); - ctx.log.debug?.({ event: 'middleware.cache.store', middleware: 'cache', key: slot.key }); + if (!successfulDriverExecution || !isWriteExecution(exec)) return; + + const uncachePayload = readUncachePayload(exec); + const writeNamespace = uncachePayload?.namespace ?? options?.namespace; + const writeExecConfig = buildExecConfig(writeNamespace, undefined); + + const actions = resolveUncacheActions(exec, writeExecConfig.uncacheOnMutation); + const detectedModels = detectModels(exec); + + const runWriteInvalidation = async () => { + let generationBumpedForWrite = false; + if ( + writeExecConfig.useGenerationCleanup && + writeExecConfig.generationBumpOn === 'all-writes' + ) { + const bump = await bumpGenerationAndCleanup( + writeExecConfig.storeHandle, + writeExecConfig, + resolveGenerationModelsForWrite(writeExecConfig, detectedModels, actions), + ); + logGenerationTelemetry(bump); + generationBumpedForWrite = true; + } + + if (actions === undefined) return; + + const detectedEntitySelectors = writeExecConfig.strategy.useWriteEntitySelectors + ? detectWriteEntitySelectors(exec, ctx) + : []; + for (const action of actions) { + const actionConfig = buildExecConfig(action.namespace ?? writeNamespace, undefined); + const bump = await invalidateForAction( + actionConfig.storeHandle, + actionConfig, + action, + detectedModels, + detectedEntitySelectors, + !generationBumpedForWrite, + ); + logGenerationTelemetry(bump); + } + ctx.log.debug?.({ event: 'middleware.cache.uncache', middleware: 'cache' }); + }; + + await runStoreTask( + runWriteInvalidation, + ctx, + 'middleware.cache.uncache.detached.error', + writeExecConfig.storeOperationMode, + ); + } + + async function uncacheImpl(actions: readonly UncacheAction[]): Promise { + for (const action of actions) { + const execConfig = buildExecConfig(action.namespace, undefined); + await invalidateForAction(execConfig.storeHandle, execConfig, action); + } } return { name: 'cache', - interceptQuery, + intercept, onRow, - afterQuery, + afterExecute, + uncache: uncacheImpl, }; } diff --git a/packages/3-extensions/middleware-cache/src/cache-store.ts b/packages/3-extensions/middleware-cache/src/cache-store.ts index c9e2c865c266..736dee367b8a 100644 --- a/packages/3-extensions/middleware-cache/src/cache-store.ts +++ b/packages/3-extensions/middleware-cache/src/cache-store.ts @@ -11,12 +11,18 @@ * telemetry) and is **not** used by the in-memory store itself for * expiry — TTL is driven by the store's own clock plus the `ttlMs` * passed to `set`. Custom stores may use it differently. + * - `tags` are optional labels attached to this cache entry. Multiple + * entries can share the same tag; bulk invalidation is possible via + * `delByTag`. */ export interface CachedEntry { readonly rows: readonly Record[]; readonly storedAt: number; + readonly tags?: readonly string[] | undefined; } +export const CACHE_INTERNAL_GENERATION_PREFIX = '__prisma_next_cache:generation:'; + /** * Pluggable cache backend used by the cache middleware. * @@ -24,7 +30,7 @@ export interface CachedEntry { * `createInMemoryCacheStore`. Users can supply Redis, Memcached, or any * other backend by implementing this interface. * - * The interface is intentionally minimal: + * The required interface is intentionally small: * * - `get` returns the entry if it exists and has not expired, or * `undefined` otherwise. Implementations that gate on TTL should @@ -36,7 +42,22 @@ export interface CachedEntry { * forget at scale; the cache middleware does not rely on `set` * completing before subsequent `get`s. * - * Both methods are async to leave the door open for I/O-backed stores + * - `list` (optional) returns all live keys or only those matching a + * prefix. Implementations should avoid returning expired entries. + * Required when using `uncacheOnMutation` or `uncacheAnnotation`. + * - `del` (optional) removes a key from the store. + * Required when using `uncacheOnMutation` or `uncacheAnnotation`. + * - `delByTag` (optional) removes all entries that have any of the given + * tags. Useful for bulk cache invalidation. Implementations may delete + * synchronously or asynchronously; the middleware awaits the result + * but does not retry on failure. + * - `incr` (optional) increments a numeric counter stored under a key. + * Used by generation-based invalidation when the cache must stay + * coherent across multiple instances sharing the same backend. Store + * implementations that support generation mode in clustered setups + * should implement this method. + * + * All methods are async to leave the door open for I/O-backed stores * (Redis, S3, etc.). The default in-memory store completes * synchronously and wraps the result in `Promise.resolve` for type * conformance. @@ -44,6 +65,10 @@ export interface CachedEntry { export interface CacheStore { get(key: string): Promise; set(key: string, entry: CachedEntry, ttlMs: number): Promise; + list?(prefix?: string): Promise; + del?(key: string): Promise; + delByTag?(tags: readonly string[]): Promise; + incr?(key: string, delta?: number): Promise; } /** @@ -66,6 +91,10 @@ interface StoredRecord { readonly expiresAt: number; } +interface StoredCounter { + readonly value: number; +} + /** * Default cache backend. An LRU with per-entry TTL, backed by a `Map`. * @@ -93,8 +122,27 @@ export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): Ca const maxEntries = options.maxEntries; const clock = options.clock ?? Date.now; const map = new Map(); + const counters = new Map(); + const tagIndex = new Map>(); + + function isGenerationKey(key: string): boolean { + return key.startsWith(CACHE_INTERNAL_GENERATION_PREFIX); + } + + function generationKeyEntry(value: number): CachedEntry { + return { + rows: [], + storedAt: value, + }; + } function get(key: string): Promise { + if (isGenerationKey(key)) { + return Promise.resolve( + counters.get(key) === undefined ? undefined : generationKeyEntry(counters.get(key)!.value), + ); + } + const record = map.get(key); if (record === undefined) { return Promise.resolve(undefined); @@ -110,6 +158,27 @@ export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): Ca } function set(key: string, entry: CachedEntry, ttlMs: number): Promise { + if (isGenerationKey(key)) { + counters.set(key, { value: entry.storedAt }); + return Promise.resolve(); + } + + // If the key already exists, clean up its old tags. + if (map.has(key)) { + const oldEntry = map.get(key)?.entry; + if (oldEntry?.tags) { + for (const tag of oldEntry.tags) { + const tagSet = tagIndex.get(tag); + if (tagSet) { + tagSet.delete(key); + if (tagSet.size === 0) { + tagIndex.delete(tag); + } + } + } + } + } + const expiresAt = clock() + ttlMs; // Re-set semantics: if the key is already present, deleting first // ensures the new value lands at the end of the iteration order @@ -121,6 +190,16 @@ export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): Ca } map.set(key, { entry, expiresAt }); + // Index the new tags + if (entry.tags) { + for (const tag of entry.tags) { + if (!tagIndex.has(tag)) { + tagIndex.set(tag, new Set()); + } + tagIndex.get(tag)!.add(key); + } + } + // Evict LRU entries until the live count is within bounds. The // iterator yields keys in insertion order; the first one is the // oldest (LRU). @@ -129,11 +208,109 @@ export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): Ca if (oldest.done) { break; } - map.delete(oldest.value); + const keyToEvict = oldest.value; + const evictedRecord = map.get(keyToEvict); + if (evictedRecord?.entry.tags) { + for (const tag of evictedRecord.entry.tags) { + const tagSet = tagIndex.get(tag); + if (tagSet) { + tagSet.delete(keyToEvict); + if (tagSet.size === 0) { + tagIndex.delete(tag); + } + } + } + } + map.delete(keyToEvict); + } + + return Promise.resolve(); + } + + function unlinkTaggedKey(key: string, tags: readonly string[] | undefined): void { + if (tags === undefined) return; + for (const tag of tags) { + const tagSet = tagIndex.get(tag); + if (tagSet === undefined) continue; + tagSet.delete(key); + if (tagSet.size === 0) tagIndex.delete(tag); + } + } + + function deleteCacheKey(key: string): void { + const record = map.get(key); + unlinkTaggedKey(key, record?.entry.tags); + map.delete(key); + } + + function list(prefix?: string): Promise { + if (prefix !== undefined && prefix.startsWith(CACHE_INTERNAL_GENERATION_PREFIX)) { + return Promise.resolve([...counters.keys()].filter((key) => key.startsWith(prefix))); + } + + const now = clock(); + for (const [key, record] of map) { + if (now >= record.expiresAt) { + deleteCacheKey(key); + } + } + const keys = + prefix === undefined + ? [...map.keys()] + : [...map.keys()].filter((key) => key.startsWith(prefix)); + return Promise.resolve(keys); + } + + function del(key: string): Promise { + if (isGenerationKey(key)) { + counters.delete(key); + return Promise.resolve(); + } + + deleteCacheKey(key); + return Promise.resolve(); + } + + function incr(key: string, delta = 1): Promise { + if (!isGenerationKey(key)) { + const current = counters.get(key)?.value ?? 0; + const next = current + delta; + counters.set(key, { value: next }); + return Promise.resolve(next); + } + + const current = counters.get(key)?.value ?? 0; + const next = current + delta; + counters.set(key, { value: next }); + return Promise.resolve(next); + } + + function delByTag(tags: readonly string[]): Promise { + const keysToDelete = new Set(); + for (const tag of tags) { + const tagSet = tagIndex.get(tag); + if (tagSet) { + for (const key of tagSet) { + keysToDelete.add(key); + } + } + } + + for (const key of keysToDelete) { + if (map.has(key)) { + deleteCacheKey(key); + } else { + for (const tag of tags) { + const tagSet = tagIndex.get(tag); + if (tagSet === undefined) continue; + tagSet.delete(key); + if (tagSet.size === 0) tagIndex.delete(tag); + } + } } return Promise.resolve(); } - return { get, set }; + return { get, set, list, del, delByTag, incr }; } diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts index 9abc87a6c368..78faac4d2c65 100644 --- a/packages/3-extensions/middleware-cache/src/exports/index.ts +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -1,6 +1,20 @@ export type { CachePayload } from '../cache-annotation'; export { cacheAnnotation } from '../cache-annotation'; -export type { CacheMiddlewareOptions } from '../cache-middleware'; -export { createCacheMiddleware } from '../cache-middleware'; +export type { + CacheMiddleware, + CacheMiddlewareOptions, + CacheStoreOperationMode, + CacheStrategyConfig, + CacheStrategyMode, + GenerationBumpOn, + GenerationGuardConfig, + GenerationScope, + GenerationStrategyConfig, + NamespaceConfig, + NamespacePattern, +} from '../cache-middleware'; +export { createCacheMiddleware, uncache } from '../cache-middleware'; export type { CachedEntry, CacheStore, InMemoryCacheStoreOptions } from '../cache-store'; export { createInMemoryCacheStore } from '../cache-store'; +export type { UncacheAction, UncachePayload } from '../uncache-annotation'; +export { uncacheAnnotation } from '../uncache-annotation'; diff --git a/packages/3-extensions/middleware-cache/src/uncache-annotation.ts b/packages/3-extensions/middleware-cache/src/uncache-annotation.ts new file mode 100644 index 000000000000..ff879e9cc76c --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/uncache-annotation.ts @@ -0,0 +1,44 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; + +/** + * A single cache invalidation action. + * + * - `keys` — explicit list of cache keys to delete. Each key is prefixed + * with `namespace:` when `namespace` is set. + * - `models` — invalidates keys previously indexed for these model names. + * - `tags` — invalidates all keys that have any of these tags. + * - `namespace` — narrows invalidation to all keys that start with this + * prefix (when `keys` is omitted). When both are omitted, all keys in + * the store are invalidated. + */ +export interface UncacheAction { + readonly namespace?: string; + readonly keys?: readonly string[]; + readonly models?: readonly string[]; + readonly tags?: readonly string[]; +} + +/** + * Payload accepted by `uncacheAnnotation` on write terminals. + * + * - `enabled` toggles mutation-triggered invalidation for this execution. + * - `skip` is an explicit passthrough alias to disable invalidation. + * - `namespace` narrows invalidation to a namespace prefix (single-action + * shorthand; ignored when `uncache` is provided). + * - `uncache` — explicit list of invalidation actions. When provided, + * each action is executed in order. Takes precedence over `namespace`. + */ +export interface UncachePayload { + readonly enabled?: boolean; + readonly skip?: boolean; + readonly namespace?: string; + readonly uncache?: readonly UncacheAction[]; +} + +/** + * Write-only annotation handle for mutation-triggered cache invalidation. + */ +export const uncacheAnnotation = defineAnnotation()({ + namespace: 'uncache', + applicableTo: ['write'], +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts index bed3e9da416d..b7398307f8b3 100644 --- a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts @@ -48,8 +48,17 @@ describe('cacheAnnotation handle', () => { expect(cacheAnnotation.read(plan)).toBeUndefined(); }); - it('preserves all CachePayload fields (ttl, skip, key)', () => { - const payload: CachePayload = { ttl: 120, skip: false, key: 'custom-key' }; + it('preserves all CachePayload fields (enabled, ttl, skip, key, namespace, dedupe, tags, store)', () => { + const payload: CachePayload = { + enabled: true, + ttl: 120, + skip: false, + key: 'custom-key', + namespace: 'tenant-a', + dedupe: true, + tags: ['users', 'tenant-a'], + store: 'primary', + }; const applied = cacheAnnotation(payload); const plan = planWith({ cache: applied }); diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts index 8f1204dd6973..666a05a59232 100644 --- a/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts @@ -40,10 +40,13 @@ test('cacheAnnotation declares applicableTo = "read" only', () => { expectTypeOf(cacheAnnotation.applicableTo).toEqualTypeOf>(); }); -test('CachePayload has optional ttl, skip, and key', () => { +test('CachePayload has optional enabled, ttl, skip, key, namespace, and dedupe', () => { const empty: CachePayload = {}; void empty; + const enabledOnly: CachePayload = { enabled: true }; + void enabledOnly; + const ttlOnly: CachePayload = { ttl: 60 }; void ttlOnly; @@ -53,7 +56,20 @@ test('CachePayload has optional ttl, skip, and key', () => { const keyOnly: CachePayload = { key: 'k' }; void keyOnly; - const all: CachePayload = { ttl: 60, skip: false, key: 'k' }; + const namespaceOnly: CachePayload = { namespace: 'tenant-a' }; + void namespaceOnly; + + const dedupeOnly: CachePayload = { dedupe: true }; + void dedupeOnly; + + const all: CachePayload = { + enabled: true, + ttl: 60, + skip: false, + key: 'k', + namespace: 'tenant-a', + dedupe: true, + }; void all; }); diff --git a/packages/3-extensions/middleware-cache/test/cache-key.test.ts b/packages/3-extensions/middleware-cache/test/cache-key.test.ts index 2bde8de704a7..b4b434828fe9 100644 --- a/packages/3-extensions/middleware-cache/test/cache-key.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-key.test.ts @@ -42,6 +42,8 @@ function makeCtx(overrides?: Partial): RuntimeMiddlewa function spyStore(): CacheStore & { readonly getSpy: ReturnType; readonly setSpy: ReturnType; + readonly listSpy: ReturnType; + readonly delSpy: ReturnType; readonly inner: Map; } { const inner = new Map(); @@ -49,7 +51,24 @@ function spyStore(): CacheStore & { const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { inner.set(key, entry); }); - return { get: getSpy, set: setSpy, getSpy, setSpy, inner }; + const listSpy = vi.fn(async (prefix?: string) => { + const keys = [...inner.keys()]; + return prefix === undefined ? keys : keys.filter((key) => key.startsWith(prefix)); + }); + const delSpy = vi.fn(async (key: string) => { + inner.delete(key); + }); + return { + get: getSpy, + set: setSpy, + list: listSpy, + del: delSpy, + getSpy, + setSpy, + listSpy, + delSpy, + inner, + }; } async function drain(iter: AsyncIterable): Promise { diff --git a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts index 874162e6d880..599537d49607 100644 --- a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts @@ -6,8 +6,9 @@ import type { } from '@internal/framework-components/runtime'; import { describe, expect, it, vi } from 'vitest'; import { cacheAnnotation } from '../src/cache-annotation'; -import { createCacheMiddleware } from '../src/cache-middleware'; +import { createCacheMiddleware, uncache as runUncache } from '../src/cache-middleware'; import { type CachedEntry, type CacheStore, createInMemoryCacheStore } from '../src/cache-store'; +import { type UncacheAction, uncacheAnnotation } from '../src/uncache-annotation'; interface MockExec extends ExecutionPlan { readonly statement: string; @@ -40,9 +41,38 @@ function makeCtx(overrides?: Partial): RuntimeMiddlewa }; } +function makeContractWithCompositePrimaryKey() { + return { + storage: { + namespaces: { + public: { + tables: { + kv: { + primaryKey: { columns: ['ns', 'key'] }, + }, + }, + }, + }, + }, + domain: { + namespaces: { + public: { + models: { + Kv: { + storage: { table: 'kv' }, + }, + }, + }, + }, + }, + }; +} + function spyStore(): CacheStore & { readonly getSpy: ReturnType; readonly setSpy: ReturnType; + readonly listSpy: ReturnType; + readonly delSpy: ReturnType; readonly inner: Map; } { const inner = new Map(); @@ -50,15 +80,36 @@ function spyStore(): CacheStore & { const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { inner.set(key, entry); }); + const listSpy = vi.fn(async (prefix?: string) => { + const keys = [...inner.keys()]; + return prefix === undefined ? keys : keys.filter((key) => key.startsWith(prefix)); + }); + const delSpy = vi.fn(async (key: string) => { + inner.delete(key); + }); return { get: getSpy, set: setSpy, + list: listSpy, + del: delSpy, getSpy, setSpy, + listSpy, + delSpy, inner, }; } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + async function drain(iter: AsyncIterable): Promise { const out: T[] = []; for await (const x of iter) out.push(x); @@ -364,6 +415,221 @@ describe('createCacheMiddleware — miss path', () => { { from: 'B', n: 2 }, ]); }); + + it('deduplicates concurrent misses for the same key (single-flight)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, readDedupe: true, clock: () => 0 }); + const leaderExec = makeExec('select dedupe', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const followerExec = makeExec('select dedupe', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(leaderExec, ctx); + const follower = mw.intercept!(followerExec, ctx); + + let settled = false; + void follower.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + await mw.onRow!({ id: 1 }, leaderExec, ctx); + await mw.afterExecute!( + leaderExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await expect(follower).resolves.toEqual({ rows: [{ id: 1 }] }); + expect(store.setSpy).toHaveBeenCalledTimes(1); + }); + + it('detached storeOperationMode does not await slow store.set on read commit', async () => { + const gate = deferred(); + const inner = new Map(); + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry) => { + await gate.promise; + inner.set(key, entry); + }); + const store: CacheStore = { + get: getSpy, + set: setSpy, + list: async () => [], + del: async () => {}, + }; + + const mw = createCacheMiddleware({ store, storeOperationMode: 'detached', clock: () => 0 }); + const exec = makeExec('select detached set', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + + const after = mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + const completionRace = await Promise.race([ + after.then(() => 'after' as const), + new Promise<'tick'>((resolve) => setTimeout(() => resolve('tick'), 0)), + ]); + expect(completionRace).toBe('after'); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(inner.get('key:select detached set')).toBeUndefined(); + + gate.resolve(); + await after; + await Promise.resolve(); + expect(inner.get('key:select detached set')?.rows).toEqual([{ id: 1 }]); + }); + + it('falls back to passthrough for deduplicated followers when the leader fails', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, readDedupe: true }); + const leaderExec = makeExec('select dedupe fail', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const followerExec = makeExec('select dedupe fail', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(leaderExec, ctx); + const follower = mw.intercept!(followerExec, ctx); + + await mw.afterExecute!( + leaderExec, + { rowCount: 0, latencyMs: 1, completed: false, source: 'driver' }, + ctx, + ); + + await expect(follower).resolves.toBeUndefined(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('global readDedupe: false disables single-flight dedupe', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, readDedupe: false, clock: () => 0 }); + const leaderExec = makeExec('select dedupe global off', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const followerExec = makeExec('select dedupe global off', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await expect(mw.intercept!(leaderExec, ctx)).resolves.toBeUndefined(); + await expect(mw.intercept!(followerExec, ctx)).resolves.toBeUndefined(); + + await mw.onRow!({ leader: true }, leaderExec, ctx); + await mw.onRow!({ follower: true }, followerExec, ctx); + await mw.afterExecute!( + leaderExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + await mw.afterExecute!( + followerExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).toHaveBeenCalledTimes(2); + }); + + it('readDedupe defaults to false', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const leaderExec = makeExec('select dedupe default off', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const followerExec = makeExec('select dedupe default off', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await expect(mw.intercept!(leaderExec, ctx)).resolves.toBeUndefined(); + await expect(mw.intercept!(followerExec, ctx)).resolves.toBeUndefined(); + + await mw.onRow!({ leader: true }, leaderExec, ctx); + await mw.onRow!({ follower: true }, followerExec, ctx); + await mw.afterExecute!( + leaderExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + await mw.afterExecute!( + followerExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).toHaveBeenCalledTimes(2); + }); + + it('cacheAnnotation dedupe: true overrides global readDedupe: false', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, readDedupe: false, clock: () => 0 }); + const leaderExec = makeExec('select dedupe annotation on', { + cache: cacheAnnotation({ ttl: 60_000, dedupe: true }), + }); + const followerExec = makeExec('select dedupe annotation on', { + cache: cacheAnnotation({ ttl: 60_000, dedupe: true }), + }); + const ctx = makeCtx(); + + await mw.intercept!(leaderExec, ctx); + const follower = mw.intercept!(followerExec, ctx); + + await mw.onRow!({ id: 1 }, leaderExec, ctx); + await mw.afterExecute!( + leaderExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await expect(follower).resolves.toEqual({ rows: [{ id: 1 }] }); + expect(store.setSpy).toHaveBeenCalledTimes(1); + }); + + it('cacheAnnotation dedupe: false overrides global readDedupe: true', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, readDedupe: true, clock: () => 0 }); + const leaderExec = makeExec('select dedupe annotation off', { + cache: cacheAnnotation({ ttl: 60_000, dedupe: false }), + }); + const followerExec = makeExec('select dedupe annotation off', { + cache: cacheAnnotation({ ttl: 60_000, dedupe: false }), + }); + const ctx = makeCtx(); + + await expect(mw.intercept!(leaderExec, ctx)).resolves.toBeUndefined(); + await expect(mw.intercept!(followerExec, ctx)).resolves.toBeUndefined(); + + await mw.onRow!({ leader: true }, leaderExec, ctx); + await mw.onRow!({ follower: true }, followerExec, ctx); + await mw.afterExecute!( + leaderExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + await mw.afterExecute!( + followerExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).toHaveBeenCalledTimes(2); + }); }); describe('createCacheMiddleware — scope guard', () => { @@ -490,3 +756,1598 @@ describe('createCacheMiddleware — middleware shape', () => { expect(real?.rows).toEqual([{ id: 7 }]); }); }); + +describe('createCacheMiddleware — standalone uncache()', () => { + it('exposes an uncache method', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(typeof mw.uncache).toBe('function'); + }); + + it('deletes explicit keys from the store', async () => { + const store = spyStore(); + store.inner.set('user:1', { rows: [{ id: 1 }], storedAt: 0 }); + store.inner.set('user:2', { rows: [{ id: 2 }], storedAt: 0 }); + store.inner.set('post:1', { rows: [{ id: 3 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + + const uncacheActions: readonly UncacheAction[] = [{ keys: ['user:1', 'user:2'] }]; + await mw.uncache(uncacheActions); + + expect(await store.get('user:1')).toBeUndefined(); + expect(await store.get('user:2')).toBeUndefined(); + expect(await store.get('post:1')).toBeDefined(); + }); + + it('deletes keys with namespace prefix when namespace is set on the action', async () => { + const store = spyStore(); + store.inner.set('ns:user:1', { rows: [{ id: 1 }], storedAt: 0 }); + store.inner.set('ns:user:2', { rows: [{ id: 2 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + + await mw.uncache([{ namespace: 'ns', keys: ['user:1', 'user:2'] }]); + + expect(await store.get('ns:user:1')).toBeUndefined(); + expect(await store.get('ns:user:2')).toBeUndefined(); + }); + + it('deletes all keys with matching namespace prefix when keys is omitted', async () => { + const store = spyStore(); + store.inner.set('users:1', { rows: [{ id: 1 }], storedAt: 0 }); + store.inner.set('users:2', { rows: [{ id: 2 }], storedAt: 0 }); + store.inner.set('posts:1', { rows: [{ id: 3 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + + await mw.uncache([{ namespace: 'users' }]); + + expect(await store.get('users:1')).toBeUndefined(); + expect(await store.get('users:2')).toBeUndefined(); + expect(await store.get('posts:1')).toBeDefined(); + }); + + it('executes multiple actions in order', async () => { + const store = spyStore(); + store.inner.set('a:1', { rows: [], storedAt: 0 }); + store.inner.set('b:1', { rows: [], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + + await mw.uncache([{ namespace: 'a' }, { namespace: 'b' }]); + + expect(await store.get('a:1')).toBeUndefined(); + expect(await store.get('b:1')).toBeUndefined(); + }); + + it('supports model-based invalidation via middleware.uncache()', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: false, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUsersExec = Object.freeze({ + ...makeExec('select users manual-model'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readPostsExec = Object.freeze({ + ...makeExec('select posts manual-model'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUsersExec, readPostsExec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.uncache([{ models: ['users'] }]); + + expect(await mw.intercept!(readUsersExec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readPostsExec, ctx)).toBeDefined(); + }); + + it('exports uncache helper function and delegates to middleware.uncache()', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + store.inner.set('export:user:1', { rows: [{ id: 1 }], storedAt: 0 }); + + await runUncache(mw, [{ keys: ['export:user:1'] }]); + + expect(await store.get('export:user:1')).toBeUndefined(); + }); + + it('throws when the store does not implement del and explicit keys are provided', async () => { + const minimalStore: CacheStore = { + async get(_key) { + return undefined; + }, + async set(_key, _entry, _ttlMs) {}, + }; + const mw = createCacheMiddleware({ store: minimalStore }); + + await expect(mw.uncache([{ keys: ['user:1'] }])).rejects.toThrow(/does not implement `del`/); + }); +}); + +describe('createCacheMiddleware — global policy controls', () => { + it('detached storeOperationMode does not await slow store.del on mutation invalidation', async () => { + const gate = deferred(); + const inner = new Map(); + inner.set('k:1', { rows: [{ id: 1 }], storedAt: 0 }); + + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry) => { + inner.set(key, entry); + }); + const delSpy = vi.fn(async (key: string) => { + await gate.promise; + inner.delete(key); + }); + const store: CacheStore = { + get: getSpy, + set: setSpy, + list: async () => [], + del: delSpy, + }; + + const mw = createCacheMiddleware({ + store, + storeOperationMode: 'detached', + uncacheOnMutation: true, + }); + const write = Object.freeze({ + ...makeExec('mutation detached del', { + uncache: uncacheAnnotation({ uncache: [{ keys: ['k:1'] }] }), + }), + ast: { + kind: 'update', + table: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + const after = mw.afterExecute!( + write, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + const completionRace = await Promise.race([ + after.then(() => 'after' as const), + new Promise<'tick'>((resolve) => setTimeout(() => resolve('tick'), 0)), + ]); + expect(completionRace).toBe('after'); + expect(delSpy).toHaveBeenCalledWith('k:1'); + expect(inner.has('k:1')).toBe(true); + + gate.resolve(); + await after; + await Promise.resolve(); + expect(inner.has('k:1')).toBe(false); + }); + + it('falls back to generation invalidation when uncache is triggered but store lacks del/list', async () => { + const minimalStore: CacheStore = { + async get(_key) { + return undefined; + }, + async set(_key, _entry, _ttlMs) {}, + // no list, no del + }; + const mw = createCacheMiddleware({ + store: minimalStore, + uncacheOnMutation: true, + }); + const writeExec = Object.freeze({ + ...makeExec('delete users'), + ast: { + kind: 'delete', + table: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + await expect( + mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ), + ).resolves.toBeUndefined(); + }); + + it('automatically uses generation versioning without del/list for model invalidation', async () => { + const inner = new Map(); + const store: CacheStore = { + async get(key) { + return inner.get(key); + }, + async set(key, entry) { + inner.set(key, entry); + }, + }; + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + cacheStrategy: { mode: 'broad' }, + }); + const readExec = Object.freeze({ + ...makeExec('select users fallback generation'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users fallback generation'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeUndefined(); + }); + it('caches unannotated reads when global readCaching is enabled and defaultTtlMs is set', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + readCaching: true, + defaultTtlMs: 30_000, + }); + const exec: MockExec = makeExec('select global-read'); + const execWithAst: MockExec = Object.freeze({ + ...exec, + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + const first = await mw.intercept!(execWithAst, ctx); + expect(first).toBeUndefined(); + await mw.onRow!({ id: 1 }, execWithAst, ctx); + await mw.afterExecute!( + execWithAst, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + const second = await mw.intercept!(execWithAst, ctx); + expect(second).toBeDefined(); + expect(await drain(second!.rows as AsyncIterable>)).toEqual([ + { id: 1 }, + ]); + }); + + it('invalidates cached read keys on write when uncacheOnMutation is globally enabled', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users'), + ast: { + kind: 'update', + table: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + const postMutation = await mw.intercept!(readExec, ctx); + expect(postMutation).toBeUndefined(); + }); + + it('allows uncacheAnnotation to force invalidation even when global uncache is disabled', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: false, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users force'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users force', { + uncache: uncacheAnnotation({ enabled: true }), + }), + ast: { + kind: 'update', + table: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeUndefined(); + }); + + it('allows uncacheAnnotation skip to suppress global invalidation', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users skip'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users skip', { + uncache: uncacheAnnotation({ skip: true }), + }), + ast: { + kind: 'update', + table: { kind: 'table-source', name: 'users' }, + }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + }); + + it('enabled: false on uncacheAnnotation suppresses global invalidation', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users enabled-false'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users enabled-false', { + uncache: uncacheAnnotation({ enabled: false }), + }), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + }); + + it('failed mutation (completed: false) does not invalidate cache', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users failed-mut'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('delete users failed'), + ast: { kind: 'delete', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 0, latencyMs: 1, completed: false, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + }); +}); + +describe('createCacheMiddleware — model-indexed invalidation', () => { + it('invalidates cache when model is discovered through a nested derived-table source', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const nestedRead = Object.freeze({ + ...makeExec('select derived users'), + ast: { + kind: 'select', + from: { + kind: 'derived-table-source', + query: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + }, + }, + }, + }) as MockExec; + const mutateUsersExec = Object.freeze({ + ...makeExec('update users nested'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(nestedRead, ctx); + await mw.onRow!({ id: 1 }, nestedRead, ctx); + await mw.afterExecute!( + nestedRead, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(nestedRead, ctx)).toBeDefined(); + + await mw.afterExecute!( + mutateUsersExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(nestedRead, ctx)).toBeUndefined(); + }); + + it('invalidates a JOIN-read when a mutation touches any of the joined tables', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const joinRead = Object.freeze({ + ...makeExec('select users join posts'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + joins: [{ source: { kind: 'table-source', name: 'posts' } }], + }, + }) as MockExec; + const mutatePostsExec = Object.freeze({ + ...makeExec('update posts'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(joinRead, ctx); + await mw.onRow!({ id: 1 }, joinRead, ctx); + await mw.afterExecute!( + joinRead, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(joinRead, ctx)).toBeDefined(); + + await mw.afterExecute!( + mutatePostsExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(joinRead, ctx)).toBeUndefined(); + }); + + it('invalidates a JOIN-read when a mutation touches the primary (FROM) table', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const joinRead = Object.freeze({ + ...makeExec('select users join posts 2'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + joins: [{ source: { kind: 'table-source', name: 'posts' } }], + }, + }) as MockExec; + const mutateUsersExec = Object.freeze({ + ...makeExec('delete users'), + ast: { kind: 'delete', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(joinRead, ctx); + await mw.onRow!({ id: 1 }, joinRead, ctx); + await mw.afterExecute!( + joinRead, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + mutateUsersExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(joinRead, ctx)).toBeUndefined(); + }); + + it('invalidates only the matching entity cache for simple id-based CRUD', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUser1Exec = Object.freeze({ + ...makeExec('select users where id = 1'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const readUser2Exec = Object.freeze({ + ...makeExec('select users where id = 2'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 2 }, + }, + }, + }) as MockExec; + const deleteUser1Exec = Object.freeze({ + ...makeExec('delete users where id = 1'), + ast: { + kind: 'delete', + table: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readUser1Exec, ctx); + await mw.onRow!({ id: 1 }, readUser1Exec, ctx); + await mw.afterExecute!( + readUser1Exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.intercept!(readUser2Exec, ctx); + await mw.onRow!({ id: 2 }, readUser2Exec, ctx); + await mw.afterExecute!( + readUser2Exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUser1Exec, ctx)).toBeDefined(); + expect(await mw.intercept!(readUser2Exec, ctx)).toBeDefined(); + + await mw.afterExecute!( + deleteUser1Exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUser1Exec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readUser2Exec, ctx)).toBeDefined(); + }); + + it('uses broad model invalidation when cacheStrategy.mode = model', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'broad' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUser1Exec = Object.freeze({ + ...makeExec('select users model-strategy id=1'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const readUser2Exec = Object.freeze({ + ...makeExec('select users model-strategy id=2'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 2 }, + }, + }, + }) as MockExec; + const deleteUser1Exec = Object.freeze({ + ...makeExec('delete users model-strategy id=1'), + ast: { + kind: 'delete', + table: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUser1Exec, readUser2Exec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + deleteUser1Exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUser1Exec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readUser2Exec, ctx)).toBeUndefined(); + }); + + it('invalidates an exact composite-primary-key entity cache when the contract exposes the primary key', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const ctx = makeCtx({ contract: makeContractWithCompositePrimaryKey() }); + const readKvExec = Object.freeze({ + ...makeExec('select kv exact pk'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'kv' }, + where: { + kind: 'and', + exprs: [ + { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'kv', column: 'ns' }, + right: { kind: 'literal', value: 'tenant-a' }, + }, + { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'kv', column: 'key' }, + right: { kind: 'literal', value: 'feature-x' }, + }, + ], + }, + }, + }) as MockExec; + const deleteKvExec = Object.freeze({ + ...makeExec('delete kv exact pk'), + ast: { + kind: 'delete', + table: { kind: 'table-source', name: 'kv' }, + where: { + kind: 'and', + exprs: [ + { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'kv', column: 'ns' }, + right: { kind: 'literal', value: 'tenant-a' }, + }, + { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'kv', column: 'key' }, + right: { kind: 'literal', value: 'feature-x' }, + }, + ], + }, + }, + }) as MockExec; + + await mw.intercept!(readKvExec, ctx); + await mw.onRow!({ ns: 'tenant-a', key: 'feature-x', enabled: true }, readKvExec, ctx); + await mw.afterExecute!( + readKvExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readKvExec, ctx)).toBeDefined(); + + await mw.afterExecute!( + deleteKvExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readKvExec, ctx)).toBeUndefined(); + }); + it('model strategy invalidation crosses middleware instances that share the same store', async () => { + const store = createInMemoryCacheStore({ maxEntries: 20 }); + const mwA = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'broad' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const mwB = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'broad' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users shared-model overlap'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('delete users shared-model overlap'), + ast: { kind: 'delete', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mwA.intercept!(readExec, ctx); + await mwA.onRow!({ id: 1 }, readExec, ctx); + await mwA.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mwA.intercept!(readExec, ctx)).toBeDefined(); + + await mwB.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mwA.intercept!(readExec, ctx)).toBeUndefined(); + }); + + it('uses generation invalidation when cacheStrategy.mode = generation', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'versioned' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUser1Exec = Object.freeze({ + ...makeExec('select users generation id=1'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const readUser2Exec = Object.freeze({ + ...makeExec('select users generation id=2'), + ast: { + kind: 'select', + from: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 2 }, + }, + }, + }) as MockExec; + const deleteUser1Exec = Object.freeze({ + ...makeExec('delete users generation id=1'), + ast: { + kind: 'delete', + table: { kind: 'table-source', name: 'users' }, + where: { + kind: 'binary', + op: 'eq', + left: { kind: 'column-ref', table: 'users', column: 'id' }, + right: { kind: 'literal', value: 1 }, + }, + }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUser1Exec, readUser2Exec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + expect(await mw.intercept!(readUser1Exec, ctx)).toBeDefined(); + expect(await mw.intercept!(readUser2Exec, ctx)).toBeDefined(); + + await mw.afterExecute!( + deleteUser1Exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUser1Exec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readUser2Exec, ctx)).toBeUndefined(); + }); + + it('generation strategy invalidation crosses middleware instances that share the same store', async () => { + const store = createInMemoryCacheStore({ maxEntries: 20 }); + const mwA = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'versioned' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const mwB = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'versioned' }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users shared-generation overlap'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('delete users shared-generation overlap'), + ast: { kind: 'delete', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mwA.intercept!(readExec, ctx); + await mwA.onRow!({ id: 1 }, readExec, ctx); + await mwA.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mwA.intercept!(readExec, ctx)).toBeDefined(); + + await mwB.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mwA.intercept!(readExec, ctx)).toBeUndefined(); + }); + + it('generation mode does not require del/list for model invalidation', async () => { + const minimalStore: CacheStore = { + async get(_key) { + return undefined; + }, + async set(_key, _entry, _ttlMs) {}, + }; + const mw = createCacheMiddleware({ + store: minimalStore, + cacheStrategy: { mode: 'versioned' }, + uncacheOnMutation: true, + }); + const writeExec = Object.freeze({ + ...makeExec('update users generation no-del-list'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + + await expect( + mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + makeCtx(), + ), + ).resolves.toBeUndefined(); + }); + + it('generation bumpOn=all-writes invalidates on writes even when uncache is disabled', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { mode: 'versioned', generation: { bumpOn: 'all-writes' } }, + uncacheOnMutation: false, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users generation all-writes read'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation all-writes write'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeDefined(); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeUndefined(); + }); + + it('generation scope action-models-preferred bumps annotation models for all-writes', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { bumpOn: 'all-writes', scope: 'action-models-preferred' }, + }, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUsersExec = Object.freeze({ + ...makeExec('select users generation scope users'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readPostsExec = Object.freeze({ + ...makeExec('select posts generation scope posts'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation scope write', { + uncache: uncacheAnnotation({ uncache: [{ models: ['posts'] }] }), + }), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUsersExec, readPostsExec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUsersExec, ctx)).toBeDefined(); + expect(await mw.intercept!(readPostsExec, ctx)).toBeUndefined(); + }); + + it('generation guard deletes stale keys when enabled', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { guard: { enabled: true, maxDeletesPerBump: 10 } }, + }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users generation guard enabled'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation guard enabled write'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.inner.size).toBeGreaterThan(0); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.delSpy).toHaveBeenCalled(); + expect(store.inner.size).toBe(0); + }); + + it('generation guard does not delete stale keys when disabled', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { guard: { enabled: false } }, + }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users generation guard disabled'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation guard disabled write'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.delSpy).not.toHaveBeenCalled(); + expect(store.inner.size).toBeGreaterThan(0); + }); + + it('generation guard respects maxDeletesPerBump limit', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { guard: { enabled: true, maxDeletesPerBump: 1 } }, + }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExecA = Object.freeze({ + ...makeExec('select users generation guard limit a'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readExecB = Object.freeze({ + ...makeExec('select users generation guard limit b'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation guard limit write'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readExecA, readExecB]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.delSpy).toHaveBeenCalledTimes(1); + expect(store.inner.size).toBeGreaterThan(1); + expect(store.inner.size).toBeLessThan(4); + }); + + it('generation guard skips cleanup when store.del is missing', async () => { + const minimalStore: CacheStore = { + async get(_key) { + return undefined; + }, + async set(_key, _entry, _ttlMs) {}, + }; + const mw = createCacheMiddleware({ + store: minimalStore, + cacheStrategy: { + mode: 'versioned', + generation: { guard: { enabled: true } }, + }, + uncacheOnMutation: true, + }); + const writeExec = Object.freeze({ + ...makeExec('update users generation guard no-del'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + + await expect( + mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + makeCtx(), + ), + ).resolves.toBeUndefined(); + }); + + it('generation scope action-models-preferred falls back to detected models when actions have no model', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { bumpOn: 'all-writes', scope: 'action-models-preferred' }, + }, + readCaching: true, + defaultTtlMs: 30_000, + uncacheOnMutation: true, + }); + const readUsersExec = Object.freeze({ + ...makeExec('select users generation scope fallback users'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readPostsExec = Object.freeze({ + ...makeExec('select posts generation scope fallback posts'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation scope fallback write', { + uncache: uncacheAnnotation({ uncache: [{ namespace: 'tenant-a' }] }), + }), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUsersExec, readPostsExec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUsersExec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readPostsExec, ctx)).toBeDefined(); + }); + + it('emits generation bump and guard cleanup telemetry when stale keys are deleted', async () => { + const store = spyStore(); + const debugSpy = vi.fn(); + const mw = createCacheMiddleware({ + store, + cacheStrategy: { + mode: 'versioned', + generation: { guard: { enabled: true, maxDeletesPerBump: 10 } }, + }, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users generation telemetry'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update users generation telemetry write'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx({ + log: { info: () => {}, warn: () => {}, error: () => {}, debug: debugSpy }, + }); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(debugSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'middleware.cache.generation.bump', + middleware: 'cache', + models: ['users'], + }), + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'middleware.cache.generation.guard.cleanup', + middleware: 'cache', + models: ['users'], + }), + ); + }); + + it('does NOT invalidate an unrelated table cache when mutating a different table', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readPostsExec = Object.freeze({ + ...makeExec('select posts only'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const mutateUsersExec = Object.freeze({ + ...makeExec('update users only'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readPostsExec, ctx); + await mw.onRow!({ id: 1 }, readPostsExec, ctx); + await mw.afterExecute!( + readPostsExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + mutateUsersExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readPostsExec, ctx)).toBeDefined(); + }); + + it('invalidates only the matching table cache; leaves other tables intact', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUsersExec = Object.freeze({ + ...makeExec('select users iso'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readPostsExec = Object.freeze({ + ...makeExec('select posts iso'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const mutateUsersExec = Object.freeze({ + ...makeExec('insert users iso'), + ast: { kind: 'insert', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUsersExec, readPostsExec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + mutateUsersExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUsersExec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readPostsExec, ctx)).toBeDefined(); + }); + + it('model index survives a second mutation after first already deleted the entry', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: true, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readExec = Object.freeze({ + ...makeExec('select users cleanup'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const mutateExec = Object.freeze({ + ...makeExec('update users cleanup'), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + mutateExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + await expect( + mw.afterExecute!( + mutateExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ), + ).resolves.toBeUndefined(); + }); + + it('uncacheAnnotation uncache field on a mutation invalidates specified namespace', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: false, + readCaching: true, + defaultTtlMs: 30_000, + namespace: 'app', + }); + const readExec = Object.freeze({ + ...makeExec('select users ann-actions'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('insert users ann-actions', { + uncache: uncacheAnnotation({ uncache: [{ namespace: 'app' }] }), + }), + ast: { kind: 'insert', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.intercept!(readExec, ctx); + await mw.onRow!({ id: 1 }, readExec, ctx); + await mw.afterExecute!( + readExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readExec, ctx)).toBeUndefined(); + }); + + it('uncacheAnnotation uncache with explicit keys deletes only those keys', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, uncacheOnMutation: false }); + store.inner.set('user:1', { rows: [{ id: 1 }], storedAt: 0 }); + store.inner.set('user:2', { rows: [{ id: 2 }], storedAt: 0 }); + store.inner.set('post:1', { rows: [{ id: 3 }], storedAt: 0 }); + + const writeExec = Object.freeze({ + ...makeExec('update users explicit-keys', { + uncache: uncacheAnnotation({ uncache: [{ keys: ['user:1'] }] }), + }), + ast: { kind: 'update', table: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const ctx = makeCtx(); + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await store.get('user:1')).toBeUndefined(); + expect(await store.get('user:2')).toBeDefined(); + expect(await store.get('post:1')).toBeDefined(); + }); + + it('uncacheAnnotation uncache supports model selector', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ + store, + uncacheOnMutation: false, + readCaching: true, + defaultTtlMs: 30_000, + }); + const readUsersExec = Object.freeze({ + ...makeExec('select users ann-model'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'users' } }, + }) as MockExec; + const readPostsExec = Object.freeze({ + ...makeExec('select posts ann-model'), + ast: { kind: 'select', from: { kind: 'table-source', name: 'posts' } }, + }) as MockExec; + const writeExec = Object.freeze({ + ...makeExec('update profile ann-model', { + uncache: uncacheAnnotation({ uncache: [{ models: ['users'] }] }), + }), + ast: { kind: 'update', table: { kind: 'table-source', name: 'profiles' } }, + }) as MockExec; + const ctx = makeCtx(); + + for (const exec of [readUsersExec, readPostsExec]) { + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + } + + await mw.afterExecute!( + writeExec, + { rowCount: 1, latencyMs: 1, completed: true, source: 'driver' }, + ctx, + ); + + expect(await mw.intercept!(readUsersExec, ctx)).toBeUndefined(); + expect(await mw.intercept!(readPostsExec, ctx)).toBeDefined(); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-middleware.types.test-d.ts b/packages/3-extensions/middleware-cache/test/cache-middleware.types.test-d.ts new file mode 100644 index 000000000000..1897848e6d39 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-middleware.types.test-d.ts @@ -0,0 +1,50 @@ +import { expectTypeOf, test } from 'vitest'; +import type { + CacheMiddlewareOptions, + CacheStoreOperationMode, + CacheStrategyConfig, + GenerationBumpOn, + GenerationGuardConfig, + GenerationScope, + GenerationStrategyConfig, +} from '../src/cache-middleware'; + +test('generation strategy config exposes scope, bumpOn and guard', () => { + expectTypeOf().toEqualTypeOf<'detected-models' | 'action-models-preferred'>(); + expectTypeOf().toEqualTypeOf<'uncache' | 'all-writes'>(); + + expectTypeOf().toMatchTypeOf<{ + readonly enabled?: boolean; + readonly maxDeletesPerBump?: number; + }>(); + + expectTypeOf().toMatchTypeOf<{ + readonly scope?: GenerationScope; + readonly bumpOn?: GenerationBumpOn; + readonly guard?: GenerationGuardConfig; + }>(); +}); + +test('cache strategy config and middleware options accept generation config', () => { + expectTypeOf().toMatchTypeOf<{ + readonly mode?: 'broad' | 'targeted' | 'versioned'; + readonly generation?: GenerationStrategyConfig; + }>(); + + expectTypeOf().toEqualTypeOf<'await' | 'detached'>(); + + const options: CacheMiddlewareOptions = { + readDedupe: false, + storeOperationMode: 'detached', + cacheStrategy: { + mode: 'versioned', + generation: { + scope: 'action-models-preferred', + bumpOn: 'all-writes', + guard: { enabled: true, maxDeletesPerBump: 10 }, + }, + }, + }; + + expectTypeOf(options.cacheStrategy).toMatchTypeOf(); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-store.test.ts b/packages/3-extensions/middleware-cache/test/cache-store.test.ts index f27fbda0b04a..328be9a1b9b6 100644 --- a/packages/3-extensions/middleware-cache/test/cache-store.test.ts +++ b/packages/3-extensions/middleware-cache/test/cache-store.test.ts @@ -40,6 +40,25 @@ describe('createInMemoryCacheStore', () => { const store: CacheStore = createInMemoryCacheStore({ maxEntries: 10 }); expect(typeof store.get).toBe('function'); expect(typeof store.set).toBe('function'); + expect(typeof store.list).toBe('function'); + expect(typeof store.del).toBe('function'); + }); + + it('lists keys and supports prefix filtering', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('ns:user:1', entry([{ id: 1 }]), 60_000); + await store.set('ns:post:1', entry([{ id: 2 }]), 60_000); + await store.set('global:1', entry([{ id: 3 }]), 60_000); + + expect(await store.list!()).toEqual(['ns:user:1', 'ns:post:1', 'global:1']); + expect(await store.list!('ns:')).toEqual(['ns:user:1', 'ns:post:1']); + }); + + it('deletes a key via del()', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('k', entry([{ id: 1 }]), 60_000); + await store.del!('k'); + expect(await store.get('k')).toBeUndefined(); }); }); diff --git a/packages/3-extensions/middleware-cache/test/uncache-annotation.test.ts b/packages/3-extensions/middleware-cache/test/uncache-annotation.test.ts new file mode 100644 index 000000000000..3d066d7d76ac --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/uncache-annotation.test.ts @@ -0,0 +1,48 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + type UncacheAction, + type UncachePayload, + uncacheAnnotation, +} from '../src/uncache-annotation'; + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function planWith(annotations: Record): { readonly meta: PlanMeta } { + return { meta: { ...baseMeta, annotations } }; +} + +describe('uncacheAnnotation handle', () => { + it('declares namespace "uncache"', () => { + expect(uncacheAnnotation.namespace).toBe('uncache'); + }); + + it('declares applicableTo = ["write"]', () => { + expect(Array.from(uncacheAnnotation.applicableTo)).toEqual(['write']); + }); + + it('round-trips payload from call to read()', () => { + const payload: UncachePayload = { enabled: true, namespace: 'tenant-a' }; + const plan = planWith({ uncache: uncacheAnnotation(payload) }); + expect(uncacheAnnotation.read(plan)).toEqual(payload); + }); + + it('round-trips payload with uncache array', () => { + const uncache: readonly UncacheAction[] = [ + { namespace: 'users', keys: ['user:1'] }, + { namespace: 'posts', models: ['posts'] }, + ]; + const payload: UncachePayload = { uncache }; + const plan = planWith({ uncache: uncacheAnnotation(payload) }); + expect(uncacheAnnotation.read(plan)).toEqual(payload); + }); + + it('returns undefined when annotation is absent', () => { + expect(uncacheAnnotation.read(planWith({}))).toBeUndefined(); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/uncache-annotation.types.test-d.ts b/packages/3-extensions/middleware-cache/test/uncache-annotation.types.test-d.ts new file mode 100644 index 000000000000..997ca692da8f --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/uncache-annotation.types.test-d.ts @@ -0,0 +1,36 @@ +import type { AnnotationValue } from '@prisma-next/framework-components/runtime'; +import { expectTypeOf, test } from 'vitest'; +import { + type UncacheAction, + type UncachePayload, + uncacheAnnotation, +} from '../src/uncache-annotation'; + +test('uncacheAnnotation call signature preserves payload type', () => { + const applied = uncacheAnnotation({ enabled: true }); + expectTypeOf(applied).toEqualTypeOf>(); +}); + +test('uncacheAnnotation exposes write applicability only', () => { + expectTypeOf(uncacheAnnotation.applicableTo).toEqualTypeOf>(); +}); + +test('UncacheAction accepts namespace and keys', () => { + expectTypeOf().toMatchTypeOf<{ + readonly namespace?: string; + readonly keys?: readonly string[]; + readonly models?: readonly string[]; + }>(); +}); + +test('UncachePayload accepts uncache field', () => { + expectTypeOf().toEqualTypeOf(); +}); + +test('uncacheAnnotation rejects invalid payload fields', () => { + // @ts-expect-error invalid field + uncacheAnnotation({ foo: true }); + + // @ts-expect-error wrong type + uncacheAnnotation({ enabled: 'yes' }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03f1964a9258..06028ba61b88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2858,6 +2858,9 @@ importers: '@internal/framework-components': specifier: workspace:8.0.0-rc.7 version: link:../../1-framework/1-core/framework-components + '@prisma-next/utils': + specifier: workspace:0.14.0 + version: link:../../1-framework/0-foundation/utils devDependencies: '@internal/contract': specifier: workspace:8.0.0-rc.7 diff --git a/projects/middleware-cache-strategies/spec.md b/projects/middleware-cache-strategies/spec.md new file mode 100644 index 000000000000..fc99f98945ca --- /dev/null +++ b/projects/middleware-cache-strategies/spec.md @@ -0,0 +1,542 @@ +# Summary + +Extend `@prisma-next/middleware-cache` with pluggable invalidation strategies, mutation-driven cache invalidation via `uncacheAnnotation`, in-process miss deduplication (single-flight), tag-based bulk invalidation, and a configurable store-operation execution mode. These additions make cache invalidation a first-class concern alongside caching and remove the previous constraint that applications had to manage invalidation manually outside the middleware pipeline. + +# Description + +The initial `@prisma-next/middleware-cache` implementation (TML-2143, M1) shipped the interception hook, `cacheAnnotation`, a pluggable `CacheStore` interface, and a default in-memory LRU store. It intentionally deferred invalidation strategies beyond TTL expiry. + +Three gaps became apparent in practice: + +1. **No mutation-driven invalidation.** After a write, stale cache entries continued to be served until their TTL elapsed. Applications had to call `middleware.uncache(...)` out-of-band and keep this in sync with their mutation paths — a maintenance burden that grows proportionally with the number of write paths. + +2. **No per-query deduplication.** Under concurrent load, a cache miss for a popular key triggered N parallel database executions, one per concurrent request. Without a single-flight mechanism, the cache provided no protection against thundering-herd during the window between a miss detection and a `set` completing. + +3. **No bulk / tag-based invalidation.** Fine-grained invalidation required callers to enumerate explicit keys. Invalidating "everything related to a user" meant the caller had to know all key patterns in advance. + +This project adds all three, plus the `storeOperationMode` knob that lets latency-sensitive deployments run store I/O in the background. + +# Before / After + +## Mutation-driven invalidation + +**Before** — write completes, cache is stale until TTL; caller must remember to invalidate manually: + +```typescript +await db.orm.User.update({ id: 1, name: 'New' }); +// caller must now call middleware.uncache([{ namespace: 'app' }]) out-of-band +``` + +**After** — invalidation intent travels with the mutation via `uncacheAnnotation`: + +```typescript +import { uncacheAnnotation } from '@prisma-next/middleware-cache'; + +await db.orm.User.update( + { id: 1, name: 'New' }, + (meta) => meta.annotate(uncacheAnnotation({ + uncache: [{ namespace: 'app' }, { keys: ['user:1'] }], + })), +); +// after the write commits, the cache middleware deletes the listed entries +``` + +## Invalidation strategies + +**Before** — no configurable strategy; callers had to pass explicit keys to `middleware.uncache(...)`: + +```typescript +const cacheMiddleware = createCacheMiddleware({ maxEntries: 1_000 }); +``` + +**After** — three modes selectable via `cacheStrategy.mode`: + +```typescript +// broad: on any write, delete all keys that match the namespace prefix +const cacheMiddleware = createCacheMiddleware({ + cacheStrategy: { mode: 'broad' }, + uncacheOnMutation: true, +}); + +// targeted (default): track entity selectors on reads and writes; +// only invalidate keys that touched the same rows +const cacheMiddleware = createCacheMiddleware({ + cacheStrategy: { mode: 'targeted' }, + uncacheOnMutation: true, +}); + +// versioned: bump a per-model generation counter on writes; +// reads embed the current generation in their cache key — stale keys expire via TTL +const cacheMiddleware = createCacheMiddleware({ + cacheStrategy: { + mode: 'versioned', + generation: { + scope: 'detected-models', + bumpOn: 'uncache', + guard: { enabled: true, maxDeletesPerBump: 500 }, + }, + }, + uncacheOnMutation: true, +}); +``` + +## In-process miss deduplication + +**Before** — N concurrent misses for the same key each hit the database: + +```typescript +// 100 concurrent requests all miss → 100 database queries fired in parallel +``` + +**After** — one leader executes; followers wait for its result: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + readDedupe: true, // global toggle + defaultTtlMs: 60_000, +}); + +// per-query override +const user = await db.orm.User.first( + { id }, + (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000, dedupe: true })), +); +``` + +## Tag-based bulk invalidation + +**Before** — no concept of grouping entries for bulk invalidation: + +```typescript +// must enumerate specific keys +await middleware.uncache([{ keys: ['user:1', 'user:2', 'user:3'] }]); +``` + +**After** — tag entries at cache time, bulk-invalidate by tag: + +```typescript +// tag at cache time +const users = await db.orm.User.all( + (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000, tags: ['users'] })), +); + +// invalidate all entries bearing the 'users' tag +await middleware.uncache([{ tags: ['users'] }]); + +// or via mutation annotation +await db.orm.User.create( + { email: 'a@b.com' }, + (meta) => meta.annotate(uncacheAnnotation({ uncache: [{ tags: ['users'] }] })), +); +``` + +## Detached store operations + +**Before** — every store `set` / `del` was awaited synchronously on the response path. + +**After** — opt-in background mode for latency-sensitive deployments: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + storeOperationMode: 'detached', // store writes/deletes fire in the background +}); +``` + +## Standalone uncache helper + +```typescript +import { uncache, uncacheAnnotation } from '@prisma-next/middleware-cache'; + +// free-function helper — avoids importing the middleware instance everywhere +await uncache(cacheMiddleware, [ + { namespace: 'app' }, + { tags: ['users', 'posts'] }, +]); +``` + +# Requirements + +## Functional Requirements + +### `uncacheAnnotation` (write terminal annotation) + +1. **Handle declaration.** `uncacheAnnotation = defineAnnotation()({ namespace: 'uncache', applicableTo: ['write'] })`. Structurally impossible to apply to a read terminal — the applicability gate (`ValidAnnotations<'read', ...>`) rejects it at both type and runtime levels. + +2. **`UncachePayload` shape.** + ```typescript + interface UncacheAction { + readonly namespace?: string; + readonly keys?: readonly string[]; + readonly models?: readonly string[]; + readonly tags?: readonly string[]; + } + interface UncachePayload { + readonly enabled?: boolean; + readonly skip?: boolean; + readonly namespace?: string; + readonly uncache?: readonly UncacheAction[]; + } + ``` + - `namespace` (shorthand): invalidates all keys matching that prefix when `uncache` is omitted. + - `uncache`: explicit action list. Each action is executed in order. Takes precedence over `namespace`. + - `enabled` / `skip`: opt-out toggles. When `skip: true` or `enabled: false`, the middleware bypasses invalidation for this execution. + +3. **Execution timing.** Invalidation runs inside `afterExecute`, after `completed: true` is confirmed. A failed write (rolled-back transaction, constraint violation) does not invalidate anything. + +4. **Action resolution.** `UncacheAction` supports four orthogonal selectors, each independently optional: + - `keys`: direct key list (prefixed with `namespace:` if `namespace` is set). + - `models`: model index lookup — keys previously tagged against these model names. + - `tags`: delegated to `CacheStore.delByTag`. + - `namespace` (on action): invalidates all keys with this prefix via `CacheStore.list` + `CacheStore.del`. + +### Invalidation strategy modes (`CacheStrategyMode`) + +5. **Three modes.** + - `'broad'`: on any write touching the monitored namespace, delete all live keys matching the global namespace prefix. Requires `CacheStore.list` and `CacheStore.del`. + - `'targeted'` (default): track entity selectors (model + table + id columns) at read time; at write time, intersect the write's entity selectors with the read selectors' index and delete only matching keys. Requires `CacheStore.list` and `CacheStore.del`. + - `'versioned'`: maintain a per-model generation counter via `CacheStore.incr`. Reads embed the current generation value in the cache key. Writes bump the counter; old keys become unreachable and expire via TTL. Does not require `del` at write time. + +6. **`CacheStrategyConfig` shape.** + ```typescript + type CacheStrategyMode = 'broad' | 'targeted' | 'versioned'; + type GenerationScope = 'detected-models' | 'action-models-preferred'; + type GenerationBumpOn = 'uncache' | 'all-writes'; + interface GenerationGuardConfig { + readonly enabled?: boolean; + readonly maxDeletesPerBump?: number; + } + interface GenerationStrategyConfig { + readonly scope?: GenerationScope; + readonly bumpOn?: GenerationBumpOn; + readonly guard?: GenerationGuardConfig; + } + interface CacheStrategyConfig { + readonly mode?: CacheStrategyMode; + readonly generation?: GenerationStrategyConfig; + } + ``` + +7. **Default mode.** When `cacheStrategy` is omitted or `mode` is `undefined`, the middleware defaults to `'targeted'`. + +### In-process miss deduplication (`dedupe` / `readDedupe`) + +8. **Single-flight deduplication.** When deduplication is active for a given effective key, only the first concurrent miss (the leader) executes against the database. All followers for the same key in the same process return a `Promise` that resolves from the leader's result. The leader writes to the store; followers receive the result without a second `set`. + +9. **Activation.** Global: `createCacheMiddleware({ readDedupe: true })`. Per-query override: `cacheAnnotation({ dedupe: true | false })`. A per-query `dedupe: false` overrides `readDedupe: true` and vice versa. + +10. **Correlation key.** Deduplication uses the same effective cache key as lookup (per-query `cacheAnnotation({ key })` override or `ctx.contentHash(exec)`). Two executions with identical keys in the same process share one leader. + +11. **Failure propagation.** If the leader throws, all followers reject with the same error. The key is removed from the in-flight map so a subsequent request for the same key starts a fresh leader. + +### Tag-based invalidation + +12. **`CachedEntry.tags` field.** `readonly tags?: readonly string[] | undefined` on `CachedEntry`. Tags are stored alongside the entry; the store is responsible for preserving them. + +13. **`CacheStore.delByTag` method.** Optional method `delByTag?(tags: readonly string[]): Promise` on `CacheStore`. Implementations remove all entries bearing any of the given tags. The in-memory store supports `delByTag`. + +14. **Annotation wire-up.** Per-query tags supplied via `cacheAnnotation({ tags: [...] })` are stored in `CachedEntry.tags` when the entry is committed. + +15. **`uncacheAnnotation` tag action.** An `UncacheAction` with `tags` set invokes `store.delByTag(action.tags)`. Requires `CacheStore.delByTag`; the middleware logs a warning if the method is absent and tags are requested. + +### `storeOperationMode` + +16. **Two modes.** `'await'` (default): store writes and deletes are awaited on the execution path — failure surfaces as a thrown error. `'detached'`: store writes and deletes are submitted as untracked microtask callbacks; errors are suppressed (best-effort). Response latency is not inflated by store I/O in `'detached'` mode. + +17. **Consistency trade-off.** `'detached'` mode provides eventual consistency: there is a window after the database result is returned to the caller but before the cache is updated. Applications that require strict read-your-writes consistency must use `'await'`. + +### `CacheStore` interface additions + +18. **`CacheStore.list`** (optional): `list?(prefix?: string): Promise`. Returns all live keys, optionally filtered by prefix. Required by `'broad'` and `'targeted'` strategies and by namespace-scope `uncacheAnnotation` actions. + +19. **`CacheStore.del`** (optional): `del?(key: string): Promise`. Required by `'broad'`, `'targeted'`, and key-scope `uncacheAnnotation` actions. + +20. **`CacheStore.delByTag`** (optional): `delByTag?(tags: readonly string[]): Promise`. Required by tag-scope `uncacheAnnotation` actions. + +21. **`CacheStore.incr`** (optional): `incr?(key: string, delta?: number): Promise`. Required by `'versioned'` strategy (generation counter bumps). In clustered setups the store implementation must make `incr` atomic (e.g. Redis `INCR`). + +22. **In-memory store.** `createInMemoryCacheStore` implements all five methods (`get`, `set`, `list`, `del`, `delByTag`). It does not implement `incr` (atomic increment is only meaningful in multi-process / Redis deployments; in-memory generation mode can be achieved without a store-level counter since the process has direct access to the generation map). + +### `CacheMiddleware` type and `uncache` method + +23. **`CacheMiddleware` type.** `createCacheMiddleware` returns `CacheMiddleware`, which extends `CrossFamilyMiddleware` with a standalone `uncache` method: + ```typescript + type CacheMiddleware = CrossFamilyMiddleware & { + readonly uncache: (actions: readonly UncacheAction[]) => Promise; + }; + ``` + +24. **Standalone `uncache` free-function.** Exported helper: + ```typescript + function uncache( + middleware: Pick, + actions: readonly UncacheAction[], + ): Promise + ``` + Allows callers to invalidate entries without importing the full middleware type. + +### Global options added to `CacheMiddlewareOptions` + +25. **`readCaching?: boolean`** — when `true`, all read executions are cached even without `cacheAnnotation`, using `defaultTtlMs` as the TTL. + +26. **`readDedupe?: boolean`** — when `true`, enables single-flight deduplication globally for all read misses. + +27. **`defaultTtlMs?: number`** — default TTL in milliseconds, used when `readCaching` is `true` and no per-query `ttl` is set. + +28. **`namespace?: string`** — global cache namespace prefix applied to all keys and used as the default scope for namespace-based invalidation. + +29. **`uncacheOnMutation?: boolean`** — when `true`, every write execution that passes through the middleware triggers the configured strategy's invalidation logic, even without an explicit `uncacheAnnotation`. + +30. **`storeOperationMode?: CacheStoreOperationMode`** — `'await'` (default) or `'detached'`. + +31. **`cacheStrategy?: CacheStrategyConfig`** — strategy selector and generation sub-config. + +## Non-Functional Requirements + +1. **Additive interface changes.** All additions to `CacheStore` are optional methods. Existing store implementations continue to compile and function without modification; absent optional methods are guarded before invocation. + +2. **No new framework-components SPI changes.** This project builds entirely within `@prisma-next/middleware-cache`. It does not modify `RuntimeMiddleware`, `RuntimeMiddlewareContext`, or `runWithMiddleware`. + +3. **Type safety.** No `any`, no `@ts-expect-error` outside negative type tests. `uncacheAnnotation`'s write-only applicability is enforced at both type level (via `ValidAnnotations<'write', ...>`) and runtime level (via `assertAnnotationsApplicable`). + +4. **Store interface is I/O-agnostic.** All `CacheStore` methods are async, leaving the door open for Redis, Memcached, or any I/O-backed backend. + +## Non-goals + +- **Cross-process in-memory generation counters.** Generation mode with the default in-memory store is single-process only. Clustered setups must supply a Redis-backed `CacheStore` with a proper atomic `incr`. +- **Automatic index re-build on startup.** The middleware starts with an empty in-flight map and an empty internal entity index on each process start; there is no warm-up or persistence beyond the `CacheStore`. +- **Annotation-driven read-through warming.** Pre-populating the cache ahead of the first miss is not in scope. +- **Cache introspection API.** No method to enumerate live keys from the middleware; callers use the `CacheStore` directly for that purpose. +- **`beforeCompile` AST-rewrite-based caching.** The cache middleware operates at the `intercept` / `afterExecute` level only. + +# Acceptance Criteria + +## `uncacheAnnotation` + +- [ ] `uncacheAnnotation` is declared with `namespace: 'uncache'` and `applicableTo: ['write']`. +- [ ] Passing `uncacheAnnotation(...)` to a read terminal fails at the type level (type test). +- [ ] `uncacheAnnotation.read(plan)` round-trips the full `UncachePayload` including nested `UncacheAction[]` (unit test). +- [ ] Invalidation fires in `afterExecute` only when `completed: true`; a failed write does not delete cache entries (unit test). +- [ ] `skip: true` or `enabled: false` on the payload suppresses invalidation for that execution (unit test). + +## Invalidation strategies + +- [ ] `CacheStrategyMode` is `'broad' | 'targeted' | 'versioned'` (type test). +- [ ] Default mode when `cacheStrategy` is omitted is `'targeted'` (unit test). +- [ ] `'broad'` mode: a write causes all keys matching the namespace prefix to be deleted via `CacheStore.list` + `CacheStore.del` (unit test). +- [ ] `'targeted'` mode: a write only deletes keys whose recorded entity selectors overlap with the write's touched models/rows (unit test). +- [ ] `'versioned'` mode: a write bumps the generation counter via `CacheStore.incr`; subsequent reads with the old generation key miss and re-execute (unit test). +- [ ] `GenerationStrategyConfig` fields (`scope`, `bumpOn`, `guard`) are typed correctly and accepted by `CacheMiddlewareOptions` (type test). + +## In-process miss deduplication + +- [ ] `readDedupe: true` causes concurrent misses for the same key to share one database execution (unit test using a spy on the driver). +- [ ] `cacheAnnotation({ dedupe: false })` overrides `readDedupe: true` per-query (unit test). +- [ ] If the leader throws, all followers reject with the same error; the in-flight record is removed (unit test). + +## Tag-based invalidation + +- [ ] `CachedEntry.tags` is preserved by `createInMemoryCacheStore` (unit test). +- [ ] `createInMemoryCacheStore` implements `delByTag`: calling it removes all entries with a matching tag and leaves other entries intact (unit test). +- [ ] `uncache([{ tags: ['users'] }])` triggers `store.delByTag(['users'])` (unit test with spy store). +- [ ] `cacheAnnotation({ tags: ['users'] })` causes the committed `CachedEntry` to carry `tags: ['users']` (unit test). + +## `storeOperationMode` + +- [ ] `'await'` mode: store `set` errors surface to the caller (unit test). +- [ ] `'detached'` mode: store `set` errors are suppressed; the execution result is returned normally (unit test). +- [ ] `CacheStoreOperationMode` is `'await' | 'detached'` (type test). + +## `CacheMiddleware` type and `uncache` + +- [ ] `createCacheMiddleware` return type is `CacheMiddleware` (extends `CrossFamilyMiddleware` + `uncache` method) (type test). +- [ ] `middleware.uncache([{ namespace: 'app' }])` deletes all keys with prefix `'app:'` (unit test with spy store). +- [ ] Exported `uncache(middleware, actions)` delegates to `middleware.uncache(actions)` (unit test). + +## Global `CacheMiddlewareOptions` additions + +- [ ] `readCaching: true` + `defaultTtlMs` caches all reads without requiring `cacheAnnotation` per query (unit test). +- [ ] `uncacheOnMutation: true` triggers the configured strategy's invalidation on every write passing through the middleware (unit test). +- [ ] All new option fields (`readCaching`, `readDedupe`, `defaultTtlMs`, `namespace`, `uncacheOnMutation`, `storeOperationMode`, `cacheStrategy`) are accepted by `CacheMiddlewareOptions` without TypeScript errors (type test). + +## `CacheStore` interface additions + +- [ ] `CacheStore` compiles with only `get` and `set` — all other methods are optional (type test: existing two-method stores satisfy the interface). +- [ ] Calling `middleware.uncache([{ tags: [...] }])` against a store without `delByTag` logs a warning and does not throw (unit test). +- [ ] `createInMemoryCacheStore` satisfies the full `CacheStore` interface including `list`, `del`, and `delByTag` (type test + unit test). + +--- + +# Part 2 — Multi-store support and per-namespace configuration + +## Before / After + +### Multiple named stores + +**Before** — single store; all executions share one backend: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + store: redisStore, + defaultTtlMs: 60_000, +}); +``` + +**After** — register named stores alongside the default; route by namespace or per-query annotation: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + store: redisStore, // default store + stores: { + hot: memoryStore, // named store for hot-path data + cold: s3Store, // named store for infrequent, long-lived data + }, + defaultTtlMs: 60_000, +}); + +// annotation-level routing +const leaderboard = await db.orm.Score.all( + (meta) => meta.annotate(cacheAnnotation({ ttl: 5_000, store: 'hot' })), +); + +// namespace-level routing (see below) +``` + +### Per-namespace configuration overrides + +**Before** — single global settings; no way to give one namespace a shorter TTL, a different strategy, or a different store: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + readCaching: true, + defaultTtlMs: 60_000, + cacheStrategy: { mode: 'versioned' }, +}); +``` + +**After** — `namespaces` map lets each namespace override any global option: + +```typescript +const cacheMiddleware = createCacheMiddleware({ + store: redisStore, + stores: { hot: memoryStore }, + readCaching: true, + defaultTtlMs: 60_000, + cacheStrategy: { mode: 'versioned' }, + namespaces: { + 'realtime:*': { + store: 'hot', + defaultTtlMs: 2_000, + cacheStrategy: { mode: 'broad' }, + }, + 'archive:*': { + store: 'cold', + defaultTtlMs: 86_400_000, + }, + '/^tenant:.{36}$/': { + uncacheOnMutation: true, + storeOperationMode: 'detached', + }, + }, +}); +``` + +## Requirements + +### Named store registry + +32. **`CacheMiddlewareOptions.stores?: Record`** — optional map of named stores. Keys are arbitrary identifiers used in `NamespaceConfig.store` and `cacheAnnotation({ store })`. When a name cannot be resolved, the middleware silently falls back to the default store. + +33. **Default store behaviour unchanged.** When `stores` is absent or a named store is not found, all executions use the default store (`options.store` or the built-in in-memory LRU). Existing single-store configurations require no changes. + +34. **Independent per-store state.** Each named store gets its own `modelKeyIndex`, `entityKeyIndex`, `modelGenerations`, and `inflightMisses`. Operations on one store do not affect the indexes of another. + +### Annotation-level store selection + +35. **`CachePayload.store?: string`** — optional named store identifier added to `CachePayload`. When set, the execution reads from and writes to the named store, bypassing any namespace-level store assignment. + +36. **Resolution priority (store).** `cacheAnnotation({ store })` > `NamespaceConfig.store` > default store. + +### `NamespacePattern` and `NamespaceConfig` + +37. **`NamespacePattern` type.** `type NamespacePattern = string`. Three pattern syntaxes: + - **Exact**: `"tenant-a"` — only matches the namespace string `"tenant-a"`. + - **Glob**: `"organization:*"` — `*` is a wildcard that matches any sequence of characters (including empty). + - **RegExp**: `"/pattern/"` — a string that starts and ends with `/` is treated as a regular expression. The inner content is passed to `new RegExp(inner).test(namespace)`. + +38. **`NamespaceConfig` interface.** All fields are optional: + ```typescript + interface NamespaceConfig { + readonly store?: string; + readonly readCaching?: boolean; + readonly readDedupe?: boolean; + readonly defaultTtlMs?: number; + readonly uncacheOnMutation?: boolean; + readonly storeOperationMode?: CacheStoreOperationMode; + readonly cacheStrategy?: CacheStrategyConfig; + } + ``` + +39. **Lookup algorithm.** Given an effective namespace: + 1. If the exact namespace string is a key in `options.namespaces`, use that entry. + 2. Otherwise, sort remaining keys by length (longest first) and return the first whose pattern matches the namespace. + 3. If no pattern matches, return `undefined` (no namespace config; fall back to global options). + +40. **Override semantics.** Each field in the matching `NamespaceConfig` overrides the corresponding global `CacheMiddlewareOptions` field for that execution. Absent fields fall through to the global option. Annotation-level fields (`cacheAnnotation({ ttl, dedupe, store, ... })`) take precedence over both namespace config and global options. + +41. **`NamespacePattern` and `NamespaceConfig` exports.** Both are exported from `@prisma-next/middleware-cache`. + +### Resolution priority (full precedence table) + +| Setting | Highest priority → Lowest priority | +|---|---| +| `store` | `cacheAnnotation.store` → `NamespaceConfig.store` → default store | +| `ttlMs` | `cacheAnnotation.ttl` → `NamespaceConfig.defaultTtlMs` → `CacheMiddlewareOptions.defaultTtlMs` | +| `dedupe` | `cacheAnnotation.dedupe` → `NamespaceConfig.readDedupe` → `CacheMiddlewareOptions.readDedupe` | +| `readCaching` | `NamespaceConfig.readCaching` → `CacheMiddlewareOptions.readCaching` | +| `uncacheOnMutation` | `NamespaceConfig.uncacheOnMutation` → `CacheMiddlewareOptions.uncacheOnMutation` | +| `storeOperationMode` | `NamespaceConfig.storeOperationMode` → `CacheMiddlewareOptions.storeOperationMode` | +| `cacheStrategy` | `NamespaceConfig.cacheStrategy` → `CacheMiddlewareOptions.cacheStrategy` | + +### Per-action store resolution during write invalidation + +42. When an `UncacheAction` has a `namespace`, the middleware resolves the `NamespaceConfig` for that action's namespace (not the write execution's namespace). This means a single write can invalidate entries across different named stores by using multiple actions with different namespaces. + +## Non-goals + +- **Namespace config inheritance / merging across multiple matching patterns.** Only the single winning pattern's config is applied; multiple matching patterns are not merged. +- **Cross-store invalidation triggers.** A write annotated for store `"hot"` does not automatically invalidate entries in the default store or in `"cold"`. Per-store invalidation requires separate `UncacheAction` entries. +- **Dynamic namespace config updates at runtime.** `namespaces` is fixed at `createCacheMiddleware` call time. + +## Acceptance Criteria (Part 2) + +### Named store registry + +- [ ] `CacheMiddlewareOptions.stores` accepts `Record` without TypeScript errors (type test). +- [ ] An execution with `cacheAnnotation({ store: 'hot' })` reads from and writes to the `'hot'` store, not the default (unit test with two spy stores). +- [ ] When `store: 'unknown'` is annotated, the middleware falls back to the default store without throwing (unit test). +- [ ] Each named store has its own independent `modelKeyIndex`; invalidating model `User` in store `'hot'` does not remove entries from the default store (unit test). + +### Annotation-level store selection + +- [ ] `CachePayload.store?: string` is present in the type definition (type test). +- [ ] `cacheAnnotation({ store: 'hot' })` round-trips `store: 'hot'` through `cacheAnnotation.read(plan)` (unit test). +- [ ] Annotation `store` takes precedence over a namespace config `store` for the same namespace (unit test). + +### `NamespaceConfig` and `NamespacePattern` + +- [ ] `NamespacePattern` and `NamespaceConfig` are exported from `@prisma-next/middleware-cache` (type test). +- [ ] Exact pattern `"tenant-a"` matches namespace `"tenant-a"` and does not match `"tenant-b"` (unit test). +- [ ] Glob pattern `"organization:*"` matches `"organization:acme"` and `"organization:"` but not `"tenant:acme"` (unit test). +- [ ] RegExp pattern `"/^tenant:.{36}$/"` matches a UUID-keyed tenant namespace and rejects shorter strings (unit test). +- [ ] Longer patterns are preferred over shorter patterns when multiple glob/regex patterns match (unit test). +- [ ] Exact match always wins over a glob or regex of equal or shorter length (unit test). +- [ ] `NamespaceConfig.defaultTtlMs` overrides `CacheMiddlewareOptions.defaultTtlMs` for matching executions (unit test). +- [ ] `NamespaceConfig.store` routes matching executions to the named store (unit test). +- [ ] `NamespaceConfig.readCaching: true` enables read caching for matching namespaces even when the global option is `false` (unit test). +- [ ] `NamespaceConfig.storeOperationMode: 'detached'` is used for matching executions even when the global mode is `'await'` (unit test). +- [ ] `NamespaceConfig.cacheStrategy.mode` overrides the global strategy for matching namespaces (unit test). + +### Per-action store routing during write invalidation + +- [ ] A write with two `UncacheAction` entries bearing different namespaces invalidates entries in two different named stores (unit test). +