diff --git a/architecture.config.json b/architecture.config.json index bd56395967..d36c291939 100644 --- a/architecture.config.json +++ b/architecture.config.json @@ -186,6 +186,12 @@ "layer": "integrations", "plane": "runtime" }, + { + "glob": "packages/3-extensions/middleware-cache/**", + "domain": "extensions", + "layer": "integrations", + "plane": "runtime" + }, { "glob": "packages/3-extensions/sql-orm-client/**", "domain": "extensions", diff --git a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md index 05bf57184b..fafa923997 100644 --- a/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md +++ b/docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md @@ -231,7 +231,56 @@ export interface InterceptResult { **Plan-identity invariant.** Interceptors that need per-execution scratch space (the cache middleware's miss buffer, request-coalescing pending sets, etc.) commonly key a private `WeakMap` on the post-lowering plan object. This relies on each call producing a fresh `exec` identity. Both family runtimes satisfy this today: SQL `executeAgainstQueryable` constructs a fresh `Object.freeze({...lowered, params: ...})` on every call; Mongo lowers fresh per call. If a future plan-memoization change ever recycles `exec` objects across calls, these middleware would silently leak rows between concurrent executions — which is what the cache middleware's concurrency regression test catches. See [ADR 025 — Plan caching & memoization](../adrs/ADR%20025%20-%20Plan%20Caching%20Memoization.md). -**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. A first-party cache middleware exercising this hook against both SQL and Mongo runtimes is planned as a follow-up. +**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. The first-party cache middleware ([`@prisma-next/middleware-cache`](../../../packages/3-extensions/middleware-cache/)) is the canonical interceptor example and works against both SQL and Mongo runtimes day one — see its README for the full design (opt-in via annotations, transaction-scope guard, pluggable `CacheStore`, TTL/LRU semantics). + +## Annotations + +Annotations are a typed, namespaced way to attach per-query metadata that middleware can read. They are the opt-in mechanism the cache middleware uses ("cache this read for 60 seconds"), the audit middleware would use ("attribute this write to this actor"), and any future per-query policy hook will reuse. + +The framework provides three pieces: + +- `OperationKind = 'read' | 'write'` — the binary discriminator that gates which terminals an annotation can attach to. Finer-grained kinds (`'select' | 'insert' | …`) are deferred; widening is additive. +- `defineAnnotation()({ namespace, applicableTo })` — curried two-step factory. The first call takes `Payload` explicitly; the second takes the runtime options and infers `Kinds` from `applicableTo` via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated as a type argument. The returned handle is a **callable function**: `handle(value)` produces an `AnnotationValue`. The function also carries `namespace`, `applicableTo: ReadonlySet` (frozen), and `read(plan)` as properties. Both `applicableTo` (off the handle and off each value) feed the type-level and runtime applicability gates. +- `ValidAnnotations` — mapped tuple type consumed by lane terminals. Resolves any tuple element whose declared `Kinds` does not include the terminal's `K` to `never`, surfacing the mismatch as a type error at the call site. + +```typescript +// packages/1-framework/1-core/framework-components/src/annotations.ts +import { defineAnnotation } from '@prisma-next/framework-components/runtime' + +// Read-only annotation. Kinds inferred as 'read'. +export const cacheAnnotation = defineAnnotation<{ ttl?: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}) + +// Write-only annotation (illustrative). Kinds inferred as 'write'. +export const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}) + +// Applicable to both kinds (e.g. tracing). Kinds inferred as 'read' | 'write'. +export const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}) +``` + +**Lane integration.** SQL DSL and ORM `Collection` adopt different surface shapes appropriate to their idioms; both gate by the terminal's operation kind `K`: + +- SQL DSL — `.annotate(...)` is a variadic chainable method on every builder kind: `SelectQueryImpl` / `GroupedQueryImpl` accept `'read'`-applicable annotations; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` accept `'write'`-applicable. The variadic parameter is constrained by `As & ValidAnnotations`. The intersection is load-bearing — TypeScript's variadic-tuple inference is too forgiving with `ValidAnnotations` alone, but the intersection pins `As` to the call-site tuple AND requires assignability to the gated form. +- ORM `Collection` and `GroupedCollection` — every terminal accepts an optional `configure: (meta: MetaBuilder) => void` callback as its last argument. The user calls `meta.annotate(annotation)` (chainable, returns the builder) once per annotation; the conditional `K extends Kinds ? AnnotationValue : never` parameter type rejects inapplicable annotations at the call site. There is no chainable `Collection.annotate()` — annotations attach via the configurator only. The callback shape (rather than the variadic shape SQL DSL uses) is deliberate at the ORM layer: a terminal's last argument grows over time (future per-call options), and a callback configurator is extension-friendly where a variadic forecloses on additional arguments. The callback also drops the load-bearing `As & ValidAnnotations` intersection — taking one annotation at a time avoids variadic-tuple inference entirely. + +**Runtime applicability check.** SQL DSL `.build()` and the ORM `MetaBuilder.annotate(...)` both call `assertAnnotationsApplicable(...)` against the annotation(s) and the terminal's kind. The helper throws `RUNTIME.ANNOTATION_INAPPLICABLE` naming the namespace and terminal on any annotation whose `applicableTo` set lacks `kind`. This is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. ORM terminals construct the meta builder via `createMetaBuilder(kind, terminalName)` and let the builder's `annotate` perform the gate eagerly; lane code does not re-validate after the user callback returns. + +**Storage.** Applied annotations land under `plan.meta.annotations[namespace]` as branded `AnnotationValue` objects. Multiple `.annotate()` chain calls (SQL DSL) or `meta.annotate(...)` calls inside the configurator (ORM) compose; duplicate namespaces use last-write-wins. Reading via `handle.read(plan)` defensively checks the `__annotation` brand so framework-internal metadata (e.g. `meta.annotations.codecs` written by the SQL emitter) is not mistaken for a user annotation. + +**Reserved namespaces.** `codecs` is consumed by the SQL emitter (`meta.annotations.codecs[alias] = 'pg/text@1'`) and read by the SQL runtime's `decodeRow`; it must not be used by user handles. Target-specific keys such as `pg` are similarly reserved. `defineAnnotation` does not structurally prevent a user from naming a reserved namespace — handles are namespaced strings, not branded types — but the framework makes no compatibility guarantees about handles that do. + +**Runtime context: `contentHash` and `scope`.** Two `RuntimeMiddlewareContext` fields support the annotation-driven middleware ecosystem: + +- `contentHash(exec): Promise` — the family runtime returns an opaque, bounded digest identifying the `(storage, statement, params)` tuple of an execution. SQL composes `meta.storageHash + '|' + exec.sql + '|' + canonicalStringify(exec.params)` and pipes through `hashContent` (SHA-512); Mongo composes `meta.storageHash + '|' + canonicalStringify(exec.command)` and pipes the same way. Two semantically equivalent executions return the same digest. Used by middleware that need per-execution identity (caching, request coalescing). The cache middleware uses the returned string directly as a `Map` key — it is not (and should not be) further hashed by callers. +- `scope: 'runtime' | 'connection' | 'transaction'` — discriminates the queryable surface the execution is running under. Top-level `runtime.execute` populates `'runtime'`; `connection.execute` and `transaction.execute` derive a context with `'connection'` / `'transaction'`. Middleware that should only act at the top level (e.g. the cache middleware) read this field to bypass non-runtime scopes. ## ORM Client Integration @@ -476,6 +525,7 @@ export interface RuntimeMiddleware { - `intercept` is optional and lets a middleware short-circuit execution and supply rows directly. See "Intercepting Execution" above. - `beforeExecute` runs on the driver path only and is where verification and guardrails live. On the intercepted hit path it is skipped. - `onRow` is optional and called for each streamed row on the driver path; intercepted rows skip `onRow`. `afterExecute` receives aggregates including the `completed` flag (success vs error path) and the `source` field (`'driver'` vs `'middleware'`). +- `RuntimeMiddlewareContext` carries `contentHash(exec)` (a bounded opaque digest of the execution identity) and `scope` (`'runtime' | 'connection' | 'transaction'`) for middleware that need per-execution identity or scope-aware behavior. ### Family-Specific Middleware Interfaces @@ -537,7 +587,7 @@ export const softDelete: SqlMiddleware = { **Raw SQL lanes.** `beforeCompile` only runs when the lane produces a `SqlQueryPlan` (AST present). Raw SQL plans — which arrive as fully-lowered `SqlExecutionPlan` — bypass the hook entirely (the SQL runtime's `executeAgainstQueryable` branches on plan shape). -**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above) — it lives on `RuntimeMiddleware` rather than in the SQL `beforeCompile` chain. User-authored query annotations are deferred to later milestones; their API is not yet defined. +**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above); user-authored query annotations are provided by the framework `defineAnnotation` helper plus lane-side `.annotate(...)` (see "Annotations" above) — both are family-agnostic and live on `RuntimeMiddleware` / lane terminals rather than in the SQL `beforeCompile` chain. ## Guardrails via Middleware @@ -615,6 +665,10 @@ Tests ensure the middleware contract remains stable, plan identity is preserved, - `runWithMiddleware` unit tests (registration order, error path with `completed: false`, error swallowing in `afterExecute` during the error path, zero-middleware passthrough, and the full `intercept` matrix: first-wins semantics, hit path skipping `beforeExecute` / `runDriver` / `onRow`, hit + `source: 'middleware'` on `afterExecute`, miss-path zero-change regression, mixed chains, interceptor-throw paths, and the `Iterable` / `AsyncIterable` row-source variants). - `RuntimeCore` mock-family tests demonstrate that a minimal subclass with concrete `lower` / `runDriver` / `close` (and nothing else) typechecks, constructs, and exercises the lifecycle. - Cross-family proof: the same generic middleware observes queries from both SQL and Mongo runtimes, **and** the same generic interceptor short-circuits queries from both runtimes via inherited `runWithMiddleware` (preserved and extended from `cross-family-middleware-spi`). +- Annotations: unit + type tests for `defineAnnotation`, `ValidAnnotations` (positive + negative on both kinds), `assertAnnotationsApplicable` (cast-bypass coverage), and lane-side `.annotate(...)` on every SQL DSL builder kind plus every ORM terminal. +- Family `contentHash` implementations: SQL and Mongo unit tests cover stability across object-key order, discrimination on `storageHash` and params/command, fixed-size opaque output (`sha512:HEXDIGEST`), and round-trip determinism. +- Cache middleware: package unit tests (opt-in semantics, hit/miss paths, scope guard, key composition incl. user-supplied `key` override and Mongo-style mock parity, store mechanics — LRU + TTL with injected clock) plus integration tests against real Postgres covering the April stop condition (driver invocation count = 1 on a repeated annotated read), composition with a `beforeCompile` rewriter (cache key reflects rewritten SQL), composition with telemetry (`source` round-trip), and concurrency (parallel executes do not cross-talk via the per-exec `WeakMap` buffer). +- SQL runtime scope plumbing: `connection.execute` and `transaction.execute` populate `ctx.scope` correctly; round-trips through `runtime → connection → transaction → runtime` route to the right scope each time. - Golden SQL and hash stability tests to ensure Plan immutability and identity do not drift. - Violation matrix ensuring built-in rules behave consistently under `strict` and `permissive` modes. - Benchmarks to validate overhead budgets. diff --git a/examples/prisma-next-demo/README.md b/examples/prisma-next-demo/README.md index e50ad0a5d3..14b3dd27d5 100644 --- a/examples/prisma-next-demo/README.md +++ b/examples/prisma-next-demo/README.md @@ -106,6 +106,8 @@ The demo includes ORM client examples under `src/orm-client/`: - `ormClientGetUserInsights(limit, runtime)` — `include().combine()` metrics and latest related row - `ormClientGetUserKindBreakdown(minUsers, runtime)` — `groupBy().having().aggregate()` breakdown - `ormClientUpsertUser(data, runtime)` — `upsert()` for create-or-update by primary key +- `ormClientFindUserByIdCached(id, runtime, options?)` — opt-in cached `first({ id })` lookup via `cacheAnnotation({ ttl })` from `@prisma-next/middleware-cache` +- `ormClientGetUsersCached(limit, runtime, options?)` — opt-in cached `User.all()` listing, with optional explicit cache-key override Run from the CLI: @@ -123,6 +125,45 @@ pnpm start -- repo-kind-breakdown 1 pnpm start -- repo-upsert-user 00000000-0000-0000-0000-000000000099 demo@example.com user ``` +## Cache Middleware Examples + +The demo wires `@prisma-next/middleware-cache` into the Postgres client in `src/prisma/db.ts`. The cache middleware is **opt-in per query** — it only acts on plans whose `meta.annotations` carry a `cacheAnnotation` payload with a `ttl` set. Three CLI commands run a query twice and report the latency of each call so the cache hit is visible: + +```bash +# ORM client first({ id }) cached for 60s. +pnpm start -- cache-demo-user 00000000-0000-0000-0000-000000000001 + +# ORM client User.all() listing cached for 60s. +pnpm start -- cache-demo-users 5 + +# SQL DSL .annotate(cacheAnnotation({ ttl })) on a select. +pnpm start -- cache-demo-sql 5 +``` + +A representative run looks like: + +``` +Demonstrating opt-in caching with cacheAnnotation... +Calling User.first({ id: 00000000-... }) twice — second call should hit cache. + +First call (cache miss): 4.71ms +Second call (cache hit): 0.18ms +Speedup: 26.2x faster +``` + +The corresponding source files: + +- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))` +- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all((meta) => meta.annotate(cacheAnnotation({ ttl, key? })))` +- `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build()` + +Relevant points: + +- The `cacheAnnotation` handle declares `applicableTo: ['read']`. Passing it to a write terminal is rejected at both the type and runtime levels — a `as any` cast cannot smuggle it past one without failing at the other. +- The default cache key is `RuntimeMiddlewareContext.contentHash(exec)`, a SHA-512 digest of the post-lowering SQL plus parameters. Different parameters land in different cache slots; identical executions hit. Schema migrations rotate `meta.storageHash`, which feeds into `contentHash`, so cached entries do not leak across migrations. +- The default in-memory store is per-process. For shared caching across replicas, supply a custom `CacheStore` (for example a Redis-backed implementation) via `createCacheMiddleware({ store })`. +- Connection-scoped (`runtime.connection().execute(...)`) and transaction-scoped (`runtime.transaction(...)`) executions bypass the cache regardless of annotation, so transactional read-after-write coherence is preserved. + ## Setup 1. Install dependencies: diff --git a/examples/prisma-next-demo/package.json b/examples/prisma-next-demo/package.json index f133e11437..94ba4a1196 100644 --- a/examples/prisma-next-demo/package.json +++ b/examples/prisma-next-demo/package.json @@ -29,6 +29,7 @@ "@prisma-next/extension-pgvector": "workspace:*", "@prisma-next/family-sql": "workspace:*", "@prisma-next/ids": "workspace:*", + "@prisma-next/middleware-cache": "workspace:*", "@prisma-next/middleware-telemetry": "workspace:*", "@prisma-next/postgres": "workspace:*", "@prisma-next/sql-contract": "workspace:*", diff --git a/examples/prisma-next-demo/src/main.ts b/examples/prisma-next-demo/src/main.ts index 23a406c01a..57248bddaf 100644 --- a/examples/prisma-next-demo/src/main.ts +++ b/examples/prisma-next-demo/src/main.ts @@ -42,6 +42,12 @@ * authors via a self-join on a non-relation predicate, with * cosineDistance over two column references — a shape the * current ORM collection surface cannot directly express. + * - cache-demo-user Cached `User.first({ id })` lookup. Runs the + * same query twice and reports cache hit/miss + * by observing telemetry's `source` field. + * - cache-demo-users [limit] Cached `User.all()` listing via ORM client. + * - cache-demo-sql [limit] Cached SQL DSL select. Runs the same plan + * twice and observes the cache short-circuit. * - budget-violation Demo budget enforcement error * - guardrail-delete Demo AST lint blocking DELETE without WHERE * @@ -54,6 +60,7 @@ import { loadAppConfig } from './app-config'; import { ormClientCreateUserWithAddress } from './orm-client/create-user-with-address'; import { ormClientFindSimilarPosts } from './orm-client/find-similar-posts'; import { ormClientFindUserByEmail } from './orm-client/find-user-by-email'; +import { ormClientFindUserByIdCached } from './orm-client/find-user-by-id-cached'; import { ormClientGetAdminUsers } from './orm-client/get-admin-users'; import { ormClientGetDashboardUsers } from './orm-client/get-dashboard-users'; import { ormClientGetLatestUserPerKind } from './orm-client/get-latest-user-per-kind'; @@ -65,6 +72,7 @@ import { ormClientGetUserPosts } from './orm-client/get-user-posts'; import { ormClientGetUsers } from './orm-client/get-users'; import { ormClientGetUsersBackwardCursor } from './orm-client/get-users-backward-cursor'; import { ormClientGetUsersByIdCursor } from './orm-client/get-users-by-id-cursor'; +import { ormClientGetUsersCached } from './orm-client/get-users-cached'; import { ormClientSearchPostsByEmbedding } from './orm-client/search-posts-by-embedding'; import { ormClientUpsertUser } from './orm-client/upsert-user'; import { db } from './prisma/db'; @@ -74,6 +82,7 @@ import { getAllPostsUnbounded } from './queries/get-all-posts-unbounded'; import { getUserById } from './queries/get-user-by-id'; import { getUserPosts } from './queries/get-user-posts'; import { getUsers } from './queries/get-users'; +import { getUsersCached } from './queries/get-users-cached'; import { similaritySearch } from './queries/similarity-search'; const argv = process.argv.slice(2).filter((arg) => arg !== '--'); @@ -337,6 +346,67 @@ async function main() { const results = await crossAuthorSimilarity(limit); console.log(JSON.stringify(results, null, 2)); + } else if (cmd === 'cache-demo-user') { + const [userIdStr] = args; + if (!userIdStr) { + console.error('Usage: pnpm start -- cache-demo-user '); + process.exit(1); + } + console.log('Demonstrating opt-in caching with cacheAnnotation...'); + console.log( + `Calling User.first({ id: ${userIdStr} }) twice — second call should hit cache.\n`, + ); + + const firstStart = performance.now(); + const first = await ormClientFindUserByIdCached(userIdStr, runtime); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await ormClientFindUserByIdCached(userIdStr, runtime); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log('\nResult (identical between calls):'); + console.log(JSON.stringify(second, null, 2)); + void first; + } else if (cmd === 'cache-demo-users') { + const limit = args[0] ? Number.parseInt(args[0], 10) : 10; + console.log('Demonstrating opt-in caching with cacheAnnotation on User.all()...'); + console.log(`Listing ${limit} users twice — second call should hit cache.\n`); + + const firstStart = performance.now(); + const first = await ormClientGetUsersCached(limit, runtime); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await ormClientGetUsersCached(limit, runtime); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log(`\nReturned ${second.length} rows (identical between calls).`); + void first; + } else if (cmd === 'cache-demo-sql') { + const limit = args[0] ? Number.parseInt(args[0], 10) : 10; + console.log('Demonstrating opt-in caching via SQL DSL .annotate(cacheAnnotation(...))...'); + console.log('Running the same select twice — second call should hit cache.\n'); + + const firstStart = performance.now(); + const first = await getUsersCached(limit); + const firstMs = performance.now() - firstStart; + + const secondStart = performance.now(); + const second = await getUsersCached(limit); + const secondMs = performance.now() - secondStart; + + console.log(`First call (cache miss): ${firstMs.toFixed(2)}ms`); + console.log(`Second call (cache hit): ${secondMs.toFixed(2)}ms`); + console.log(`Speedup: ${(firstMs / Math.max(secondMs, 0.001)).toFixed(1)}x faster`); + console.log(`\nReturned ${second.length} rows (identical between calls).`); + void first; } else if (cmd === 'budget-violation') { console.log('Running unbounded query to demonstrate budget violation...'); @@ -390,6 +460,7 @@ async function main() { 'repo-similar-posts [limit] | repo-search-posts [limit] | ' + 'users-paginate [cursor] [limit] | users-paginate-back [limit] | ' + 'similarity-search [limit] | cross-author-similarity [limit] | ' + + 'cache-demo-user | cache-demo-users [limit] | cache-demo-sql [limit] | ' + 'budget-violation | guardrail-delete]', ); process.exit(1); diff --git a/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts new file mode 100644 index 0000000000..ee70dea1bf --- /dev/null +++ b/examples/prisma-next-demo/src/orm-client/find-user-by-id-cached.ts @@ -0,0 +1,69 @@ +/** + * Cached `User.first({ id })` lookup. + * + * Demonstrates the read-only `cacheAnnotation` from + * `@prisma-next/middleware-cache`. The annotation is opt-in: the cache + * middleware only acts on plans whose `meta.annotations` carry a + * `cacheAnnotation` payload with a `ttl` set. Calling the same lookup + * with the same `id` within the TTL window is served from the in-memory + * LRU configured in `src/prisma/db.ts` — the driver is **not** invoked + * the second time. + * + * The cache key is composed by the runtime via + * `RuntimeMiddlewareContext.contentHash(exec)`, which incorporates the + * post-lowering SQL plus parameters. Two lookups for different `id` + * values therefore land in different cache slots; the same `id` hits. + * + * Notes worth pinning: + * + * - `cacheAnnotation` declares `applicableTo: ['read']`. Passing it to + * a write terminal (`create`, `update`, `delete`) is a type error + * *and* a runtime error — it cannot be smuggled through with a cast + * on one side without failing on the other. + * - On a cache hit, telemetry's `afterExecute` event reports + * `source: 'middleware'`. Telemetry is wired in front of the cache + * in `db.ts`, so observability still works for cached reads. + * - Schema migrations rotate `meta.storageHash`, which feeds + * `contentHash`, so cached entries from a previous schema cannot + * accidentally serve queries against the new schema. + */ + +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import type { DefaultModelRow } from '@prisma-next/sql-orm-client'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import type { Contract } from '../prisma/contract.d'; +import { createOrmClient } from './client'; + +type UserId = DefaultModelRow['id']; + +export interface CachedLookupOptions { + /** + * Time-to-live for the cached entry, in milliseconds. Defaults to + * 60 seconds when not supplied. The cache middleware passes the + * query through unchanged when `ttl` is omitted from the + * annotation, so we always set one here. + */ + readonly ttlMs?: number; + /** + * When `true`, the cache middleware passes the query through + * untouched even if the annotation carries a `ttl`. Useful as a + * "force refresh" knob without removing the annotation entirely. + */ + readonly forceRefresh?: boolean; +} + +export async function ormClientFindUserByIdCached( + id: string, + runtime: Runtime, + options: CachedLookupOptions = {}, +) { + const db = createOrmClient(runtime); + const ttl = options.ttlMs ?? 60_000; + return db.User.first({ id: toUserId(id) }, (meta) => + meta.annotate(cacheAnnotation({ ttl, skip: options.forceRefresh ?? false })), + ); +} + +function toUserId(value: string): UserId { + return value as UserId; +} diff --git a/examples/prisma-next-demo/src/orm-client/get-users-cached.ts b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts new file mode 100644 index 0000000000..0fb63c8ccb --- /dev/null +++ b/examples/prisma-next-demo/src/orm-client/get-users-cached.ts @@ -0,0 +1,44 @@ +/** + * Cached `User.all()` listing. + * + * Companion to `find-user-by-id-cached.ts` — same opt-in caching + * mechanism, this time on a multi-row read terminal. The terminal's + * `configure: (meta) => void` callback hands the caller a + * `MetaBuilder<'read'>`; calling `meta.annotate(cacheAnnotation({ ttl }))` + * enables caching of the post-lowering execution. + * + * The example also shows the per-query `key` override. When set, the + * supplied string is used verbatim as the cache key; the cache + * middleware does not rehash it. This is useful for sharing entries + * across slightly different plans whose results you know to be + * equivalent (e.g. the same user list rendered through two different + * `select` shapes), but the trade-off is that you take responsibility + * for keeping the key bounded and free of sensitive data — the + * default `contentHash(exec)` digest is a SHA-512 hash with no + * such risks. + */ + +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import type { Runtime } from '@prisma-next/sql-runtime'; +import { createOrmClient } from './client'; + +export interface CachedListOptions { + readonly ttlMs?: number; + /** + * Optional override for the cache key. When omitted, the runtime's + * `contentHash(exec)` is used (the default and recommended path). + */ + readonly key?: string; +} + +export async function ormClientGetUsersCached( + limit: number, + runtime: Runtime, + options: CachedListOptions = {}, +) { + const db = createOrmClient(runtime); + const ttl = options.ttlMs ?? 60_000; + return db.User.take(limit).all((meta) => + meta.annotate(cacheAnnotation(options.key !== undefined ? { ttl, key: options.key } : { ttl })), + ); +} diff --git a/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts b/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts index eaade94fd4..0d1a5952aa 100644 --- a/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts +++ b/examples/prisma-next-demo/src/prisma-no-emit/runtime.ts @@ -1,4 +1,5 @@ import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; import { budgets, createRuntime, type Runtime, type SqlMiddleware } from '@prisma-next/sql-runtime'; import { Pool } from 'pg'; @@ -7,6 +8,11 @@ import { context, stack } from './context'; export async function getRuntime( databaseUrl: string, middleware: SqlMiddleware[] = [ + // Cache first: short-circuits annotated reads on a hit before + // telemetry's `beforeExecute` runs. Both middleware see the + // `afterExecute` event regardless, so observability still works + // for cached reads. + createCacheMiddleware({ maxEntries: 1_000 }), createTelemetryMiddleware(), budgets({ maxRows: 10_000, diff --git a/examples/prisma-next-demo/src/prisma/db.ts b/examples/prisma-next-demo/src/prisma/db.ts index 070af39a07..25d0173413 100644 --- a/examples/prisma-next-demo/src/prisma/db.ts +++ b/examples/prisma-next-demo/src/prisma/db.ts @@ -1,4 +1,5 @@ import pgvector from '@prisma-next/extension-pgvector/runtime'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { createTelemetryMiddleware } from '@prisma-next/middleware-telemetry'; import postgres from '@prisma-next/postgres/runtime'; import { budgets, lints } from '@prisma-next/sql-runtime'; @@ -9,6 +10,12 @@ export const db = postgres({ contractJson, extensions: [pgvector], middleware: [ + // Cache first so its `intercept` short-circuits before any other + // middleware's `beforeExecute` fires on a hit. Telemetry's + // `afterExecute` still runs on both paths and observes the + // `source: 'driver' | 'middleware'` field, so cache hits are + // visible through whichever observability sink is plugged in. + createCacheMiddleware({ maxEntries: 1_000 }), createTelemetryMiddleware(), lints(), budgets({ diff --git a/examples/prisma-next-demo/src/queries/get-users-cached.ts b/examples/prisma-next-demo/src/queries/get-users-cached.ts new file mode 100644 index 0000000000..d30c3efc3f --- /dev/null +++ b/examples/prisma-next-demo/src/queries/get-users-cached.ts @@ -0,0 +1,27 @@ +/** + * Cached SQL DSL `select` example. + * + * Mirrors `get-users.ts` but adds `.annotate(cacheAnnotation(...))` + * to opt the plan into the cache middleware registered on the runtime + * in `src/prisma/db.ts`. The annotation is a read-only handle, so the + * type system rejects passing it to write builders (`insert`, + * `update`, `delete`); the runtime gate fails closed for callers that + * bypass the type check with a cast. + * + * The cache key is computed by the runtime via + * `RuntimeMiddlewareContext.contentHash(exec)` — the post-lowering + * statement plus parameters, hashed to a bounded SHA-512 digest. + * Subsequent calls with the same plan within the TTL window are + * served from the cache without invoking the driver. + */ +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import { db } from '../prisma/db'; + +export async function getUsersCached(limit = 10, ttlMs = 60_000) { + const plan = db.sql.user + .select('id', 'email', 'createdAt', 'kind') + .annotate(cacheAnnotation({ ttl: ttlMs })) + .limit(limit) + .build(); + return db.runtime().execute(plan); +} diff --git a/examples/prisma-next-demo/test/repositories.integration.test.ts b/examples/prisma-next-demo/test/repositories.integration.test.ts index 0eb44c3b9f..39164db9d1 100644 --- a/examples/prisma-next-demo/test/repositories.integration.test.ts +++ b/examples/prisma-next-demo/test/repositories.integration.test.ts @@ -1,10 +1,16 @@ import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; import { sql } from '@prisma-next/sql-builder/runtime'; import type { SqlDriver } from '@prisma-next/sql-relational-core/ast'; -import { type CreateRuntimeOptions, createRuntime, type Runtime } from '@prisma-next/sql-runtime'; +import { + type CreateRuntimeOptions, + createRuntime, + type Runtime, + type SqlMiddleware, +} from '@prisma-next/sql-runtime'; import { timeouts, withDevDatabase } from '@prisma-next/test-utils'; import { Pool } from 'pg'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ormClientAggregateUsers } from '../src/orm-client/aggregate-users'; import { ormClientCreateUser } from '../src/orm-client/create-user'; import { ormClientCreateUserWithAddress } from '../src/orm-client/create-user-with-address'; @@ -12,6 +18,7 @@ import { ormClientDeleteUser } from '../src/orm-client/delete-user'; import { ormClientFindSimilarPosts } from '../src/orm-client/find-similar-posts'; import { ormClientFindUserByEmail } from '../src/orm-client/find-user-by-email'; import { ormClientFindUserById } from '../src/orm-client/find-user-by-id'; +import { ormClientFindUserByIdCached } from '../src/orm-client/find-user-by-id-cached'; import { ormClientGetAdminUsers } from '../src/orm-client/get-admin-users'; import { ormClientGetDashboardUsers } from '../src/orm-client/get-dashboard-users'; import { ormClientGetLatestUserPerKind } from '../src/orm-client/get-latest-user-per-kind'; @@ -22,6 +29,7 @@ import { ormClientGetUserPosts } from '../src/orm-client/get-user-posts'; import { ormClientGetUsers } from '../src/orm-client/get-users'; import { ormClientGetUsersBackwardCursor } from '../src/orm-client/get-users-backward-cursor'; import { ormClientGetUsersByIdCursor } from '../src/orm-client/get-users-by-id-cursor'; +import { ormClientGetUsersCached } from '../src/orm-client/get-users-cached'; import { ormClientSearchPostsByEmbedding } from '../src/orm-client/search-posts-by-embedding'; import { ormClientUpdateUserEmail } from '../src/orm-client/update-user-email'; import { ormClientUpsertUser } from '../src/orm-client/upsert-user'; @@ -60,6 +68,26 @@ async function getRuntime(connectionString: string): Promise { }); } +/** + * Creates a runtime wired up with the supplied middleware. Used by + * cache-middleware tests below; each test owns its own cache instance + * so entries don't bleed between tests. + */ +async function getRuntimeWithMiddleware( + connectionString: string, + middleware: readonly SqlMiddleware[], +): Promise<{ runtime: Runtime; driver: SqlDriver }> { + const { stackInstance, driver } = await createTestDriver(connectionString); + const runtime = createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + return { runtime, driver }; +} + const seededUserIds = { admin: '00000000-0000-0000-0000-000000000001', member: '00000000-0000-0000-0000-000000000002', @@ -776,4 +804,142 @@ describe('ORM client integration examples', () => { }, timeouts.spinUpPpgDev, ); + + // --------------------------------------------------------------------------- + // Cache middleware integration tests. + // + // The cache helpers under `src/orm-client/find-user-by-id-cached.ts` and + // `src/orm-client/get-users-cached.ts` opt their reads into the + // `@prisma-next/middleware-cache` middleware via `cacheAnnotation(...)`. + // The middleware short-circuits repeated executions of the same plan via + // its `intercept` hook, so a cache hit means the SQL driver is *not* + // invoked again. We assert that contract by spying on `driver.execute`. + // --------------------------------------------------------------------------- + + it( + 'ormClientFindUserByIdCached serves the second call from the cache (driver.execute not invoked again)', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + + // Spy *after* seeding so we don't count seed inserts. + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + const first = await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + const driverCallsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(driverCallsAfterFirst).toBeGreaterThan(0); + expect(first).toMatchObject({ id: seededUserIds.admin, kind: 'admin' }); + + const second = await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + // Cache hit: driver was not invoked again. + expect(driverExecuteSpy.mock.calls.length).toBe(driverCallsAfterFirst); + expect(second).toEqual(first); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientFindUserByIdCached forceRefresh: true bypasses the cache (skip annotation)', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + // Prime the cache. + await ormClientFindUserByIdCached(seededUserIds.admin, runtime); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + // Same query but with skip — should hit the driver again. + await ormClientFindUserByIdCached(seededUserIds.admin, runtime, { + forceRefresh: true, + }); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientGetUsersCached serves the second call from cache; different limits land in distinct slots', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + const first = await ormClientGetUsersCached(2, runtime); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(first).toHaveLength(2); + + const second = await ormClientGetUsersCached(2, runtime); + // Same plan: cache hit, driver not invoked. + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + + // Different plan (different limit → different params → different + // identity key): cache miss, driver invoked. + await ormClientGetUsersCached(3, runtime); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'ormClientGetUsersCached supports an explicit cache key for sharing entries across plans', + async () => { + await withDevDatabase(async ({ connectionString }) => { + await initTestDatabase({ connection: connectionString, contract }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const { runtime, driver } = await getRuntimeWithMiddleware(connectionString, [cache]); + + try { + await seedOrmClientData(runtime); + const driverExecuteSpy = vi.spyOn(driver, 'execute'); + + // Prime the cache under an explicit key. + const first = await ormClientGetUsersCached(2, runtime, { key: 'user-list:demo' }); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(first).toHaveLength(2); + + // Different limit (→ different identity key) but same explicit + // key: the entry is shared. The cache middleware uses the + // supplied key verbatim and serves the previously buffered + // rows even though the underlying SQL has changed. (Two-row + // rows from the first call — the explicit key takes precedence + // over the canonical identity key.) + const second = await ormClientGetUsersCached(5, runtime, { key: 'user-list:demo' }); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + } finally { + await runtime.close(); + } + }); + }, + timeouts.spinUpPpgDev, + ); }); diff --git a/packages/1-framework/1-core/framework-components/src/annotations.ts b/packages/1-framework/1-core/framework-components/src/annotations.ts new file mode 100644 index 0000000000..492a88b954 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/annotations.ts @@ -0,0 +1,319 @@ +import { runtimeError } from './execution/runtime-error'; + +/** + * The kinds of operations an annotation may apply to. + * + * - `'read'` — `SELECT` / `find` / `first` / `all` / `count` / aggregates. + * - `'write'` — `INSERT` / `UPDATE` / `DELETE` / `create` / `update` / `delete` / `upsert`. + * + * Annotations declare which kinds they apply to via `defineAnnotation`'s + * `applicableTo` option; lane terminals enforce the constraint at both the + * type level (via `ValidAnnotations`) and at runtime (via + * `assertAnnotationsApplicable`). + * + * Finer-grained kinds (`'select' | 'insert' | 'update' | 'delete' | 'upsert'`) + * are deliberately deferred. The binary covers the common case (the cache + * middleware applies to reads; an audit annotation would apply to writes; + * tracing/OTel applies to both). When a real annotation surfaces that needs + * a finer split, the union widens and existing handles remain typecheckable. + */ +export type OperationKind = 'read' | 'write'; + +/** + * An applied annotation. Carries the namespace, the typed payload, and the + * `applicableTo` set the underlying handle declared. The `__annotation` + * brand lets `read` distinguish branded user annotations from arbitrary + * data that may happen to live under the same namespace key in + * `plan.meta.annotations` (e.g. framework-internal metadata such as + * `meta.annotations.codecs`). + * + * Constructed by calling an `AnnotationHandle` directly (e.g. + * `cacheAnnotation({ ttl: 60 })`); never instantiated by hand. + */ +export interface AnnotationValue { + readonly __annotation: true; + readonly namespace: string; + readonly value: Payload; + readonly applicableTo: ReadonlySet; +} + +/** + * Handle returned by `defineAnnotation`. The handle is **callable**: the + * call signature wraps a `Payload` into an `AnnotationValue` ready to + * pass to a lane terminal's variadic `annotations` argument. The handle + * also carries static metadata as own properties: + * + * - `namespace` — the namespace string the handle was declared with. + * - `applicableTo` — the frozen `ReadonlySet` consumed by both + * the type-level `ValidAnnotations` gate and the runtime + * `assertAnnotationsApplicable` gate. + * - `read(plan)` — extract the `Payload` from a plan's `meta.annotations` + * if a value was previously written under this handle's namespace. + * Returns `undefined` when the annotation is absent or when the stored + * value is not a branded `AnnotationValue` (e.g. framework-internal + * metadata under the same namespace key). + * + * Handles are the only supported public entry point for reading and + * writing annotations. Direct mutation of `plan.meta.annotations` is not + * part of the public API. + * + * ```typescript + * const cacheAnnotation = defineAnnotation<{ ttl: number }>()({ + * namespace: 'cache', + * applicableTo: ['read'], + * }); + * + * // Call the handle to construct a value: + * const applied = cacheAnnotation({ ttl: 60 }); + * + * // Read a stored value off a plan: + * const payload = cacheAnnotation.read(plan); + * ``` + * + * Note on the inherited `Function.prototype.apply`: because the handle is + * a function, the property name `apply` resolves to JavaScript's built-in + * `Function.prototype.apply` (which lets you invoke a function with an + * array of arguments). This is **not** the construction entry point — to + * build an `AnnotationValue`, call the handle directly. The + * `AnnotationHandle` interface deliberately does not declare an `apply` + * member of its own. + */ +export interface AnnotationHandle { + (value: Payload): AnnotationValue; + readonly namespace: string; + readonly applicableTo: ReadonlySet; + read(plan: { + readonly meta: { readonly annotations?: Record }; + }): Payload | undefined; +} + +/** + * Options accepted by `defineAnnotation`. + * + * `namespace` is the string key under which the annotation is stored in + * `plan.meta.annotations`. **Reserved namespaces** include framework- + * internal metadata keys; user handles must not use them: + * + * - `codecs` — used by the SQL emitter to record per-alias codec ids + * (`meta.annotations.codecs[alias] = 'pg/text@1'`); the SQL runtime's + * `decodeRow` reads from this key. A user `defineAnnotation('codecs')` + * handle is not structurally prevented, but its behavior with the + * emitter and the runtime is undefined and we make no compatibility + * guarantees about it. + * - Target-specific keys such as `pg` (and equivalents on other + * targets) are similarly reserved for adapter / target use. + * + * `applicableTo` declares which operation kinds the annotation may attach + * to. The lane terminals' type-level `ValidAnnotations` gate rejects + * annotations whose `Kinds` does not include the terminal's `K`; the + * runtime helper `assertAnnotationsApplicable` does the equivalent at + * runtime so casts and `any` cannot bypass the gate. + */ +export interface DefineAnnotationOptions { + readonly namespace: string; + readonly applicableTo: readonly Kinds[]; +} + +/** + * Defines a typed annotation handle. + * + * Two-step call form. The first step takes the `Payload` type argument + * (TypeScript cannot infer `Payload` from anything in the options, so it + * must be supplied explicitly); the second step takes the runtime options + * and infers `Kinds` from the `applicableTo` array via a `const` type + * parameter, so the operation kinds appear exactly once at the call site. + * + * @example + * ```typescript + * // Read-only annotation. Lane terminals like `db.User.first(...)` accept + * // it; `db.User.create(...)` rejects it at the type level. + * const cacheAnnotation = defineAnnotation<{ ttl?: number; skip?: boolean }>()({ + * namespace: 'cache', + * applicableTo: ['read'], + * }); // Kinds inferred as 'read' + * + * // Write-only annotation. Mirror image. + * const auditAnnotation = defineAnnotation<{ actor: string }>()({ + * namespace: 'audit', + * applicableTo: ['write'], + * }); // Kinds inferred as 'write' + * + * // Annotation applicable to both kinds (e.g. tracing). + * const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + * namespace: 'otel', + * applicableTo: ['read', 'write'], + * }); // Kinds inferred as 'read' | 'write' + * ``` + * + * **Reserved namespaces.** See `DefineAnnotationOptions.namespace` for the + * list of framework-internal namespaces (`codecs`, target-specific keys). + * `defineAnnotation` does not structurally prevent a user from naming a + * reserved namespace, but the framework makes no compatibility guarantee + * about handles that do. + */ +export function defineAnnotation(): ( + options: DefineAnnotationOptions, +) => AnnotationHandle { + return ( + options: DefineAnnotationOptions, + ): AnnotationHandle => { + const namespace = options.namespace; + const applicableTo: ReadonlySet = Object.freeze(new Set(options.applicableTo)); + + function handle(value: Payload): AnnotationValue { + return Object.freeze({ + __annotation: true as const, + namespace, + value, + applicableTo, + }); + } + + function read(plan: { + readonly meta: { readonly annotations?: Record }; + }): Payload | undefined { + const stored = plan.meta.annotations?.[namespace]; + if (!isAnnotationValue(stored)) { + return undefined; + } + if (stored.namespace !== namespace) { + // Defensive: a different handle wrote under our namespace key. + return undefined; + } + return stored.value as Payload; + } + + return Object.freeze( + Object.assign(handle, { + namespace, + applicableTo, + read, + }), + ); + }; +} + +/** + * Type-level applicability gate consumed by lane terminals. + * + * Maps a tuple of `AnnotationValue`s to a tuple where each element either + * keeps its annotation type (when the annotation's declared `Kinds` + * includes the terminal's operation kind `K`) or resolves to `never` + * (when the kinds are incompatible). A `never` element makes the entire + * tuple unassignable, surfacing the mismatch as a type error at the call + * site of the terminal. + * + * Lane terminals constrain their variadic `...annotations` parameter via + * `As & ValidAnnotations`. **The intersection is load-bearing** — + * see the note below. + * + * @example + * ```typescript + * class Collection { + * first[]>( + * where: WhereInput, + * ...annotations: As & ValidAnnotations<'read', As> + * ): Promise; + * + * create[]>( + * input: CreateInput, + * ...annotations: As & ValidAnnotations<'write', As> + * ): Promise; + * } + * + * db.User.first({ id }, cacheAnnotation({ ttl: 60 })); + * // ✓ cacheAnnotation declares 'read'; first() requires 'read'. + * + * db.User.create(input, cacheAnnotation({ ttl: 60 })); + * // ✗ cacheAnnotation declares 'read'; create() requires 'write'. + * // Element resolves to `never` → tuple unassignable → type error. + * ``` + * + * **Why `As & ValidAnnotations` and not `ValidAnnotations` + * alone.** TypeScript's variadic-tuple inference is too forgiving when + * the parameter type refers to `As` only through `ValidAnnotations`: it + * will pick an `As` that makes the call valid even when the gated tuple + * would contain `never` for an inapplicable element. The intersection + * pins `As` to the actual call-site tuple AND requires it to be + * assignable to the gated form. A `never` element in the gated tuple + * then collapses the corresponding intersection position to `never`, + * and the inapplicable argument fails to assign — surfacing the mismatch + * as a type error at the call site. + * + * The runtime helper `assertAnnotationsApplicable` covers the equivalent + * check at runtime so casts and `any` cannot bypass this gate. + */ +export type ValidAnnotations< + K extends OperationKind, + As extends readonly AnnotationValue[], +> = { + readonly [I in keyof As]: As[I] extends AnnotationValue + ? K extends Kinds + ? AnnotationValue + : never + : never; +}; + +/** + * Runtime applicability gate. Throws `RUNTIME.ANNOTATION_INAPPLICABLE` if + * any annotation in `annotations` declares an `applicableTo` set that does + * not include `kind`. Used by lane terminals (SQL DSL builders' `.build()`, + * ORM `Collection` terminals) to fail closed when the type-level + * `ValidAnnotations` gate is bypassed via cast / `any` / dynamic + * invocation. + * + * Passes silently on: + * - empty arrays + * - annotations whose `applicableTo` includes `kind` + * + * Throws on: + * - any annotation whose `applicableTo` does not include `kind`. The + * error names the offending annotation's `namespace` and the + * `terminalName` so users can locate the misuse. + * + * @example + * ```typescript + * // Inside an ORM read terminal: + * assertAnnotationsApplicable(annotations, 'read', 'first'); + * ``` + */ +export function assertAnnotationsApplicable( + annotations: readonly AnnotationValue[], + kind: OperationKind, + terminalName: string, +): void { + for (const annotation of annotations) { + if (!annotation.applicableTo.has(kind)) { + throw runtimeError( + 'RUNTIME.ANNOTATION_INAPPLICABLE', + `Annotation '${annotation.namespace}' is not applicable to '${kind}' operations (terminal: '${terminalName}'). The annotation declares applicableTo = [${Array.from( + annotation.applicableTo, + ) + .map((k) => `'${k}'`) + .join(', ')}].`, + { + namespace: annotation.namespace, + terminalName, + kind, + applicableTo: Array.from(annotation.applicableTo), + }, + ); + } + } +} + +/** + * Type guard for branded annotation values stored in `plan.meta.annotations`. + * + * Internal — used by `AnnotationHandle.read` to distinguish user + * annotations (created by calling a handle returned from + * `defineAnnotation(...)`) from framework-internal metadata that may + * happen to live under the same namespace key. + */ +function isAnnotationValue(value: unknown): value is AnnotationValue { + if (value === null || typeof value !== 'object') { + return false; + } + const candidate = value as { readonly __annotation?: unknown }; + return candidate.__annotation === true; +} diff --git a/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts b/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts index d8f66a48f9..841dd14e7c 100644 --- a/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts +++ b/packages/1-framework/1-core/framework-components/src/execution/runtime-middleware.ts @@ -32,6 +32,27 @@ export interface RuntimeMiddlewareContext { * — it is not (and should not be) further hashed by callers. */ contentHash(exec: ExecutionPlan): Promise; + /** + * Identifies the queryable scope this execution is running under. + * + * - `'runtime'` — top-level `runtime.execute(plan)`. The default scope + * used by the standard read/write paths. + * - `'connection'` — `connection.execute(plan)` after + * `runtime.connection()` checked out a connection from the pool. + * - `'transaction'` — `transaction.execute(plan)` inside an explicit + * transaction, or a query routed through `withTransaction`. + * + * Middleware that should only act at the top level read this field to + * bypass non-runtime scopes. The cache middleware uses it to skip + * caching inside transactions (where read-after-write coherence is the + * caller's expectation) and dedicated connections (where the user has + * explicitly stepped outside the shared cache surface). Observers that + * don't care about the scope can ignore the field. + * + * Family runtimes populate this at context-construction time per + * scope. Existing middleware that ignore the field are unaffected. + */ + readonly scope: 'runtime' | 'connection' | 'transaction'; } export interface AfterExecuteResult { diff --git a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts index 94c7628e34..54d75ccbc3 100644 --- a/packages/1-framework/1-core/framework-components/src/exports/runtime.ts +++ b/packages/1-framework/1-core/framework-components/src/exports/runtime.ts @@ -1,3 +1,13 @@ +export type { + AnnotationHandle, + AnnotationValue, + DefineAnnotationOptions, + OperationKind, + ValidAnnotations, +} from '../annotations'; +export { assertAnnotationsApplicable, defineAnnotation } from '../annotations'; +export type { LaneMetaBuilder, MetaBuilder } from '../meta-builder'; +export { createMetaBuilder } from '../meta-builder'; export { AsyncIterableResult } from '../execution/async-iterable-result'; export type { ExecutionPlan, QueryPlan, ResultType } from '../execution/query-plan'; export { checkAborted, raceAgainstAbort } from '../execution/race-against-abort'; diff --git a/packages/1-framework/1-core/framework-components/src/meta-builder.ts b/packages/1-framework/1-core/framework-components/src/meta-builder.ts new file mode 100644 index 0000000000..389f105c2f --- /dev/null +++ b/packages/1-framework/1-core/framework-components/src/meta-builder.ts @@ -0,0 +1,99 @@ +import { + type AnnotationValue, + assertAnnotationsApplicable, + type OperationKind, +} from './annotations'; + +/** + * Per-terminal meta configurator handed to user callbacks. The terminal's + * operation kind `K` is fixed by the terminal that constructed the builder; + * `annotate(...)` accepts only annotations whose declared `Kinds` include + * `K`. + * + * The conditional parameter type + * `K extends Kinds ? AnnotationValue : never` collapses to `never` + * for inapplicable annotations, surfacing the mismatch as a type error at + * the call site of `meta.annotate(...)`. No variadic-tuple inference is + * involved — TypeScript infers `Kinds` from the annotation argument and + * checks the conditional directly. + * + * The runtime gate inside `annotate` (via + * `assertAnnotationsApplicable`) catches cast / `any` / dynamic bypasses + * and throws `RUNTIME.ANNOTATION_INAPPLICABLE`. + * + * `annotate` returns the builder for chaining; the return value of the + * configurator callback is unused, so both block-body and expression-body + * callbacks compile. + * + * @example + * ```typescript + * await db.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * await db.User.create(input, (meta) => { + * meta.annotate(auditAnnotation({ actor: 'system' })); + * meta.annotate(otelAnnotation({ traceId })); + * }); + * ``` + */ +export interface MetaBuilder { + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this; +} + +/** + * Lane-side view of a meta builder. Extends the public `MetaBuilder` + * surface with `annotations` so lane terminals can read the recorded map + * after invoking the user configurator. + * + * Lane terminals construct one of these via `createMetaBuilder(kind, terminalName)`, + * pass it to the user callback as `MetaBuilder` (the narrower public + * view), then read `meta.annotations` to thread the recorded values into + * `plan.meta.annotations`. + */ +export interface LaneMetaBuilder extends MetaBuilder { + readonly annotations: ReadonlyMap>; +} + +class MetaBuilderImpl implements LaneMetaBuilder { + readonly #kind: K; + readonly #terminalName: string; + readonly #annotations = new Map>(); + + constructor(kind: K, terminalName: string) { + this.#kind = kind; + this.#terminalName = terminalName; + } + + get annotations(): ReadonlyMap> { + return this.#annotations; + } + + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this { + // The conditional parameter is opaque inside the body; widen to the + // structural shape. Both possible resolutions of the conditional — + // `AnnotationValue` (assignable up via covariance) and + // `never` (assignable to anything) — satisfy the wider type at runtime. + const value = annotation as AnnotationValue; + assertAnnotationsApplicable([value], this.#kind, this.#terminalName); + this.#annotations.set(value.namespace, value); + return this; + } +} + +/** + * Construct a lane-side meta builder for a terminal of operation kind `K`. + * + * Lane terminals call this with their `kind` (`'read'` or `'write'`) and a + * `terminalName` for error messages, hand the resulting builder to the + * user-supplied configurator callback (typed as `MetaBuilder`, the + * narrower public view), and read `meta.annotations` afterwards to thread + * the recorded values into `plan.meta.annotations`. + */ +export function createMetaBuilder( + kind: K, + terminalName: string, +): LaneMetaBuilder { + return new MetaBuilderImpl(kind, terminalName); +} diff --git a/packages/1-framework/1-core/framework-components/test/annotations.test.ts b/packages/1-framework/1-core/framework-components/test/annotations.test.ts new file mode 100644 index 0000000000..94cd7cfac1 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/annotations.test.ts @@ -0,0 +1,327 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import { + type AnnotationValue, + assertAnnotationsApplicable, + defineAnnotation, + type OperationKind, +} from '../src/annotations'; + +const meta: PlanMeta = { + target: 'mock', + storageHash: 'sha256:test', + lane: 'raw-sql', +}; + +function makePlan(annotations?: Record): { + readonly meta: { readonly annotations?: Record }; +} { + if (annotations === undefined) { + return { meta }; + } + return { meta: { ...meta, annotations } }; +} + +describe('defineAnnotation', () => { + describe('handle metadata', () => { + it('exposes the namespace it was created with', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + expect(handle.namespace).toBe('cache'); + }); + + it('exposes a frozen ReadonlySet for applicableTo', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], + }); + expect(handle.applicableTo.has('read')).toBe(true); + expect(handle.applicableTo.has('write')).toBe(true); + expect(Object.isFrozen(handle.applicableTo)).toBe(true); + }); + + it('handles do not share state across separate defineAnnotation calls', () => { + const a = defineAnnotation<{ x: number }>()({ + namespace: 'a', + applicableTo: ['read'], + }); + const b = defineAnnotation<{ y: string }>()({ + namespace: 'b', + applicableTo: ['write'], + }); + expect(a.namespace).toBe('a'); + expect(b.namespace).toBe('b'); + expect(a.applicableTo.has('read')).toBe(true); + expect(a.applicableTo.has('write' as 'read')).toBe(false); + expect(b.applicableTo.has('write')).toBe(true); + }); + }); + + describe('value construction (calling the handle)', () => { + it('produces an AnnotationValue carrying the __annotation brand', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle({ ttl: 60 }); + expect(applied.__annotation).toBe(true); + }); + + it('embeds the namespace, payload, and applicableTo set on the value', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle({ ttl: 60 }); + expect(applied.namespace).toBe('cache'); + expect(applied.value).toEqual({ ttl: 60 }); + expect(applied.applicableTo.has('read')).toBe(true); + }); + + it('produces a frozen value', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle({ ttl: 60 }); + expect(Object.isFrozen(applied)).toBe(true); + }); + + it('produces independent values across repeated calls', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const a = handle({ ttl: 60 }); + const b = handle({ ttl: 120 }); + expect(a.value).toEqual({ ttl: 60 }); + expect(b.value).toEqual({ ttl: 120 }); + }); + }); + + describe('read', () => { + it('returns the payload when a value applied through the same handle is stored', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const applied = handle({ ttl: 60 }); + const plan = makePlan({ cache: applied }); + expect(handle.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined when the annotation is absent', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + expect(handle.read(makePlan())).toBeUndefined(); + expect(handle.read(makePlan({}))).toBeUndefined(); + expect(handle.read(makePlan({ other: 'value' }))).toBeUndefined(); + }); + + it('returns undefined when the stored value is not a branded AnnotationValue', () => { + const handle = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + // Framework-internal metadata stored under the same namespace key + // (e.g. the SQL emitter's meta.annotations.codecs map) is a raw + // record, not a branded AnnotationValue. read() must not surface it + // as a user annotation. + expect(handle.read(makePlan({ cache: { ttl: 60 } }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: 'string-value' }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: 42 }))).toBeUndefined(); + expect(handle.read(makePlan({ cache: null }))).toBeUndefined(); + }); + + it('two handles with different namespaces do not interfere', () => { + const cache = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const audit = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], + }); + const plan = makePlan({ + cache: cache({ ttl: 60 }), + audit: audit({ actor: 'system' }), + }); + + expect(cache.read(plan)).toEqual({ ttl: 60 }); + expect(audit.read(plan)).toEqual({ actor: 'system' }); + }); + + it('read ignores annotations applied via a different handle even when the namespace string matches', () => { + // Two handles claiming the same namespace string. Defensive: + // read() compares the stored value's `namespace` field to the + // handle's, so a value applied through one handle does not surface + // through the other. + const a = defineAnnotation<{ kind: 'a' }>()({ + namespace: 'shared', + applicableTo: ['read'], + }); + // Construct a value whose stored namespace differs from the + // handle that reads it. (Both handles share a namespace string, + // but the stored AnnotationValue.namespace would normally match + // whichever handle wrote it.) + const stored: AnnotationValue<{ kind: 'b' }, 'read'> = Object.freeze({ + __annotation: true as const, + namespace: 'mismatched-namespace', + value: { kind: 'b' as const }, + applicableTo: new Set<'read'>(['read']), + }); + const plan = makePlan({ shared: stored }); + + // The stored value's `namespace` field is 'mismatched-namespace', + // which doesn't match the handle's 'shared' namespace. read() + // returns undefined. + expect(a.read(plan)).toBeUndefined(); + }); + + it('preserves Payload identity (handle.read returns the same object reference stored)', () => { + const handle = defineAnnotation<{ tags: string[] }>()({ + namespace: 'tags', + applicableTo: ['read'], + }); + const payload = { tags: ['admin', 'staff'] }; + const applied = handle(payload); + const plan = makePlan({ tags: applied }); + + const out = handle.read(plan); + expect(out).toBe(payload); + }); + }); +}); + +describe('assertAnnotationsApplicable', () => { + const cache = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], + }); + const audit = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], + }); + const otel = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], + }); + + describe('passes silently', () => { + it('on an empty annotations array', () => { + expect(() => assertAnnotationsApplicable([], 'read', 'first')).not.toThrow(); + expect(() => assertAnnotationsApplicable([], 'write', 'create')).not.toThrow(); + }); + + it('when every annotation applies to the kind', () => { + expect(() => + assertAnnotationsApplicable([cache({ ttl: 60 })], 'read', 'first'), + ).not.toThrow(); + expect(() => + assertAnnotationsApplicable([audit({ actor: 'a' })], 'write', 'create'), + ).not.toThrow(); + }); + + it('when an annotation declares both kinds and is used on either', () => { + expect(() => + assertAnnotationsApplicable([otel({ traceId: 't' })], 'read', 'first'), + ).not.toThrow(); + expect(() => + assertAnnotationsApplicable([otel({ traceId: 't' })], 'write', 'create'), + ).not.toThrow(); + }); + + it('when multiple compatible annotations are passed together', () => { + expect(() => + assertAnnotationsApplicable([cache({ ttl: 60 }), otel({ traceId: 't' })], 'read', 'first'), + ).not.toThrow(); + }); + }); + + describe('throws RUNTIME.ANNOTATION_INAPPLICABLE', () => { + it('on a read-only annotation passed to a write terminal', () => { + expect(() => assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create')).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('on a write-only annotation passed to a read terminal', () => { + expect(() => + assertAnnotationsApplicable([audit({ actor: 'system' })], 'read', 'first'), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('on the first inapplicable annotation when several are passed', () => { + expect(() => + assertAnnotationsApplicable( + [otel({ traceId: 't' }), audit({ actor: 'system' })], + 'read', + 'first', + ), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }), + ); + }); + + it('with a message naming the offending annotation namespace and the terminal', () => { + try { + assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create'); + expect.fail('expected assertAnnotationsApplicable to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("'cache'"); + expect(message).toContain("'create'"); + expect(message).toContain("'write'"); + } + }); + + it('with structured details including namespace, terminalName, kind, and applicableTo', () => { + try { + assertAnnotationsApplicable([cache({ ttl: 60 })], 'write', 'create'); + expect.fail('expected assertAnnotationsApplicable to throw'); + } catch (error) { + const envelope = error as Error & { details?: Record }; + expect(envelope.details).toEqual({ + namespace: 'cache', + terminalName: 'create', + kind: 'write', + applicableTo: ['read'], + }); + } + }); + }); + + describe('does not require AnnotationValue typing on its parameter', () => { + // The runtime helper takes readonly AnnotationValue[] + // so it can be called from lane terminals that have already passed + // the type gate via ValidAnnotations. The runtime check is the + // belt-and-suspenders that catches casts / `any` / dynamic + // invocations. + it('rejects an opaquely-typed inapplicable annotation forced through a cast', () => { + const sneakyWriteAnnotation = audit({ actor: 'system' }); + // Imagine a caller bypassed the type gate via `as any` and handed + // the runtime an annotation whose kinds do not match the terminal. + const annotations: readonly AnnotationValue[] = [ + sneakyWriteAnnotation, + ]; + expect(() => assertAnnotationsApplicable(annotations, 'read', 'first')).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts new file mode 100644 index 0000000000..83fd89d3d2 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/annotations.types.test-d.ts @@ -0,0 +1,245 @@ +import { assertType, describe, expectTypeOf, test } from 'vitest'; +import { + type AnnotationHandle, + type AnnotationValue, + defineAnnotation, + type OperationKind, + type ValidAnnotations, +} from '../src/annotations'; + +/** + * Type-level tests for the annotation surface. + * + * Verifies: + * - `defineAnnotation` preserves Payload and Kinds in the + * handle's static type and across `apply` / `read`. + * - `ValidAnnotations` resolves matching tuple elements to live + * `AnnotationValue` types and mismatched elements to `never` (which + * makes the entire tuple unassignable, which is the failure mode lane + * terminals exploit at the type level). + * - Lane-terminal call shapes — read terminals accepting read-only + * annotations, write terminals rejecting them, both-kind annotations + * accepted everywhere — work as expected. + */ + +const readOnly = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const writeOnly = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const both = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('defineAnnotation generics', () => { + test('defineAnnotation preserves Payload and Kinds in the handle type', () => { + expectTypeOf(readOnly).toEqualTypeOf>(); + expectTypeOf(writeOnly).toEqualTypeOf>(); + expectTypeOf(both).toEqualTypeOf>(); + }); + + test('AnnotationHandle.namespace is a string', () => { + expectTypeOf(readOnly.namespace).toBeString(); + }); + + test('AnnotationHandle.applicableTo is a ReadonlySet narrowed to the declared Kinds', () => { + expectTypeOf(readOnly.applicableTo).toEqualTypeOf>(); + expectTypeOf(writeOnly.applicableTo).toEqualTypeOf>(); + expectTypeOf(both.applicableTo).toEqualTypeOf>(); + }); + + test('calling the handle preserves Payload and Kinds in the AnnotationValue', () => { + const r = readOnly({ ttl: 60 }); + const w = writeOnly({ actor: 'system' }); + const x = both({ traceId: 't' }); + + expectTypeOf(r).toEqualTypeOf>(); + expectTypeOf(w).toEqualTypeOf>(); + expectTypeOf(x).toEqualTypeOf>(); + }); + + test('handle call rejects payloads of the wrong shape (negative)', () => { + // @ts-expect-error - missing required `ttl` field + readOnly({}); + // @ts-expect-error - wrong field name + readOnly({ wrong: 60 }); + // @ts-expect-error - wrong field type + readOnly({ ttl: 'not a number' }); + }); + + test('read returns Payload | undefined', () => { + const plan: { readonly meta: { readonly annotations?: Record } } = { + meta: {}, + }; + const out = readOnly.read(plan); + expectTypeOf(out).toEqualTypeOf<{ ttl: number } | undefined>(); + }); +}); + +describe('ValidAnnotations gate', () => { + test("ValidAnnotations<'read', [readOnly]> keeps the element typed", () => { + type As = readonly [AnnotationValue<{ ttl: number }, 'read'>]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf]>(); + }); + + test("ValidAnnotations<'read', [writeOnly]> resolves the element to never", () => { + type As = readonly [AnnotationValue<{ actor: string }, 'write'>]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf(); + }); + + test("ValidAnnotations<'write', [readOnly]> resolves the element to never", () => { + type As = readonly [AnnotationValue<{ ttl: number }, 'read'>]; + type Gated = ValidAnnotations<'write', As>; + expectTypeOf().toEqualTypeOf(); + }); + + test("ValidAnnotations<'read', [readOnly, both]> keeps both elements", () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ traceId: string }, 'read' | 'write'>, + ]; + type Gated = ValidAnnotations<'read', As>; + expectTypeOf().toEqualTypeOf< + readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ traceId: string }, 'read' | 'write'>, + ] + >(); + }); + + test("ValidAnnotations<'write', [readOnly, writeOnly]> resolves the read-only element to never", () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ actor: string }, 'write'>, + ]; + type Gated = ValidAnnotations<'write', As>; + expectTypeOf().toEqualTypeOf< + readonly [never, AnnotationValue<{ actor: string }, 'write'>] + >(); + }); + + test('ValidAnnotations on the empty tuple is the empty tuple', () => { + type Gated = ValidAnnotations<'read', readonly []>; + expectTypeOf().toEqualTypeOf(); + }); + + test('an inapplicable element makes the gated tuple unassignable from a value containing it', () => { + type As = readonly [ + AnnotationValue<{ ttl: number }, 'read'>, + AnnotationValue<{ actor: string }, 'write'>, + ]; + type Gated = ValidAnnotations<'read', As>; + // The gated tuple's second element is `never`, so the original tuple + // cannot be assigned to it. + const original: As = [readOnly({ ttl: 60 }), writeOnly({ actor: 'system' })]; + // @ts-expect-error - second element resolves to never under 'read' + const _gated: Gated = original; + void _gated; + }); +}); + +describe('lane-terminal call-shape simulation', () => { + /** + * Mimics the shape lane terminals adopt: a variadic `...annotations` + * parameter constrained by `ValidAnnotations`. The `As` type + * parameter is inferred from the call site's tuple of annotation values. + * + * Note: lane terminals must constrain the variadic argument as + * `As & ValidAnnotations`, not just `ValidAnnotations`. + * TypeScript's variadic-tuple inference is too forgiving when the + * parameter type alone refers to `As`: it will pick an `As` that makes + * the call valid even when the gated tuple contains `never`. The + * intersection forces the argument to be assignable to BOTH the inferred + * `As` AND the gated tuple, so a `never` element collapses to `never` + * at the position where it lives and the call rejects the offending + * argument. + */ + function readTerminal[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): void { + void annotations; + } + + function writeTerminal[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): void { + void annotations; + } + + test('read terminal accepts read-only annotations', () => { + readTerminal(readOnly({ ttl: 60 })); + }); + + test('read terminal accepts both-kind annotations', () => { + readTerminal(both({ traceId: 't' })); + }); + + test('read terminal accepts a mix of read-only and both-kind annotations', () => { + readTerminal(readOnly({ ttl: 60 }), both({ traceId: 't' })); + }); + + test('read terminal rejects write-only annotations (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + readTerminal(writeOnly({ actor: 'system' })); + }); + + test('read terminal rejects a mix that includes a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + readTerminal(readOnly({ ttl: 60 }), writeOnly({ actor: 'system' })); + }); + + test('write terminal accepts write-only annotations', () => { + writeTerminal(writeOnly({ actor: 'system' })); + }); + + test('write terminal accepts both-kind annotations', () => { + writeTerminal(both({ traceId: 't' })); + }); + + test('write terminal rejects read-only annotations (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + writeTerminal(readOnly({ ttl: 60 })); + }); + + test('terminals accept zero annotations (empty variadic)', () => { + readTerminal(); + writeTerminal(); + }); +}); + +describe('type narrowness preserved across the gate', () => { + test('the read terminal preserves the typed payload of a both-kind annotation', () => { + function inspect[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): As { + return annotations as unknown as As; + } + + const out = inspect(both({ traceId: 't' })); + // The handle's payload type survives the gate. + assertType<{ traceId: string }>(out[0].value); + }); + + test('non-AnnotationValue elements in the tuple resolve to never (defensive)', () => { + // Not part of the public API surface, but verifies the conditional's + // fallback. If somebody constructs a tuple of arbitrary objects and runs + // it through the gate, every element resolves to `never`. + type As = readonly [{ not: 'an annotation' }]; + type Gated = ValidAnnotations< + 'read', + As extends readonly AnnotationValue[] ? As : never + >; + // The conditional's outer `As extends readonly AnnotationValue[...]` + // branch makes the entire `As` resolve to `never`, which propagates + // through ValidAnnotations. + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts b/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts new file mode 100644 index 0000000000..e86b2e3ab0 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/meta-builder.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { defineAnnotation } from '../src/annotations'; +import { createMetaBuilder } from '../src/meta-builder'; + +const cacheAnnotation = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('createMetaBuilder', () => { + it('starts with an empty annotations map', () => { + const meta = createMetaBuilder('read', 'all'); + expect(meta.annotations.size).toBe(0); + }); + + it('records an applied annotation under its namespace', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(cacheAnnotation({ ttl: 60 })); + expect(meta.annotations.size).toBe(1); + expect(meta.annotations.get('cache')?.value).toEqual({ ttl: 60 }); + }); + + it('annotate returns the builder for chaining', () => { + const meta = createMetaBuilder('read', 'all'); + const chained = meta + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(otelAnnotation({ traceId: 't-1' })); + expect(chained).toBe(meta); + expect(meta.annotations.size).toBe(2); + }); + + it('last-write-wins on duplicate namespaces', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(cacheAnnotation({ ttl: 60 })); + meta.annotate(cacheAnnotation({ ttl: 120 })); + expect(meta.annotations.size).toBe(1); + expect(meta.annotations.get('cache')?.value).toEqual({ ttl: 120 }); + }); + + it('a both-kind annotation lands on a read builder', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(otelAnnotation({ traceId: 't-1' })); + expect(meta.annotations.get('otel')?.value).toEqual({ traceId: 't-1' }); + }); + + it('a both-kind annotation lands on a write builder', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(otelAnnotation({ traceId: 't-1' })); + expect(meta.annotations.get('otel')?.value).toEqual({ traceId: 't-1' }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast on a read builder', () => { + const meta = createMetaBuilder('read', 'all'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + expect(() => annotateAny.call(meta, auditAnnotation({ actor: 'system' }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('runtime gate rejects a read-only annotation forced through a cast on a write builder', () => { + const meta = createMetaBuilder('write', 'create'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + expect(() => annotateAny.call(meta, cacheAnnotation({ ttl: 60 }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); + + it('runtime gate names the offending namespace and terminal in the error', () => { + const meta = createMetaBuilder('write', 'create'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + try { + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + } catch (error) { + expect(error).toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + details: { + namespace: 'cache', + terminalName: 'create', + kind: 'write', + }, + }); + return; + } + throw new Error('expected runtime gate to throw'); + }); + + it('rejected annotations are not recorded', () => { + const meta = createMetaBuilder('read', 'all'); + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + try { + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + } catch { + // expected + } + expect(meta.annotations.size).toBe(0); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts new file mode 100644 index 0000000000..61eb6cb5c3 --- /dev/null +++ b/packages/1-framework/1-core/framework-components/test/meta-builder.types.test-d.ts @@ -0,0 +1,141 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import { type AnnotationValue, defineAnnotation } from '../src/annotations'; +import { createMetaBuilder, type LaneMetaBuilder, type MetaBuilder } from '../src/meta-builder'; + +const readOnly = defineAnnotation<{ ttl: number }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const writeOnly = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const both = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('MetaBuilder annotate', () => { + test('read meta builder accepts read-only annotations', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(readOnly({ ttl: 60 })); + }); + + test('read meta builder accepts both-kind annotations', () => { + const meta = createMetaBuilder('read', 'all'); + meta.annotate(both({ traceId: 't' })); + }); + + test('read meta builder rejects write-only annotations (negative)', () => { + const meta = createMetaBuilder('read', 'all'); + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(writeOnly({ actor: 'system' })); + }); + + test('write meta builder accepts write-only annotations', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(writeOnly({ actor: 'system' })); + }); + + test('write meta builder accepts both-kind annotations', () => { + const meta = createMetaBuilder('write', 'create'); + meta.annotate(both({ traceId: 't' })); + }); + + test('write meta builder rejects read-only annotations (negative)', () => { + const meta = createMetaBuilder('write', 'create'); + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(readOnly({ ttl: 60 })); + }); + + test('annotate returns the builder for chaining', () => { + const meta = createMetaBuilder('read', 'all'); + const result = meta.annotate(readOnly({ ttl: 60 })); + expectTypeOf(result).toEqualTypeOf(); + }); + + test('chaining annotate calls preserves the lane meta builder type', () => { + const meta = createMetaBuilder('read', 'all'); + const result = meta.annotate(readOnly({ ttl: 60 })).annotate(both({ traceId: 't' })); + expectTypeOf(result).toEqualTypeOf(); + }); +}); + +describe('createMetaBuilder return type', () => { + test('returns a LaneMetaBuilder exposing the annotations map', () => { + const meta = createMetaBuilder('read', 'all'); + expectTypeOf(meta).toEqualTypeOf>(); + expectTypeOf(meta.annotations).toEqualTypeOf< + ReadonlyMap> + >(); + }); + + test('LaneMetaBuilder is assignable to MetaBuilder (the user-callback view)', () => { + const lane = createMetaBuilder('read', 'all'); + const view: MetaBuilder<'read'> = lane; + void view; + }); + + test('MetaBuilder does not expose the annotations map (negative)', () => { + const view: MetaBuilder<'read'> = createMetaBuilder('read', 'all'); + // @ts-expect-error - annotations is not part of the public MetaBuilder surface + void view.annotations; + }); +}); + +describe('configurator-callback shape on lane terminals', () => { + /** + * Mimics the shape lane terminals adopt: an optional final + * `configure: (meta: MetaBuilder) => void` argument. The builder's + * operation kind `K` is fixed by the terminal; `meta.annotate` accepts + * any annotation whose declared kinds include `K`. + */ + function readTerminal(configure?: (meta: MetaBuilder<'read'>) => void): void { + const meta = createMetaBuilder('read', 'readTerminal'); + configure?.(meta); + } + + function writeTerminal(configure?: (meta: MetaBuilder<'write'>) => void): void { + const meta = createMetaBuilder('write', 'writeTerminal'); + configure?.(meta); + } + + test('read terminal accepts a configurator with read-applicable annotations', () => { + readTerminal((meta) => { + meta.annotate(readOnly({ ttl: 60 })); + meta.annotate(both({ traceId: 't' })); + }); + }); + + test('read terminal rejects a configurator that applies a write-only annotation (negative)', () => { + readTerminal((meta) => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(writeOnly({ actor: 'system' })); + }); + }); + + test('write terminal accepts a configurator with write-applicable annotations', () => { + writeTerminal((meta) => { + meta.annotate(writeOnly({ actor: 'system' })); + meta.annotate(both({ traceId: 't' })); + }); + }); + + test('write terminal rejects a configurator that applies a read-only annotation (negative)', () => { + writeTerminal((meta) => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(readOnly({ ttl: 60 })); + }); + }); + + test('terminals accept omitted configurator (the parameter is optional)', () => { + readTerminal(); + writeTerminal(); + }); + + test('expression-body callback that returns the builder is accepted (return type ignored)', () => { + readTerminal((meta) => meta.annotate(readOnly({ ttl: 60 }))); + }); +}); diff --git a/packages/1-framework/1-core/framework-components/test/mock-family.test.ts b/packages/1-framework/1-core/framework-components/test/mock-family.test.ts index 88f8cddf7e..bde3688b29 100644 --- a/packages/1-framework/1-core/framework-components/test/mock-family.test.ts +++ b/packages/1-framework/1-core/framework-components/test/mock-family.test.ts @@ -82,6 +82,7 @@ const ctx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const meta: PlanMeta = { diff --git a/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts b/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts index 9459c86bce..da33e81e40 100644 --- a/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts +++ b/packages/1-framework/1-core/framework-components/test/run-with-middleware.intercept.test.ts @@ -28,6 +28,7 @@ function makeCtx(overrides?: Partial): RuntimeMiddlewa now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', ...overrides, }; } @@ -233,6 +234,7 @@ describe('runWithMiddleware — intercept', () => { // No `debug` field — this is the optional case. log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const interceptor: RuntimeMiddleware = { diff --git a/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts b/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts index f16f7ddedc..da9cad702f 100644 --- a/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts +++ b/packages/1-framework/1-core/framework-components/test/run-with-middleware.test.ts @@ -26,6 +26,7 @@ const mockCtx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; async function* yieldRows(rows: ReadonlyArray): AsyncGenerator { diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts b/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts index 094fd661c7..98cb3db382 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core-options.test.ts @@ -50,6 +50,7 @@ const ctxValue: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; const plan: MockPlan = { draftId: 'd', meta }; diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts index 279b14c22c..7d815bf7d7 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core-options.types.test-d.ts @@ -38,6 +38,7 @@ test('execute accepts an optional second argument carrying { signal }', () => { now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); const plan: FixturePlan = { draftId: 'd', meta }; diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts b/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts index ed2f795051..48f5f8ca87 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core.test.ts @@ -93,6 +93,7 @@ const ctx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; describe('RuntimeCore', () => { diff --git a/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts index 849d86ca30..5f942c68c7 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-core.types.test-d.ts @@ -34,6 +34,7 @@ test('a minimal RuntimeCore subclass typechecks', () => { now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); }); @@ -58,6 +59,7 @@ test('execute(plan) enforces the TPlan constraint and returns AsyncIterableResul now: () => 0, log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }, }); const result = runtime.execute(plan); diff --git a/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts b/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts index 3cd0d72e1e..4989ba67fd 100644 --- a/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts +++ b/packages/1-framework/1-core/framework-components/test/runtime-middleware.types.test-d.ts @@ -147,7 +147,7 @@ test('RuntimeMiddleware narrowed to a SQL plan sees the SQL fields', () => { void middleware; }); -test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash', () => { +test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash, scope', () => { expectTypeOf().toHaveProperty('contract'); expectTypeOf().toHaveProperty('mode'); expectTypeOf().toHaveProperty('log'); @@ -155,6 +155,10 @@ test('RuntimeMiddlewareContext has contract, mode, log, now, contentHash', () => expectTypeOf().toHaveProperty('contentHash'); expectTypeOf().toBeFunction(); expectTypeOf().returns.resolves.toBeString(); + expectTypeOf().toHaveProperty('scope'); + expectTypeOf().toEqualTypeOf< + 'runtime' | 'connection' | 'transaction' + >(); }); test('RuntimeMiddleware familyId and targetId are optional', () => { diff --git a/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts b/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts index 859311a25d..64794a5830 100644 --- a/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts +++ b/packages/2-mongo-family/7-runtime/src/mongo-runtime.ts @@ -88,6 +88,7 @@ class MongoRuntimeImpl // ctx is only invoked by runWithMiddleware with execs this runtime lowered; // the framework parameter type is the cross-family base. contentHash: (exec) => computeMongoContentHash(exec as MongoExecutionPlan), + scope: 'runtime', }; super({ middleware, ctx }); diff --git a/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts b/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts index 8c470a74bd..eff8e2193e 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-runtime.types.test-d.ts @@ -25,4 +25,8 @@ test('MongoMiddlewareContext extends RuntimeMiddlewareContext', () => { expectTypeOf().toHaveProperty('log'); expectTypeOf().toHaveProperty('now'); expectTypeOf().toHaveProperty('contentHash'); + expectTypeOf().toHaveProperty('scope'); + expectTypeOf().toEqualTypeOf< + 'runtime' | 'connection' | 'transaction' + >(); }); diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts index 8f993ff888..10c6b992a0 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts @@ -1,4 +1,5 @@ import type { PlanMeta } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlOperationEntry } from '@prisma-next/sql-operations'; import { @@ -70,6 +71,12 @@ export interface BuilderState { readonly distinctOn: readonly AstExpression[] | undefined; readonly scope: Scope; readonly rowFields: Record; + /** + * User annotations accumulated through `.annotate(...)` calls. + * Stored as a `Map` so duplicate + * namespaces last-write-win. Empty on a fresh state. + */ + readonly userAnnotations: ReadonlyMap>; } export interface BuilderContext { @@ -97,6 +104,7 @@ export function emptyState(from: TableSource, scope: Scope): BuilderState { distinctOn: undefined, scope, rowFields: {}, + userAnnotations: new Map(), }; } @@ -131,6 +139,7 @@ export function buildSelectAst(state: BuilderState): SelectAst { export function buildQueryPlan( ast: import('@prisma-next/sql-relational-core/ast').AnyQueryAst, ctx: BuilderContext, + annotations?: ReadonlyMap>, ): SqlQueryPlan { const paramValues = collectOrderedParamRefs(ast).map((r) => r.value); @@ -138,6 +147,9 @@ export function buildQueryPlan( target: ctx.target, storageHash: ctx.storageHash, lane: 'dsl', + ...(annotations !== undefined && annotations.size > 0 + ? { annotations: Object.freeze(Object.fromEntries(annotations)) } + : {}), }); return Object.freeze({ ast, params: paramValues, meta }); @@ -147,7 +159,7 @@ export function buildPlan( state: BuilderState, ctx: BuilderContext, ): SqlQueryPlan { - return buildQueryPlan(buildSelectAst(state), ctx); + return buildQueryPlan(buildSelectAst(state), ctx, state.userAnnotations); } export function tableToScope(name: string, table: StorageTable): Scope { diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts index e11954d942..51885178c1 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts @@ -1,3 +1,9 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import { type AnyExpression as AstExpression, @@ -29,6 +35,29 @@ import { import { createFieldProxy } from './field-proxy'; import { createFunctions } from './functions'; +/** + * Validates and merges a variadic annotations call into a builder's + * accumulated user-annotations map. Used by `.annotate(...)` on each of + * the three mutation builders (`InsertQueryImpl`, `UpdateQueryImpl`, + * `DeleteQueryImpl`); the read builders share the same logic via + * `QueryBase.annotate()` in `./query-impl.ts`. + * + * Runs `assertAnnotationsApplicable` at call time (not at `.build()`) so + * inapplicable annotations forced through casts surface immediately + * rather than at plan-construction time. + */ +function mergeWriteAnnotations( + current: ReadonlyMap>, + annotations: readonly AnnotationValue[], +): ReadonlyMap> { + assertAnnotationsApplicable(annotations, 'write', 'sql-dsl.annotate'); + const next = new Map(current); + for (const annotation of annotations) { + next.set(annotation.namespace, annotation); + } + return next; +} + type WhereCallback = ExpressionBuilder; function buildParamValues( @@ -85,6 +114,7 @@ export class InsertQueryImpl< readonly #values: Record; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -94,6 +124,7 @@ export class InsertQueryImpl< ctx: BuilderContext, returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -102,6 +133,7 @@ export class InsertQueryImpl< this.#values = values; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } returning = this._gate>( @@ -122,10 +154,36 @@ export class InsertQueryImpl< this.ctx, columns, newRowFields, + this.#userAnnotations, ) as unknown as InsertQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * The type-level `As & ValidAnnotations<'write', As>` gate rejects + * read-only annotations at the call site; the runtime check fails + * closed for callers that bypass the type gate. See `QueryBase.annotate` + * in `./query-impl.ts` for the read-builder counterpart. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): InsertQuery { + return new InsertQueryImpl( + this.#tableName, + this.#table, + this.#scope, + this.#values, + this.ctx, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const paramValues = buildParamValues( this.#values, @@ -146,6 +204,7 @@ export class InsertQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } @@ -165,6 +224,7 @@ export class UpdateQueryImpl< readonly #whereCallbacks: readonly WhereCallback[]; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -175,6 +235,7 @@ export class UpdateQueryImpl< whereCallbacks: readonly WhereCallback[] = [], returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -184,6 +245,7 @@ export class UpdateQueryImpl< this.#whereCallbacks = whereCallbacks; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } where(expr: ExpressionBuilder): UpdateQuery { @@ -196,6 +258,7 @@ export class UpdateQueryImpl< [...this.#whereCallbacks, expr as unknown as WhereCallback], this.#returningColumns, this.#rowFields, + this.#userAnnotations, ); } @@ -218,10 +281,35 @@ export class UpdateQueryImpl< this.#whereCallbacks, columns, newRowFields, + this.#userAnnotations, ) as unknown as UpdateQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQueryImpl.annotate` for semantics; the runtime check + * fails closed for callers that bypass the type-level gate. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): UpdateQuery { + return new UpdateQueryImpl( + this.#tableName, + this.#table, + this.#scope, + this.#setValues, + this.ctx, + this.#whereCallbacks, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const setParams = buildParamValues( this.#setValues, @@ -250,6 +338,7 @@ export class UpdateQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } @@ -267,6 +356,7 @@ export class DeleteQueryImpl< readonly #whereCallbacks: readonly WhereCallback[]; readonly #returningColumns: string[]; readonly #rowFields: Record; + readonly #userAnnotations: ReadonlyMap>; constructor( tableName: string, @@ -275,6 +365,7 @@ export class DeleteQueryImpl< whereCallbacks: readonly WhereCallback[] = [], returningColumns: string[] = [], rowFields: Record = {}, + userAnnotations: ReadonlyMap> = new Map(), ) { super(ctx); this.#tableName = tableName; @@ -282,6 +373,7 @@ export class DeleteQueryImpl< this.#whereCallbacks = whereCallbacks; this.#returningColumns = returningColumns; this.#rowFields = rowFields; + this.#userAnnotations = userAnnotations; } where(expr: ExpressionBuilder): DeleteQuery { @@ -292,6 +384,7 @@ export class DeleteQueryImpl< [...this.#whereCallbacks, expr as unknown as WhereCallback], this.#returningColumns, this.#rowFields, + this.#userAnnotations, ); } @@ -312,10 +405,32 @@ export class DeleteQueryImpl< this.#whereCallbacks, columns, newRowFields, + this.#userAnnotations, ) as unknown as DeleteQuery; }, ); + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQueryImpl.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): DeleteQuery { + return new DeleteQueryImpl( + this.#tableName, + this.#scope, + this.ctx, + this.#whereCallbacks, + this.#returningColumns, + this.#rowFields, + mergeWriteAnnotations( + this.#userAnnotations, + annotations as readonly AnnotationValue[], + ), + ); + } + build(): SqlQueryPlan> { const whereExpr = combineWhereExprs( this.#whereCallbacks.map((cb) => @@ -334,6 +449,7 @@ export class DeleteQueryImpl< return buildQueryPlan>( ast, this.ctx, + this.#userAnnotations, ); } } diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts index 0a9b93b611..ae10510847 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts @@ -1,3 +1,9 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; +import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import { DerivedTableSource, type SelectAst } from '@prisma-next/sql-relational-core/ast'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { @@ -81,6 +87,38 @@ abstract class QueryBase< return this.clone(cloneState(this.state, { distinct: true })); } + /** + * Attach one or more user annotations to this query plan. + * + * Read builders (`SelectQueryImpl`, `GroupedQueryImpl`) accept + * annotations whose declared `applicableTo` includes `'read'`. + * The type-level `As & ValidAnnotations<'read', As>` gate rejects + * write-only annotations at the call site; the runtime check below + * fails closed for callers that bypass the type gate (cast / `any`). + * + * Multiple `.annotate(...)` calls compose; duplicate namespaces use + * last-write-wins. The accumulated annotations are merged into + * `plan.meta.annotations` at `.build()` time, alongside any framework- + * internal metadata under reserved namespaces (e.g. `codecs`). + * + * Chainable in any position (before / after `.where`, `.select`, + * `.limit`, etc.); the returned builder has the same row type. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): this { + assertAnnotationsApplicable( + annotations as readonly AnnotationValue[], + 'read', + 'sql-dsl.annotate', + ); + const next = new Map(this.state.userAnnotations); + for (const annotation of annotations as readonly AnnotationValue[]) { + next.set(annotation.namespace, annotation); + } + return this.clone(cloneState(this.state, { userAnnotations: next })); + } + groupBy( ...fields: ((keyof RowType | keyof AvailableScope['topLevel']) & string)[] ): GroupedQuery; diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts index 2e356d2342..0e0cb5bc69 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/grouped-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { AggregateFunctions, BooleanCodecType, @@ -19,6 +24,18 @@ export interface GroupedQuery< WithDistinct, WithAlias, WithBuild { + /** + * Attach one or more read-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['read']` (or `['read', 'write']`) + * via `defineAnnotation`; write-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): GroupedQuery; + groupBy( ...fields: ((keyof RowType | keyof AvailableScope['topLevel']) & string)[] ): GroupedQuery; diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts index 1a8abfb1c7..23f25a6539 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/mutation-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { StorageTable } from '@prisma-next/sql-contract/types'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { ExpressionBuilder, WithFields } from '../expression'; @@ -30,6 +35,17 @@ export interface InsertQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['write']` (or `['read', 'write']`) + * via `defineAnnotation`; read-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): InsertQuery; returning: GatedMethod< QC['capabilities'], ReturningCapability, @@ -45,6 +61,13 @@ export interface UpdateQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQuery.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): UpdateQuery; where(expr: ExpressionBuilder): UpdateQuery; returning: GatedMethod< QC['capabilities'], @@ -61,6 +84,13 @@ export interface DeleteQuery< AvailableScope extends Scope, RowType extends Record, > { + /** + * Attach one or more write-typed user annotations to this query plan. + * See `InsertQuery.annotate` for semantics. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'write', As> + ): DeleteQuery; where(expr: ExpressionBuilder): DeleteQuery; returning: GatedMethod< QC['capabilities'], diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts b/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts index 6df2c68205..567a4e837b 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/select-query.ts @@ -1,3 +1,8 @@ +import type { + AnnotationValue, + OperationKind, + ValidAnnotations, +} from '@prisma-next/framework-components/runtime'; import type { Expression, ExpressionBuilder, @@ -20,6 +25,18 @@ export interface SelectQuery< WithDistinct, WithAlias, WithBuild { + /** + * Attach one or more read-typed user annotations to this query plan. + * Annotations declare `applicableTo: ['read']` (or `['read', 'write']`) + * via `defineAnnotation`; write-only annotations fail to compile here. + * Annotations are merged into `plan.meta.annotations` at `.build()` time. + * Chainable in any position; multiple calls compose with last-write-wins + * on duplicate namespaces. + */ + annotate[]>( + ...annotations: As & ValidAnnotations<'read', As> + ): SelectQuery; + where(expr: ExpressionBuilder): SelectQuery; orderBy( diff --git a/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts new file mode 100644 index 0000000000..61322a030b --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/playground/annotate.test-d.ts @@ -0,0 +1,240 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expectTypeOf, test } from 'vitest'; +import { db } from './preamble'; + +/** + * Type-level tests for the SQL DSL `.annotate(...)` surface. + * + * Verifies: + * - Each builder kind (Select, Grouped, Insert, Update, Delete) accepts + * annotations matching its operation kind and rejects mismatched ones + * via the `As & ValidAnnotations` gate. + * - Annotations applicable to both kinds (`'read' | 'write'`) are + * accepted on every builder. + * - `.annotate()` does not widen the resulting plan's row type. + * - `.annotate()` is chainable in any position relative to other + * builder methods. + */ + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('SelectQuery.annotate (read-typed)', () => { + test('accepts a read-only annotation', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts a both-kind annotation', () => { + const plan = db.users + .select('id') + .annotate(otelAnnotation({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts multiple compatible annotations in a single call', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('rejects a write-only annotation (negative)', () => { + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + db.users.select('id').annotate(auditAnnotation({ actor: 'system' })); + }); + + test('rejects a mix containing a write-only annotation (negative)', () => { + db.users + .select('id') + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + .annotate(cacheAnnotation({ ttl: 60 }), auditAnnotation({ actor: 'system' })); + }); + + test('accepts zero annotations (empty variadic)', () => { + const plan = db.users.select('id').annotate().build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() before .where preserves row type', () => { + const plan = db.users + .select('id', 'email') + .annotate(cacheAnnotation({ ttl: 60 })) + .where((c, fns) => fns.eq(c.id, 1)) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() after .where preserves row type', () => { + const plan = db.users + .select('id', 'email') + .where((c, fns) => fns.eq(c.id, 1)) + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('chainable: .annotate() between .select and .limit preserves row type', () => { + const plan = db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .limit(10) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('GroupedQuery.annotate (read-typed)', () => { + test('accepts a read-only annotation', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('accepts a both-kind annotation', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(otelAnnotation({ traceId: 't' })) + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); + + test('rejects a write-only annotation (negative)', () => { + db.posts + .select('user_id') + .groupBy('user_id') + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + .annotate(auditAnnotation({ actor: 'system' })); + }); + + test('chainable: .annotate() between .groupBy and .orderBy preserves row type', () => { + const plan = db.posts + .select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation({ ttl: 60 })) + .orderBy('user_id') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('InsertQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users.insert({ name: 'Alice' }).annotate(auditAnnotation({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users.insert({ name: 'Alice' }).annotate(otelAnnotation({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + db.users.insert({ name: 'Alice' }).annotate(cacheAnnotation({ ttl: 60 })); + }); + + test('rejects a mix containing a read-only annotation (negative)', () => { + db.users + .insert({ name: 'Alice' }) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(auditAnnotation({ actor: 'system' }), cacheAnnotation({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .insert({ name: 'Alice' }) + .annotate(auditAnnotation({ actor: 'system' })) + .returning('id', 'name') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('UpdateQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(cacheAnnotation({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })) + .returning('id', 'name') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); + +describe('DeleteQuery.annotate (write-typed)', () => { + test('accepts a write-only annotation', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })); + }); + + test('accepts a both-kind annotation', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation({ traceId: 't' })); + }); + + test('rejects a read-only annotation (negative)', () => { + db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + .annotate(cacheAnnotation({ ttl: 60 })); + }); + + test('chainable: .annotate() before .returning preserves the resulting row type', () => { + const plan = db.users + .delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })) + .returning('id') + .build(); + expectTypeOf(plan).toEqualTypeOf>(); + }); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts new file mode 100644 index 0000000000..b7d9e6635d --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts @@ -0,0 +1,350 @@ +import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import { validateContract } from '@prisma-next/sql-contract/validate'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { sql } from '../../src/runtime/sql'; +import { contract as contractJson } from '../fixtures/contract'; +import type { Contract } from '../fixtures/generated/contract'; + +const sqlContract = validateContract(contractJson, emptyCodecLookup); + +const stubBase = { + operations: {}, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + applyMutationDefaults: () => [], +}; + +function db() { + return sql({ + context: { ...stubBase, contract: sqlContract } as unknown as ExecutionContext< + typeof sqlContract + >, + }); +} + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +describe('SelectQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + + const stored = plan.meta.annotations?.['cache']; + expect(stored).toMatchObject({ + __annotation: true, + namespace: 'cache', + value: { ttl: 60 }, + }); + }); + + it('round-trips through the typed handle.read accessor', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined from handle.read on a plan that was never annotated', () => { + const plan = db().users.select('id').build(); + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('multiple annotations under different namespaces coexist', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(otelAnnotation({ traceId: 't-1' })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('multiple annotations passed in a single call coexist', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 }), otelAnnotation({ traceId: 't-1' })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('duplicate namespace last-write-wins', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 120 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 120 }); + }); + + it('annotate does not mutate the original builder (immutability)', () => { + const base = db().users.select('id'); + const annotated = base.annotate(cacheAnnotation({ ttl: 60 })); + const basePlan = base.build(); + const annotatedPlan = annotated.build(); + + expect(cacheAnnotation.read(basePlan)).toBeUndefined(); + expect(cacheAnnotation.read(annotatedPlan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: immediately after .select', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: between .select and .where', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .where((f, fns) => fns.eq(f.id, 1)) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: after .where, before .limit', () => { + const plan = db() + .users.select('id') + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(cacheAnnotation({ ttl: 60 })) + .limit(10) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('annotate does not affect the produced AST shape', () => { + const baseAst = db() + .users.select('id') + .where((f, fns) => fns.eq(f.id, 1)) + .buildAst(); + + const annotatedAst = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(otelAnnotation({ traceId: 't-1' })) + .buildAst(); + + expect(annotatedAst).toEqual(baseAst); + }); + + it('annotate with no arguments is a no-op for user annotations (empty variadic)', () => { + // The framework `codecs` map under the reserved namespace may still + // populate `plan.meta.annotations` — this is independent of user + // annotations. We verify only that no user annotation lands. + const plan = db().users.select('id').annotate().build(); + + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + // No user-namespaced keys. + const annotations = plan.meta.annotations ?? {}; + const userKeys = Object.keys(annotations).filter((k) => k !== 'codecs'); + expect(userKeys).toEqual([]); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const builder = db().users.select('id') as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(auditAnnotation({ actor: 'system' }))).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); +}); + +describe('GroupedQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .posts.select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: between .select and .groupBy', () => { + const plan = db() + .posts.select('user_id') + .annotate(cacheAnnotation({ ttl: 60 })) + .groupBy('user_id') + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('chainable in any position: after .groupBy, before .having / .orderBy', () => { + const plan = db() + .posts.select('user_id') + .groupBy('user_id') + .annotate(cacheAnnotation({ ttl: 60 })) + .orderBy('user_id') + .build(); + + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const builder = db().posts.select('user_id').groupBy('user_id') as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(auditAnnotation({ actor: 'system' }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('InsertQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(auditAnnotation({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('accepts both-kind annotations', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(otelAnnotation({ traceId: 't-1' })) + .build(); + + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('survives across .returning(...) chaining', () => { + const plan = db() + .users.insert({ name: 'Alice' }) + .annotate(auditAnnotation({ actor: 'system' })) + .returning('id', 'name') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.insert({ name: 'Alice' }) as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('UpdateQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.update({ name: 'Alice' }) + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('survives across .where(...) and .returning(...) chaining', () => { + const plan = db() + .users.update({ name: 'Alice' }) + .annotate(auditAnnotation({ actor: 'system' })) + .where((f, fns) => fns.eq(f.id, 1)) + .returning('id', 'name') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.update({ name: 'Alice' }) as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('DeleteQuery.annotate', () => { + it('writes the applied annotation under its namespace on plan.meta.annotations', () => { + const plan = db() + .users.delete() + .where((f, fns) => fns.eq(f.id, 1)) + .annotate(auditAnnotation({ actor: 'system' })) + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('survives across .where(...) and .returning(...) chaining', () => { + const plan = db() + .users.delete() + .annotate(auditAnnotation({ actor: 'system' })) + .where((f, fns) => fns.eq(f.id, 1)) + .returning('id') + .build(); + + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', () => { + const builder = db().users.delete() as unknown as { + annotate(annotation: unknown): unknown; + }; + expect(() => builder.annotate(cacheAnnotation({ ttl: 60 }))).toThrow( + expect.objectContaining({ code: 'RUNTIME.ANNOTATION_INAPPLICABLE' }), + ); + }); +}); + +describe('annotate alongside framework-internal codecs metadata', () => { + // The SQL emitter writes per-alias codec ids under the reserved + // `codecs` namespace key in plan.meta.annotations. User annotations + // must coexist with that without collision. + it('coexists with the framework codecs map under its reserved namespace', () => { + const plan = db() + .users.select('id') + .annotate(cacheAnnotation({ ttl: 60 })) + .build(); + + // User annotation lives under its own namespace. + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + // Reserved framework namespace is not affected. + if (plan.meta.annotations?.['codecs'] !== undefined) { + expect(plan.meta.annotations['codecs']).toEqual( + expect.objectContaining({ id: expect.any(String) }), + ); + } + }); +}); diff --git a/packages/2-sql/5-runtime/src/sql-runtime.ts b/packages/2-sql/5-runtime/src/sql-runtime.ts index a6e331b9d4..4792b1575c 100644 --- a/packages/2-sql/5-runtime/src/sql-runtime.ts +++ b/packages/2-sql/5-runtime/src/sql-runtime.ts @@ -173,6 +173,7 @@ class SqlRuntimeImpl = Contract computeSqlContentHash(exec as SqlExecutionPlan), + scope: 'runtime', }; super({ middleware: middleware ?? [], ctx: sqlCtx }); @@ -264,6 +265,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan, queryable: SqlQueryable, options?: RuntimeExecuteOptions, + scope: 'runtime' | 'connection' | 'transaction' = 'runtime', ): AsyncIterableResult { this.ensureCodecRegistryValidated(); @@ -277,6 +279,14 @@ class SqlRuntimeImpl = Contract { checkAborted(codecCtx, 'stream'); @@ -309,7 +319,7 @@ class SqlRuntimeImpl = Contract>( exec, self.middleware, - self.ctx, + ctx, () => queryable.execute>({ sql: exec.sql, @@ -380,7 +390,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.executeAgainstQueryable(plan, driverConn, options); + return self.executeAgainstQueryable(plan, driverConn, options, 'connection'); }, }; @@ -400,7 +410,7 @@ class SqlRuntimeImpl = Contract | SqlQueryPlan) & { readonly _row?: Row }, options?: RuntimeExecuteOptions, ): AsyncIterableResult { - return self.executeAgainstQueryable(plan, driverTx, options); + return self.executeAgainstQueryable(plan, driverTx, options, 'transaction'); }, }; } diff --git a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts index e0ac3f07ee..350e48daee 100644 --- a/packages/2-sql/5-runtime/test/before-compile-chain.test.ts +++ b/packages/2-sql/5-runtime/test/before-compile-chain.test.ts @@ -37,6 +37,7 @@ function createContext(): SqlMiddlewareContext & { debug, }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; } diff --git a/packages/2-sql/5-runtime/test/budgets.test.ts b/packages/2-sql/5-runtime/test/budgets.test.ts index 96501ab0d3..f234191e46 100644 --- a/packages/2-sql/5-runtime/test/budgets.test.ts +++ b/packages/2-sql/5-runtime/test/budgets.test.ts @@ -29,6 +29,7 @@ function createMiddlewareContext(overrides?: Partial): Sql error: vi.fn(), }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, ...overrides, }; } diff --git a/packages/2-sql/5-runtime/test/lints.test.ts b/packages/2-sql/5-runtime/test/lints.test.ts index dce38a74e2..a8876201ae 100644 --- a/packages/2-sql/5-runtime/test/lints.test.ts +++ b/packages/2-sql/5-runtime/test/lints.test.ts @@ -28,6 +28,7 @@ function createMiddlewareContext(): SqlMiddlewareContext { error: vi.fn(), }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; } diff --git a/packages/2-sql/5-runtime/test/scope-plumbing.test.ts b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts new file mode 100644 index 0000000000..885b896a4a --- /dev/null +++ b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts @@ -0,0 +1,299 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, + type RuntimeExtensionInstance, +} from '@prisma-next/framework-components/execution'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { + CodecRegistry, + SqlDriver, + SqlExecuteRequest, +} from '@prisma-next/sql-relational-core/ast'; +import { codec, createCodecRegistry, type SelectAst } from '@prisma-next/sql-relational-core/ast'; +import type { SqlExecutionPlan } from '@prisma-next/sql-relational-core/plan'; +import { describe, expect, it, vi } from 'vitest'; +import type { SqlMiddleware } from '../src/middleware/sql-middleware'; +import type { + SqlRuntimeAdapterDescriptor, + SqlRuntimeAdapterInstance, + SqlRuntimeTargetDescriptor, +} from '../src/sql-context'; +import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; +import { createRuntime } from '../src/sql-runtime'; + +/** + * Verifies the SQL runtime populates `RuntimeMiddlewareContext.scope` + * differently for the three queryable surfaces: top-level `runtime.execute`, + * `connection.execute` (after `runtime.connection()`), and + * `transaction.execute` (after `connection.transaction()` or + * `withTransaction`). + * + * The cache middleware (TML-2143 M3) reads `ctx.scope` to bypass caching on + * connection / transaction scopes; this test pins the contract so a + * regression in scope plumbing surfaces here rather than via a confusing + * cache-coherence bug. + */ + +const testContract: Contract = { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test'), + models: {}, + roots: {}, + storage: { storageHash: coreHash('sha256:test'), tables: {} }, + extensionPacks: {}, + capabilities: {}, + meta: {}, +}; + +function createStubCodecs(): CodecRegistry { + const registry = createCodecRegistry(); + registry.register( + codec({ + typeId: 'pg/int4@1', + targetTypes: ['int4'], + encode: (v: number) => v, + decode: (w: number) => w, + }), + ); + return registry; +} + +function createStubAdapter() { + const codecs = createStubCodecs(); + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + profile: { + id: 'test-profile', + target: 'postgres', + capabilities: {}, + codecs() { + return codecs; + }, + readMarkerStatement() { + return { + sql: 'select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta from prisma_contract.marker where id = $1', + params: [1], + }; + }, + parseMarkerRow() { + throw new Error('stub adapter does not implement parseMarkerRow'); + }, + }, + lower(ast: SelectAst) { + return Object.freeze({ sql: JSON.stringify(ast), params: [] }); + }, + }; +} + +function createMockDriver(): SqlDriver { + const transaction = { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 3 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + commit: vi.fn().mockResolvedValue(undefined), + rollback: vi.fn().mockResolvedValue(undefined), + }; + const connection = { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 2 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + release: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(undefined), + beginTransaction: vi.fn().mockResolvedValue(transaction), + }; + return { + execute: vi.fn().mockImplementation(async function* (_request: SqlExecuteRequest) { + yield { id: 1 } as Record; + }), + query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }), + connect: vi.fn().mockImplementation(async (_binding?: undefined) => undefined), + acquireConnection: vi.fn().mockResolvedValue(connection), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { + return { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => createCodecRegistry(), + parameterizedCodecs: () => [], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }; +} + +function createTestAdapterDescriptor( + adapter: ReturnType, +): SqlRuntimeAdapterDescriptor<'postgres'> { + const codecRegistry = adapter.profile.codecs(); + return { + kind: 'adapter', + id: 'test-adapter', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => codecRegistry, + parameterizedCodecs: () => [], + create() { + return Object.assign( + { familyId: 'sql' as const, targetId: 'postgres' as const }, + adapter, + ) as SqlRuntimeAdapterInstance<'postgres'>; + }, + }; +} + +function createTestSetup(middleware: readonly SqlMiddleware[]) { + const adapter = createStubAdapter(); + const driver = createMockDriver(); + + const targetDescriptor = createTestTargetDescriptor(); + const adapterDescriptor = createTestAdapterDescriptor(adapter); + + const stack = createSqlExecutionStack({ + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }); + type SqlTestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + RuntimeExtensionInstance<'sql', 'postgres'> + >; + const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; + + const context = createExecutionContext({ + contract: testContract, + stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + }); + + const runtime = createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + + return { runtime }; +} + +function createRawExecutionPlan(): SqlExecutionPlan { + return { + sql: 'select 1', + params: [], + meta: { + target: testContract.target, + targetFamily: testContract.targetFamily, + storageHash: testContract.storage.storageHash, + lane: 'raw', + }, + }; +} + +describe('SQL runtime scope plumbing', () => { + it('populates ctx.scope = "runtime" on top-level runtime.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + await runtime.execute(createRawExecutionPlan()).toArray(); + + expect(seen).toEqual(['runtime']); + }); + + it('populates ctx.scope = "connection" on connection.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + const connection = await runtime.connection(); + try { + await connection.execute(createRawExecutionPlan()).toArray(); + } finally { + await connection.release(); + } + + expect(seen).toEqual(['connection']); + }); + + it('populates ctx.scope = "transaction" on transaction.execute', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + const connection = await runtime.connection(); + const transaction = await connection.transaction(); + try { + await transaction.execute(createRawExecutionPlan()).toArray(); + await transaction.commit(); + } finally { + await connection.release(); + } + + expect(seen).toEqual(['transaction']); + }); + + it('routes a sequence of executes to the right scope each time', async () => { + const seen: Array<'runtime' | 'connection' | 'transaction'> = []; + const observer: SqlMiddleware = { + name: 'scope-observer', + familyId: 'sql', + async beforeExecute(_plan, ctx) { + seen.push(ctx.scope); + }, + }; + + const { runtime } = createTestSetup([observer]); + + // Top-level. + await runtime.execute(createRawExecutionPlan()).toArray(); + + // Connection-scoped. + const connection = await runtime.connection(); + await connection.execute(createRawExecutionPlan()).toArray(); + + // Transaction-scoped. + const transaction = await connection.transaction(); + await transaction.execute(createRawExecutionPlan()).toArray(); + await transaction.commit(); + await connection.release(); + + // And another top-level after returning the connection to the pool. + await runtime.execute(createRawExecutionPlan()).toArray(); + + expect(seen).toEqual(['runtime', 'connection', 'transaction', 'runtime']); + }); +}); diff --git a/packages/3-extensions/middleware-cache/README.md b/packages/3-extensions/middleware-cache/README.md new file mode 100644 index 0000000000..d8072f5b1d --- /dev/null +++ b/packages/3-extensions/middleware-cache/README.md @@ -0,0 +1,149 @@ +# @prisma-next/middleware-cache + +A family-agnostic, opt-in caching middleware for Prisma Next runtimes. + +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 `@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 `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`). +- 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'`. +- Ship a default in-memory LRU-with-TTL `CacheStore` and expose the `CacheStore` interface for pluggable backends (Redis, Memcached, etc.). + +## Dependencies + +- `@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 `@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 '@prisma-next/postgres/runtime'; +import { + cacheAnnotation, + createCacheMiddleware, +} from '@prisma-next/middleware-cache'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [createCacheMiddleware({ maxEntries: 1000 })], +}); + +// First call: hits the database, caches the raw rows. +const first = await db.orm.User.first({ id: 1 }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60_000 })), +); + +// Second call with the identical plan: served from cache, driver +// not invoked. +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 +``` + +## Opt-in by annotation + +The cache middleware acts only on plans that carry a `cacheAnnotation` payload with a `ttl` set: + +| Annotation 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. | +| `cacheAnnotation({ ttl })` | Cache lookup; commit on miss + success. | +| `cacheAnnotation({ ttl, key })` | As above, but use the supplied key verbatim. | + +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. + +```typescript +// ✓ ORM read terminal accepts the read-only annotation via the meta callback. +await db.orm.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 }))); + +// ✗ Type error: write terminal rejects read-only annotation. +await db.orm.User.create(input, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 }))); + +// ✓ SQL DSL: chainable on select / grouped builders. +const plan = db.sql + .from(tables.user) + .select({ id: tables.user.columns.id }) + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); +``` + +## Cache key composition + +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`. +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. + +Two consequences worth pinning: + +- **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. + +## `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 '@prisma-next/middleware-cache'; + +const redis: CacheStore = { + async get(key) { + const raw = await redisClient.get(key); + return raw ? (JSON.parse(raw) as CachedEntry) : undefined; + }, + async set(key, entry, ttlMs) { + await redisClient.set(key, JSON.stringify(entry), 'PX', ttlMs); + }, +}; + +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. + +## Transaction-scope guard + +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().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 + +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 `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. +- **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. + +## See also + +- [Spec](../../../projects/middleware-intercept-and-cache/spec.md) and [plan](../../../projects/middleware-intercept-and-cache/plan.md) for the design rationale and milestone-by-milestone rollout. +- [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. +- `@prisma-next/middleware-telemetry` — companion observability middleware that observes `source: 'driver' | 'middleware'` on `afterExecute` and can be composed alongside the cache. diff --git a/packages/3-extensions/middleware-cache/biome.jsonc b/packages/3-extensions/middleware-cache/biome.jsonc new file mode 100644 index 0000000000..b8994a7330 --- /dev/null +++ b/packages/3-extensions/middleware-cache/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "extends": "//" +} diff --git a/packages/3-extensions/middleware-cache/package.json b/packages/3-extensions/middleware-cache/package.json new file mode 100644 index 0000000000..b587f0026e --- /dev/null +++ b/packages/3-extensions/middleware-cache/package.json @@ -0,0 +1,44 @@ +{ + "name": "@prisma-next/middleware-cache", + "version": "0.0.1", + "type": "module", + "sideEffects": false, + "description": "Family-agnostic caching middleware for Prisma Next runtimes", + "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 .", + "lint:fix:unsafe": "biome check --write --unsafe .", + "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output" + }, + "dependencies": { + "@prisma-next/framework-components": "workspace:*" + }, + "devDependencies": { + "@prisma-next/contract": "workspace:*", + "@prisma-next/tsconfig": "workspace:*", + "@prisma-next/tsdown": "workspace:*", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "files": [ + "dist", + "src" + ], + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "repository": { + "type": "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 new file mode 100644 index 0000000000..05f63a43f6 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-annotation.ts @@ -0,0 +1,61 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; + +/** + * Payload accepted when calling the `cacheAnnotation` handle. + * + * - `ttl` — Time-to-live for the cached entry, in milliseconds. When + * omitted, the cache middleware passes the query through untouched — + * presence of the annotation alone is not sufficient to enable caching. + * This makes the cache strictly opt-in per query. + * - `skip` — When `true`, the cache middleware passes the query through + * untouched even if a `ttl` is set. Useful for selectively bypassing + * the cache on a per-call basis without removing the annotation + * entirely (e.g. a "force refresh" knob in user code). + * - `key` — Per-query override of the cache key. When supplied, replaces + * the default `RuntimeMiddlewareContext.contentHash(exec)` digest. + * The supplied string is stored as-is — the cache middleware does + * **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. + */ +export interface CachePayload { + readonly ttl?: number; + readonly skip?: boolean; + readonly key?: string; +} + +/** + * Read-only annotation handle for the cache middleware. + * + * Declared with `applicableTo: ['read']`, which gates the type-level + * `ValidAnnotations<'write', As>` and the runtime + * `assertAnnotationsApplicable(annotations, 'write', ...)` checks at every + * lane write terminal — making "cache a mutation" structurally + * impossible without a `as any` cast bypass at both type *and* runtime + * levels. + * + * Stored under namespace `'cache'` in `plan.meta.annotations`. The cache + * middleware reads it via `cacheAnnotation.read(plan)`. + * + * @example + * ```typescript + * import { cacheAnnotation } from '@prisma-next/middleware-cache'; + * + * // ORM read terminal — accepts the read-only annotation. + * const user = await db.User.first( + * { id }, + * cacheAnnotation({ ttl: 60_000 }), + * ); + * + * // SQL DSL select builder — chainable. + * const plan = db.sql + * .from(tables.user) + * .annotate(cacheAnnotation({ ttl: 60_000 })) + * .select({ id: tables.user.columns.id }) + * .build(); + * ``` + */ +export const cacheAnnotation = defineAnnotation()({ + namespace: 'cache', + applicableTo: ['read'], +}); diff --git a/packages/3-extensions/middleware-cache/src/cache-middleware.ts b/packages/3-extensions/middleware-cache/src/cache-middleware.ts new file mode 100644 index 0000000000..d6168a9dd4 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-middleware.ts @@ -0,0 +1,235 @@ +import type { + AfterExecuteResult, + ExecutionPlan, + RuntimeMiddleware, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { type CachePayload, cacheAnnotation } from './cache-annotation'; +import { type CacheStore, createInMemoryCacheStore } from './cache-store'; + +/** + * Options accepted by `createCacheMiddleware`. + * + * - `store` — pluggable cache backend. Defaults to an in-process LRU + * produced by `createInMemoryCacheStore`. Users supply Redis, + * Memcached, or any other backend by implementing the `CacheStore` + * interface. + * - `maxEntries` — only consulted when `store` is omitted. Sets the + * `maxEntries` cap on the default in-memory store. Defaults to 1000. + * - `clock` — injectable time source for `storedAt` stamping on + * committed entries. Defaults to `Date.now`. Tests inject a controlled + * 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. + */ +export interface CacheMiddlewareOptions { + readonly store?: CacheStore; + readonly maxEntries?: number; + readonly clock?: () => number; +} + +/** + * Per-execution buffer correlated with the post-lowering `exec` object + * via a private `WeakMap`. Each in-flight cache miss owns one of these. + * + * 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 + * `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. + */ +interface PendingMiss { + readonly key: string; + readonly ttlMs: number; + readonly buffer: Record[]; +} + +/** + * Default `maxEntries` for the built-in in-memory store. Bounded so a + * runaway producer cannot exhaust process memory; users who need + * different bounds supply a custom `CacheStore`. + */ +const DEFAULT_MAX_ENTRIES = 1000; + +/** + * Reads the cache payload from the plan, if present and branded. + * + * Returns `undefined` when: + * - the plan has no `meta.annotations`, or + * - the `cache` namespace key is absent, or + * - the value under `cache` is not a branded `AnnotationValue` (the + * `cacheAnnotation.read` defensive check covers this). + */ +function readCachePayload(plan: ExecutionPlan): CachePayload | undefined { + return cacheAnnotation.read(plan); +} + +/** + * Computes the cache key for an execution. + * + * Two-tier resolution: + * + * 1. Per-query override: `cacheAnnotation({ key })` — the supplied + * string is used verbatim. Not rehashed; the user is responsible for + * keeping the string bounded and free of sensitive data. + * 2. Default: `ctx.contentHash(exec)` — the family runtime owns this and + * returns an opaque, bounded digest (SHA-512 in the SQL and Mongo + * runtimes today). + * + * The returned string is consumed directly as the `Map` key + * by the underlying `CacheStore`; the cache middleware does not perform + * any further transformation. + */ +async function resolveCacheKey( + payload: CachePayload, + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, +): Promise { + if (payload.key !== undefined) { + return payload.key; + } + return ctx.contentHash(exec); +} + +/** + * Creates a family-agnostic caching middleware. + * + * The middleware uses three hooks: + * + * - `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. + * - `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 + * - `ctx.scope !== 'runtime'` (connection / transaction scopes opt out). + * + * Returns a cross-family `RuntimeMiddleware` (no `familyId` / + * `targetId`). The package depends only on + * `@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 '@prisma-next/middleware-cache'; + * + * const db = postgres({ + * contractJson, + * url: process.env['DATABASE_URL']!, + * middleware: [createCacheMiddleware({ maxEntries: 1000 })], + * }); + * + * const users = await db.User.first( + * { id }, + * cacheAnnotation({ ttl: 60_000 }), + * ); + * ``` + */ +export function createCacheMiddleware( + options?: CacheMiddlewareOptions, +): RuntimeMiddleware & { readonly familyId?: undefined; readonly targetId?: undefined } { + const store = + options?.store ?? + createInMemoryCacheStore({ + maxEntries: options?.maxEntries ?? DEFAULT_MAX_ENTRIES, + }); + 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 `afterExecute` firing (e.g. an early throw before + // `runWithMiddleware` even starts), the entry is GC'd alongside the + // exec object. + const pending = new WeakMap(); + + async function intercept( + exec: ExecutionPlan, + ctx: RuntimeMiddlewareContext, + ): Promise<{ readonly rows: Iterable> } | undefined> { + if (ctx.scope !== 'runtime') { + return undefined; + } + + const payload = readCachePayload(exec); + if (payload === undefined) { + return undefined; + } + if (payload.skip === true) { + return undefined; + } + if (payload.ttl === undefined) { + return undefined; + } + + const key = await resolveCacheKey(payload, exec, ctx); + const hit = await store.get(key); + if (hit !== undefined) { + ctx.log.debug?.({ event: 'middleware.cache.hit', middleware: 'cache', key }); + 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 }); + return undefined; + } + + async function onRow( + row: Record, + exec: ExecutionPlan, + _ctx: RuntimeMiddlewareContext, + ): Promise { + const slot = pending.get(exec); + if (slot === undefined) { + return; + } + slot.buffer.push(row); + } + + async function afterExecute( + exec: ExecutionPlan, + result: AfterExecuteResult, + ctx: RuntimeMiddlewareContext, + ): Promise { + 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; + } + + await store.set(slot.key, { rows: slot.buffer, storedAt: clock() }, slot.ttlMs); + ctx.log.debug?.({ event: 'middleware.cache.store', middleware: 'cache', key: slot.key }); + } + + return { + name: 'cache', + intercept, + onRow, + afterExecute, + }; +} diff --git a/packages/3-extensions/middleware-cache/src/cache-store.ts b/packages/3-extensions/middleware-cache/src/cache-store.ts new file mode 100644 index 0000000000..c9e2c865c2 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/cache-store.ts @@ -0,0 +1,139 @@ +/** + * A cached set of rows produced by a single execution. + * + * - `rows` are stored raw (undecoded). The SQL runtime's `decodeRow` pass + * wraps the orchestrator output, so intercepted rows go through the + * same codec decoding as driver rows on the way to the consumer. The + * cache stores wire-format values; decoding happens once per consumer + * read regardless of where the rows came from. + * - `storedAt` is the clock value at the moment the entry was committed + * to the store. It is informational metadata for callers (debugging, + * 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. + */ +export interface CachedEntry { + readonly rows: readonly Record[]; + readonly storedAt: number; +} + +/** + * Pluggable cache backend used by the cache middleware. + * + * The default implementation is an in-memory LRU with TTL produced by + * `createInMemoryCacheStore`. Users can supply Redis, Memcached, or any + * other backend by implementing this interface. + * + * The interface is intentionally minimal: + * + * - `get` returns the entry if it exists and has not expired, or + * `undefined` otherwise. Implementations that gate on TTL should + * treat an expired entry as absent (return `undefined`) and may + * evict it as a side effect. + * - `set` writes the entry under the key with an associated TTL in + * milliseconds. Implementations may evict other entries to make + * room (LRU, LFU, etc.) and may treat the operation as fire-and- + * 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 + * (Redis, S3, etc.). The default in-memory store completes + * synchronously and wraps the result in `Promise.resolve` for type + * conformance. + */ +export interface CacheStore { + get(key: string): Promise; + set(key: string, entry: CachedEntry, ttlMs: number): Promise; +} + +/** + * Options accepted by `createInMemoryCacheStore`. + * + * - `maxEntries` — hard cap on the number of live entries. Once the cap + * is exceeded, the least recently used entry is evicted. Reads and + * writes both count as "uses" for ordering purposes. + * - `clock` — injectable time source for TTL math. Defaults to + * `Date.now`. Tests inject a controlled clock to verify expiry without + * real-time waits. + */ +export interface InMemoryCacheStoreOptions { + readonly maxEntries: number; + readonly clock?: () => number; +} + +interface StoredRecord { + readonly entry: CachedEntry; + readonly expiresAt: number; +} + +/** + * Default cache backend. An LRU with per-entry TTL, backed by a `Map`. + * + * Eviction policy: + * + * - On `set` of a fresh key whose insertion would push the live count + * above `maxEntries`, the least recently used entry is evicted. + * Setting an existing key updates the entry in place and refreshes its + * recency without changing the live count. + * - On `get` of an existing key, recency is bumped (so the entry is no + * longer the LRU candidate). + * - On `get` of an expired entry, the entry is removed from the map and + * `undefined` is returned. The slot becomes available for new writes + * without counting against `maxEntries`. + * + * `Map` insertion order is the LRU order: the first key is the LRU + * candidate; the last key is the most recently used. Bumping recency is + * a delete-then-set on the underlying map. + * + * The default store is **not** coherent across processes or replicas — + * each process holds its own Map. Users who need a shared cache supply + * their own `CacheStore` (Redis, Memcached, etc.). + */ +export function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): CacheStore { + const maxEntries = options.maxEntries; + const clock = options.clock ?? Date.now; + const map = new Map(); + + function get(key: string): Promise { + const record = map.get(key); + if (record === undefined) { + return Promise.resolve(undefined); + } + if (clock() >= record.expiresAt) { + map.delete(key); + return Promise.resolve(undefined); + } + // Bump recency: re-insert at the end of the iteration order. + map.delete(key); + map.set(key, record); + return Promise.resolve(record.entry); + } + + function set(key: string, entry: CachedEntry, ttlMs: number): Promise { + 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 + // (most recently used) rather than retaining the old slot's + // position. This matters for LRU correctness when the same key is + // re-cached after a refresh. + if (map.has(key)) { + map.delete(key); + } + map.set(key, { entry, expiresAt }); + + // Evict LRU entries until the live count is within bounds. The + // iterator yields keys in insertion order; the first one is the + // oldest (LRU). + while (map.size > maxEntries) { + const oldest = map.keys().next(); + if (oldest.done) { + break; + } + map.delete(oldest.value); + } + + return Promise.resolve(); + } + + return { get, set }; +} diff --git a/packages/3-extensions/middleware-cache/src/exports/index.ts b/packages/3-extensions/middleware-cache/src/exports/index.ts new file mode 100644 index 0000000000..9abc87a6c3 --- /dev/null +++ b/packages/3-extensions/middleware-cache/src/exports/index.ts @@ -0,0 +1,6 @@ +export type { CachePayload } from '../cache-annotation'; +export { cacheAnnotation } from '../cache-annotation'; +export type { CacheMiddlewareOptions } from '../cache-middleware'; +export { createCacheMiddleware } from '../cache-middleware'; +export type { CachedEntry, CacheStore, InMemoryCacheStoreOptions } from '../cache-store'; +export { createInMemoryCacheStore } from '../cache-store'; diff --git a/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts new file mode 100644 index 0000000000..c9d4828306 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.test.ts @@ -0,0 +1,65 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import { describe, expect, it } from 'vitest'; +import { type CachePayload, cacheAnnotation } from '../src/cache-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('cacheAnnotation handle', () => { + it('declares namespace "cache"', () => { + expect(cacheAnnotation.namespace).toBe('cache'); + }); + + it('declares applicableTo = ["read"]', () => { + expect(Array.from(cacheAnnotation.applicableTo)).toEqual(['read']); + }); + + it('produces an applied annotation under namespace "cache" carrying the payload', () => { + const applied = cacheAnnotation({ ttl: 60 }); + + expect(applied.namespace).toBe('cache'); + expect(applied.value).toEqual({ ttl: 60 }); + expect(Array.from(applied.applicableTo)).toEqual(['read']); + }); + + it('round-trips a payload via call -> read on a plan', () => { + const applied = cacheAnnotation({ ttl: 60 }); + const plan = planWith({ cache: applied }); + + const recovered = cacheAnnotation.read(plan); + expect(recovered).toEqual({ ttl: 60 }); + }); + + it('returns undefined when reading a plan without a cache annotation', () => { + const plan = planWith({}); + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('returns undefined when the plan has no annotations bag at all', () => { + const plan: { readonly meta: PlanMeta } = { meta: baseMeta }; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('preserves all CachePayload fields (ttl, skip, key)', () => { + const payload: CachePayload = { ttl: 120, skip: false, key: 'custom-key' }; + const applied = cacheAnnotation(payload); + const plan = planWith({ cache: applied }); + + expect(cacheAnnotation.read(plan)).toEqual(payload); + }); + + it('accepts an empty payload', () => { + const applied = cacheAnnotation({}); + const plan = planWith({ cache: applied }); + + expect(cacheAnnotation.read(plan)).toEqual({}); + }); +}); 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 new file mode 100644 index 0000000000..8f40a3996d --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-annotation.types.test-d.ts @@ -0,0 +1,83 @@ +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; +import { assertType, expectTypeOf, test } from 'vitest'; +import { type CachePayload, cacheAnnotation } from '../src/cache-annotation'; + +test('cacheAnnotation call signature preserves the CachePayload type', () => { + const applied = cacheAnnotation({ ttl: 60 }); + expectTypeOf(applied).toEqualTypeOf>(); +}); + +test('cacheAnnotation call rejects non-CachePayload arguments', () => { + // @ts-expect-error - unknown field on payload + cacheAnnotation({ ttl: 60, nonsense: true }); + + // @ts-expect-error - wrong field type + cacheAnnotation({ ttl: '60' }); + + // @ts-expect-error - wrong field type + cacheAnnotation({ skip: 'yes' }); +}); + +test('cacheAnnotation.read returns CachePayload | undefined', () => { + const plan = { + meta: { + target: 'postgres', + targetFamily: 'sql' as const, + storageHash: 'sha256:test', + lane: 'orm', + paramDescriptors: [], + annotations: {} as Record, + }, + }; + const result = cacheAnnotation.read(plan); + expectTypeOf(result).toEqualTypeOf(); +}); + +test('cacheAnnotation declares applicableTo = "read" only', () => { + // The handle's Kinds parameter is the literal type 'read', not the wider + // OperationKind union. This is what gates write terminals from accepting + // it via ValidAnnotations<'write', As>. + expectTypeOf(cacheAnnotation.applicableTo).toEqualTypeOf>(); +}); + +test('CachePayload has optional ttl, skip, and key', () => { + const empty: CachePayload = {}; + void empty; + + const ttlOnly: CachePayload = { ttl: 60 }; + void ttlOnly; + + const skipOnly: CachePayload = { skip: true }; + void skipOnly; + + const keyOnly: CachePayload = { key: 'k' }; + void keyOnly; + + const all: CachePayload = { ttl: 60, skip: false, key: 'k' }; + void all; +}); + +test('cacheAnnotation is not applicable to write operations at the type level', () => { + // The handle's literal Kinds = 'read'. The applicableTo set type is + // `ReadonlySet<'read'>`, not `ReadonlySet` — so a + // consumer asking whether 'write' is in the kind set sees `false`. + type Kinds = typeof cacheAnnotation extends { + readonly applicableTo: ReadonlySet; + } + ? K + : never; + type WriteApplies = 'write' extends Kinds ? true : false; + assertType(false as WriteApplies); + + // And the AnnotationValue produced by calling the handle carries 'read' + // specifically, so ValidAnnotations<'write', [typeof applied]> resolves to [never]. + const applied = cacheAnnotation({ ttl: 60 }); + expectTypeOf(applied).toExtend>(); + // The applied value is NOT assignable to AnnotationValue. + expectTypeOf(applied).not.toExtend>(); +}); + +test('OperationKind import is not accidentally widened by cacheAnnotation', () => { + // Sanity: the framework's OperationKind union is unchanged. + expectTypeOf().toEqualTypeOf<'read' | 'write'>(); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-key.test.ts b/packages/3-extensions/middleware-cache/test/cache-key.test.ts new file mode 100644 index 0000000000..9a0009f238 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-key.test.ts @@ -0,0 +1,296 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import type { + ExecutionPlan, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { cacheAnnotation } from '../src/cache-annotation'; +import { createCacheMiddleware } from '../src/cache-middleware'; +import type { CachedEntry, CacheStore } from '../src/cache-store'; + +interface MockExec extends ExecutionPlan { + readonly statement: string; +} + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function makeExec(statement: string, annotations?: Record): MockExec { + return Object.freeze({ + statement, + meta: annotations ? { ...baseMeta, annotations } : baseMeta, + }); +} + +function makeCtx(overrides?: Partial): RuntimeMiddlewareContext { + return { + contract: {}, + mode: 'strict', + now: () => Date.now(), + log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + contentHash: async (exec) => `id:${(exec as MockExec).statement}`, + scope: 'runtime', + ...overrides, + }; +} + +function spyStore(): CacheStore & { + readonly getSpy: ReturnType; + readonly setSpy: ReturnType; + readonly inner: Map; +} { + const inner = new Map(); + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { + inner.set(key, entry); + }); + return { get: getSpy, set: setSpy, getSpy, setSpy, inner }; +} + +async function drain(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('cache key resolution', () => { + describe('default path: ctx.contentHash(exec)', () => { + it('uses the contentHash return value as the cache map key (no rehashing)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss → store.get and store.set both called with the contentHash. + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.getSpy).toHaveBeenCalledWith('id:select 1'); + expect(store.setSpy).toHaveBeenCalledWith('id:select 1', expect.anything(), 60_000); + }); + + it('invokes ctx.contentHash when no per-query key annotation is supplied', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const contentHash = vi.fn(async (e: ExecutionPlan) => `derived:${(e as MockExec).statement}`); + const ctx = makeCtx({ contentHash }); + + await mw.intercept!(exec, ctx); + + expect(contentHash).toHaveBeenCalledTimes(1); + expect(contentHash).toHaveBeenCalledWith(exec); + expect(store.getSpy).toHaveBeenCalledWith('derived:select 1'); + }); + + it('produces distinct cache entries for two execs with distinct contentHash returns', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const execA = makeExec('A', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const execB = makeExec('B', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss + commit for A. + await mw.intercept!(execA, ctx); + await mw.onRow!({ from: 'A' }, execA, ctx); + await mw.afterExecute!( + execA, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Miss + commit for B. + await mw.intercept!(execB, ctx); + await mw.onRow!({ from: 'B' }, execB, ctx); + await mw.afterExecute!( + execB, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.inner.size).toBe(2); + expect(store.inner.get('id:A')?.rows).toEqual([{ from: 'A' }]); + expect(store.inner.get('id:B')?.rows).toEqual([{ from: 'B' }]); + }); + }); + + describe('per-query override: cacheAnnotation({ key })', () => { + it('uses the user-supplied key in place of ctx.contentHash', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000, key: 'custom-key' }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.getSpy).toHaveBeenCalledWith('custom-key'); + expect(store.setSpy).toHaveBeenCalledWith('custom-key', expect.anything(), 60_000); + }); + + it('does not invoke ctx.contentHash when an override key is supplied', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000, key: 'custom-key' }), + }); + const contentHash = vi.fn(async () => 'should-not-be-used'); + const ctx = makeCtx({ contentHash }); + + await mw.intercept!(exec, ctx); + + expect(contentHash).not.toHaveBeenCalled(); + }); + + it('stores user-supplied keys verbatim (no rehashing)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + // A long, structured user key — verify the middleware does not + // mangle, hash, or otherwise transform it. + const userKey = 'tenant=acme|user=alice|page=42'; + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000, key: userKey }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.inner.has(userKey)).toBe(true); + }); + + it('produces a hit using the user-supplied key when previously committed under it', async () => { + const store = spyStore(); + store.inner.set('shared-key', { + rows: [{ id: 'pre-cached' }], + storedAt: 0, + }); + + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select anything', { + cache: cacheAnnotation({ ttl: 60_000, key: 'shared-key' }), + }); + const ctx = makeCtx(); + + const result = await mw.intercept!(exec, ctx); + expect(result).toBeDefined(); + expect(await drain(result!.rows as AsyncIterable>)).toEqual([ + { id: 'pre-cached' }, + ]); + }); + }); + + describe('cross-family parity', () => { + it('works with a Mongo-style contentHash return value (no SQL fields read)', async () => { + // The cache middleware must not read exec.sql, exec.command, or any + // family-specific field. Use a "Mongo-shaped" mock plan and a + // Mongo-style contentHash to demonstrate the package is genuinely + // family-agnostic. + interface MongoLikeExec extends ExecutionPlan { + readonly command: { readonly kind: string; readonly filter: unknown }; + } + + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + + const exec: MongoLikeExec = Object.freeze({ + command: { kind: 'find', filter: { active: true } }, + meta: { + ...baseMeta, + target: 'mongo', + targetFamily: 'mongo', + annotations: { + cache: cacheAnnotation({ ttl: 60_000 }), + }, + }, + }); + + const ctx = makeCtx({ + contentHash: async () => 'mongo:users:find:{active:true}', + }); + + // Miss + commit. + await mw.intercept!(exec, ctx); + await mw.onRow!({ _id: 'a', active: true }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Hit on the second call. + const second = await mw.intercept!(exec, ctx); + expect(second).toBeDefined(); + expect(await drain(second!.rows as AsyncIterable>)).toEqual([ + { _id: 'a', active: true }, + ]); + expect(store.inner.has('mongo:users:find:{active:true}')).toBe(true); + }); + + it('two distinct contentHash returns produce two distinct cache entries', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const exec = makeExec('shared statement', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + + // Same exec object but two different ctx.contentHash returns — + // simulating two calls where the family runtime computed different + // canonical keys (e.g. a parameter changed but the AST/command is + // structurally identical at this view). + const ctxA = makeCtx({ contentHash: async () => 'key-A' }); + const ctxB = makeCtx({ contentHash: async () => 'key-B' }); + + // Commit under key-A. + await mw.intercept!(exec, ctxA); + await mw.onRow!({ from: 'A' }, exec, ctxA); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctxA, + ); + + // Commit under key-B. + await mw.intercept!(exec, ctxB); + await mw.onRow!({ from: 'B' }, exec, ctxB); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctxB, + ); + + expect(store.inner.size).toBe(2); + expect(store.inner.get('key-A')?.rows).toEqual([{ from: 'A' }]); + expect(store.inner.get('key-B')?.rows).toEqual([{ from: 'B' }]); + }); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts new file mode 100644 index 0000000000..4e2362c643 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-middleware.test.ts @@ -0,0 +1,491 @@ +import type { PlanMeta } from '@prisma-next/contract/types'; +import type { + AfterExecuteResult, + ExecutionPlan, + RuntimeMiddlewareContext, +} from '@prisma-next/framework-components/runtime'; +import { describe, expect, it, vi } from 'vitest'; +import { cacheAnnotation } from '../src/cache-annotation'; +import { createCacheMiddleware } from '../src/cache-middleware'; +import { type CachedEntry, type CacheStore, createInMemoryCacheStore } from '../src/cache-store'; + +interface MockExec extends ExecutionPlan { + readonly statement: string; +} + +const baseMeta: PlanMeta = { + target: 'postgres', + targetFamily: 'sql', + storageHash: 'sha256:test', + lane: 'orm', +}; + +function makeExec(statement: string, annotations?: Record): MockExec { + return Object.freeze({ + statement, + meta: annotations ? { ...baseMeta, annotations } : baseMeta, + }); +} + +function makeCtx(overrides?: Partial): RuntimeMiddlewareContext { + return { + contract: {}, + mode: 'strict', + now: () => Date.now(), + log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + contentHash: async (exec) => `key:${(exec as MockExec).statement}`, + scope: 'runtime', + ...overrides, + }; +} + +function spyStore(): CacheStore & { + readonly getSpy: ReturnType; + readonly setSpy: ReturnType; + readonly inner: Map; +} { + const inner = new Map(); + const getSpy = vi.fn(async (key: string) => inner.get(key)); + const setSpy = vi.fn(async (key: string, entry: CachedEntry, _ttlMs: number) => { + inner.set(key, entry); + }); + return { + get: getSpy, + set: setSpy, + getSpy, + setSpy, + inner, + }; +} + +async function drain(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('createCacheMiddleware — opt-in semantics', () => { + it('passes through (no store interaction) when the plan has no cache annotation', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1'); // no annotations + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('passes through when the cache annotation has skip: true', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000, skip: true }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('passes through when no ttl is supplied (presence alone is not sufficient)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({}), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('does not store rows for an un-annotated plan even when onRow/afterExecute fire (driver path)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1'); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); // passthrough + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createCacheMiddleware — hit path', () => { + it('returns cached rows from intercept when the store has a non-expired entry', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { + rows: [{ id: 1 }, { id: 2 }], + storedAt: 0, + }); + + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeDefined(); + expect(await drain(result!.rows as AsyncIterable>)).toEqual([ + { id: 1 }, + { id: 2 }, + ]); + }); + + it('logs a middleware.cache.hit event via ctx.log.debug on a hit', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const debug = vi.fn(); + const ctx = makeCtx({ + log: { info: () => {}, warn: () => {}, error: () => {}, debug }, + }); + + await mw.intercept!(exec, ctx); + + expect(debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'middleware.cache.hit' })); + }); + + it('does not call store.set on the hit path', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + const result = await mw.intercept!(exec, ctx); + await drain(result!.rows as AsyncIterable>); + + // afterExecute fires with source: 'middleware' on a hit; the cache + // middleware should not write back to the store. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'middleware' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('survives the absence of ctx.log.debug (it is optional on RuntimeLog)', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx({ + // No debug field. + log: { info: () => {}, warn: () => {}, error: () => {} }, + }); + + await expect(mw.intercept!(exec, ctx)).resolves.toBeDefined(); + }); +}); + +describe('createCacheMiddleware — miss path', () => { + it('returns undefined from intercept on a miss', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx()); + expect(result).toBeUndefined(); + }); + + it('logs a middleware.cache.miss event via ctx.log.debug on a miss', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const debug = vi.fn(); + const ctx = makeCtx({ + log: { info: () => {}, warn: () => {}, error: () => {}, debug }, + }); + + await mw.intercept!(exec, ctx); + + expect(debug).toHaveBeenCalledWith(expect.objectContaining({ event: 'middleware.cache.miss' })); + }); + + it('buffers rows via onRow and commits on a successful afterExecute (source: driver)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 1_234 }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); // miss + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.onRow!({ id: 2 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 2, latencyMs: 5, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).toHaveBeenCalledTimes(1); + expect(store.setSpy).toHaveBeenCalledWith( + 'key:select 1', + expect.objectContaining({ + rows: [{ id: 1 }, { id: 2 }], + storedAt: 1_234, + }), + 60_000, + ); + }); + + it('does not commit when completed = false (driver threw mid-stream)', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: false, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('does not commit when source = "middleware" (a different interceptor produced the rows)', async () => { + // If another middleware wins the intercept chain, our intercept did + // not fire — we never called set up a buffer. afterExecute would see + // source === 'middleware' and we should not store anything. + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Note: skipping intercept and onRow simulates the case where a + // different interceptor short-circuited execution upstream. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: true, source: 'middleware' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('cleans up its WeakMap entry on afterExecute even when no commit happens', async () => { + // The buffer is a WeakMap keyed on the exec object — testing this + // directly would be brittle; instead, verify behavior: re-running + // afterExecute without an intercept call should be a no-op even if + // the previous run did not commit. + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + // Mid-stream failure. + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: false, source: 'driver' }, + ctx, + ); + + // A second afterExecute (defensive — should never happen in + // practice, but verify cleanup didn't leave residue). + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 5, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); + + it('keeps per-execution buffers isolated across two concurrent execs', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store, clock: () => 0 }); + const execA = makeExec('select A', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const execB = makeExec('select B', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Interleave the two executions to stress per-exec buffer isolation. + await mw.intercept!(execA, ctx); + await mw.intercept!(execB, ctx); + await mw.onRow!({ from: 'A', n: 1 }, execA, ctx); + await mw.onRow!({ from: 'B', n: 1 }, execB, ctx); + await mw.onRow!({ from: 'A', n: 2 }, execA, ctx); + await mw.onRow!({ from: 'B', n: 2 }, execB, ctx); + + const result: AfterExecuteResult = { + rowCount: 2, + latencyMs: 0, + completed: true, + source: 'driver', + }; + await mw.afterExecute!(execA, result, ctx); + await mw.afterExecute!(execB, result, ctx); + + expect(store.inner.get('key:select A')?.rows).toEqual([ + { from: 'A', n: 1 }, + { from: 'A', n: 2 }, + ]); + expect(store.inner.get('key:select B')?.rows).toEqual([ + { from: 'B', n: 1 }, + { from: 'B', n: 2 }, + ]); + }); +}); + +describe('createCacheMiddleware — scope guard', () => { + it('passes through when ctx.scope = "connection"', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx({ scope: 'connection' })); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + }); + + it('passes through when ctx.scope = "transaction"', async () => { + const store = spyStore(); + store.inner.set('key:select 1', { rows: [{ id: 1 }], storedAt: 0 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + + const result = await mw.intercept!(exec, makeCtx({ scope: 'transaction' })); + expect(result).toBeUndefined(); + expect(store.getSpy).not.toHaveBeenCalled(); + }); + + it('does not store rows on connection-scope writes either', async () => { + const store = spyStore(); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select 1', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx({ scope: 'connection' }); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + expect(store.setSpy).not.toHaveBeenCalled(); + }); +}); + +describe('createCacheMiddleware — middleware shape', () => { + it('is a cross-family middleware (no familyId)', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.familyId).toBeUndefined(); + expect(mw.targetId).toBeUndefined(); + }); + + it('exposes a stable name', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.name).toBe('cache'); + }); + + it('wires intercept, onRow, and afterExecute (only)', () => { + const mw = createCacheMiddleware({ store: spyStore() }); + expect(mw.intercept).toBeDefined(); + expect(mw.onRow).toBeDefined(); + expect(mw.afterExecute).toBeDefined(); + // No beforeExecute — the cache middleware doesn't observe the pre- + // execute event. + expect(mw.beforeExecute).toBeUndefined(); + }); + + it('defaults to an in-memory LRU store when none is supplied', () => { + // Smoke: the constructor accepts no store and produces a working + // middleware. Behavior is exercised by the roundtrip test below. + const mw = createCacheMiddleware(); + expect(mw.intercept).toBeDefined(); + }); + + it('roundtrips a miss-then-hit through the default in-memory store', async () => { + const mw = createCacheMiddleware({ maxEntries: 10 }); + const exec = makeExec('select roundtrip', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + // Miss. + expect(await mw.intercept!(exec, ctx)).toBeUndefined(); + await mw.onRow!({ id: 1 }, exec, ctx); + await mw.onRow!({ id: 2 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 2, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + // Hit on the next call. + const second = await mw.intercept!(exec, ctx); + expect(second).toBeDefined(); + expect(await drain(second!.rows as AsyncIterable>)).toEqual([ + { id: 1 }, + { id: 2 }, + ]); + }); + + it('respects a user-supplied custom CacheStore', async () => { + const store = createInMemoryCacheStore({ maxEntries: 5 }); + const mw = createCacheMiddleware({ store }); + const exec = makeExec('select custom', { + cache: cacheAnnotation({ ttl: 60_000 }), + }); + const ctx = makeCtx(); + + await mw.intercept!(exec, ctx); + await mw.onRow!({ id: 7 }, exec, ctx); + await mw.afterExecute!( + exec, + { rowCount: 1, latencyMs: 0, completed: true, source: 'driver' }, + ctx, + ); + + const stored = await store.get('key:custom-not-this'); + expect(stored).toBeUndefined(); + const real = await store.get('key:select custom'); + expect(real?.rows).toEqual([{ id: 7 }]); + }); +}); diff --git a/packages/3-extensions/middleware-cache/test/cache-store.test.ts b/packages/3-extensions/middleware-cache/test/cache-store.test.ts new file mode 100644 index 0000000000..f27fbda0b0 --- /dev/null +++ b/packages/3-extensions/middleware-cache/test/cache-store.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { type CachedEntry, type CacheStore, createInMemoryCacheStore } from '../src/cache-store'; + +function entry(rows: ReadonlyArray>, storedAt = 0): CachedEntry { + return { rows, storedAt }; +} + +describe('createInMemoryCacheStore', () => { + describe('basic get/set', () => { + it('returns undefined for a missing key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + expect(await store.get('absent')).toBeUndefined(); + }); + + it('round-trips a stored entry by key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const stored = entry([{ id: 1 }, { id: 2 }], 0); + await store.set('k', stored, 60_000); + const got = await store.get('k'); + expect(got).toEqual(stored); + }); + + it('overwrites an existing entry on repeated set with the same key', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('k', entry([{ v: 1 }]), 60_000); + await store.set('k', entry([{ v: 2 }]), 60_000); + const got = await store.get('k'); + expect(got?.rows).toEqual([{ v: 2 }]); + }); + + it('keeps distinct entries for distinct keys', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + expect((await store.get('a'))?.rows).toEqual([{ v: 'A' }]); + expect((await store.get('b'))?.rows).toEqual([{ v: 'B' }]); + }); + + it('satisfies the CacheStore interface', () => { + const store: CacheStore = createInMemoryCacheStore({ maxEntries: 10 }); + expect(typeof store.get).toBe('function'); + expect(typeof store.set).toBe('function'); + }); + }); + + describe('LRU eviction at maxEntries', () => { + it('evicts the least recently used entry once maxEntries is exceeded', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect(await store.get('a')).toBeUndefined(); + expect((await store.get('b'))?.rows).toEqual([{ v: 'B' }]); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('treats a get on an existing entry as a "use" for LRU ordering', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + + // Touch 'a' so it becomes most recently used; 'b' is now LRU. + await store.get('a'); + + // Inserting 'c' should evict 'b' (LRU), not 'a' (most recent). + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect((await store.get('a'))?.rows).toEqual([{ v: 'A' }]); + expect(await store.get('b')).toBeUndefined(); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('treats overwriting an existing entry as a "use" for LRU ordering', async () => { + const store = createInMemoryCacheStore({ maxEntries: 2 }); + await store.set('a', entry([{ v: 'A' }]), 60_000); + await store.set('b', entry([{ v: 'B' }]), 60_000); + + // Re-set 'a' so it becomes most recently used. + await store.set('a', entry([{ v: 'A2' }]), 60_000); + + // Inserting 'c' should now evict 'b'. + await store.set('c', entry([{ v: 'C' }]), 60_000); + + expect((await store.get('a'))?.rows).toEqual([{ v: 'A2' }]); + expect(await store.get('b')).toBeUndefined(); + expect((await store.get('c'))?.rows).toEqual([{ v: 'C' }]); + }); + + it('caps the live entry count at maxEntries', async () => { + const store = createInMemoryCacheStore({ maxEntries: 3 }); + for (let i = 0; i < 10; i++) { + await store.set(`k${i}`, entry([{ i }]), 60_000); + } + // Only the most recent 3 keys (k7, k8, k9) survive. + expect(await store.get('k0')).toBeUndefined(); + expect(await store.get('k6')).toBeUndefined(); + expect((await store.get('k7'))?.rows).toEqual([{ i: 7 }]); + expect((await store.get('k8'))?.rows).toEqual([{ i: 8 }]); + expect((await store.get('k9'))?.rows).toEqual([{ i: 9 }]); + }); + }); + + describe('TTL expiry', () => { + it('returns undefined for an entry whose TTL has elapsed (relative to clock)', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 1_000); + // Within TTL. + now = 999; + expect((await store.get('k'))?.rows).toEqual([{ v: 1 }]); + + // At TTL — boundary inclusive (treat reached TTL as expired). + now = 1_000; + expect(await store.get('k')).toBeUndefined(); + }); + + it('returns undefined for an entry whose TTL is well past', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 100); + now = 100_000; + expect(await store.get('k')).toBeUndefined(); + }); + + it('does not expire entries before their TTL', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 60_000); + now = 30_000; + expect((await store.get('k'))?.rows).toEqual([{ v: 1 }]); + }); + + it('uses the current clock time at set() as the TTL reference', async () => { + let now = 1_000; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + // storedAt embedded in the entry by the caller; the store decides + // expiry based on its own clock + the ttlMs passed to set. + await store.set('k', entry([{ v: 1 }], now), 500); + + now = 1_499; + expect(await store.get('k')).toBeDefined(); + + now = 1_500; + expect(await store.get('k')).toBeUndefined(); + }); + + it('drops an expired entry on access (does not retain it for future re-set)', async () => { + let now = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => now, + }); + + await store.set('k', entry([{ v: 1 }], now), 100); + now = 200; + expect(await store.get('k')).toBeUndefined(); + + // After expiry, the slot is free; setting again works without + // counting the expired entry against maxEntries. + await store.set('k', entry([{ v: 2 }], now), 100); + expect((await store.get('k'))?.rows).toEqual([{ v: 2 }]); + }); + }); + + describe('clock injection', () => { + it('defaults to Date.now() when no clock is supplied', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const before = Date.now(); + await store.set('k', entry([{ v: 1 }]), 60_000); + const got = await store.get('k'); + const after = Date.now(); + + expect(got).toBeDefined(); + // The wall-clock check is sufficient to confirm the default clock is + // wired without flakiness — TTL of 60s vs a sub-millisecond test. + expect(after - before).toBeLessThan(60_000); + }); + + it('drives expiry purely from the injected clock', async () => { + let tick = 0; + const store = createInMemoryCacheStore({ + maxEntries: 10, + clock: () => tick, + }); + + await store.set('k', entry([{ v: 1 }]), 10); + tick = 5; + expect(await store.get('k')).toBeDefined(); + tick = 10; + expect(await store.get('k')).toBeUndefined(); + }); + }); + + describe('row immutability', () => { + it('does not lose information across get() round-trips', async () => { + const store = createInMemoryCacheStore({ maxEntries: 10 }); + const original = entry( + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + 42, + ); + await store.set('k', original, 60_000); + const recovered = await store.get('k'); + expect(recovered).toEqual(original); + expect(recovered?.storedAt).toBe(42); + }); + }); +}); diff --git a/packages/3-extensions/middleware-cache/tsconfig.json b/packages/3-extensions/middleware-cache/tsconfig.json new file mode 100644 index 0000000000..7afa587436 --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": ["@prisma-next/tsconfig/base"], + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/packages/3-extensions/middleware-cache/tsconfig.prod.json b/packages/3-extensions/middleware-cache/tsconfig.prod.json new file mode 100644 index 0000000000..b08d4c908a --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsconfig.prod.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["@prisma-next/tsconfig/prod"] +} diff --git a/packages/3-extensions/middleware-cache/tsdown.config.ts b/packages/3-extensions/middleware-cache/tsdown.config.ts new file mode 100644 index 0000000000..696fb96853 --- /dev/null +++ b/packages/3-extensions/middleware-cache/tsdown.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@prisma-next/tsdown'; + +export default defineConfig({ + entry: ['src/exports/index.ts'], +}); diff --git a/packages/3-extensions/middleware-cache/vitest.config.ts b/packages/3-extensions/middleware-cache/vitest.config.ts new file mode 100644 index 0000000000..bb99ddc5b6 --- /dev/null +++ b/packages/3-extensions/middleware-cache/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'dist/**', + 'test/**', + '**/*.test.ts', + '**/*.test-d.ts', + '**/*.config.ts', + '**/exports/**', + ], + }, + }, +}); diff --git a/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts b/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts index 0d8372fea0..9dc940cb02 100644 --- a/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts +++ b/packages/3-extensions/middleware-telemetry/test/telemetry-middleware.test.ts @@ -25,6 +25,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await middleware.beforeExecute!(plan, ctx); @@ -46,6 +47,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; const result = { rowCount: 5, @@ -77,6 +79,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; const result = { rowCount: 3, @@ -108,6 +111,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info, warn: vi.fn(), error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await middleware.beforeExecute!(plan, ctx); @@ -132,6 +136,7 @@ describe('telemetry middleware', () => { now: Date.now, log: { info: vi.fn(), warn, error: vi.fn() }, contentHash: async () => 'mock-hash', + scope: 'runtime' as const, }; await expect(middleware.beforeExecute!(plan, ctx)).resolves.toBeUndefined(); diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index 2367ae3523..c506c0baaf 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -1,5 +1,10 @@ import type { Contract } from '@prisma-next/contract/types'; -import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; +import type { + AnnotationValue, + MetaBuilder, + OperationKind, +} from '@prisma-next/framework-components/runtime'; +import { AsyncIterableResult, createMetaBuilder } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { BinaryExpr, @@ -83,6 +88,7 @@ import { compileUpdateCount, compileUpdateReturning, compileUpsertReturning, + mergeUserAnnotations, } from './query-plan'; import { type AggregateBuilder, @@ -618,19 +624,51 @@ export class Collection< return this.#clone({ offset: n }); } - all(): AsyncIterableResult { - return this.#dispatch(); + /** + * Read terminal: stream all rows matching the current state. + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` so the caller can attach typed user + * annotations to the executed plan. `meta.annotate(...)` enforces + * applicability at the type level and at runtime; annotations are + * merged into `plan.meta.annotations` at compile time. + */ + all(configure?: (meta: MetaBuilder<'read'>) => void): AsyncIterableResult { + return this.#withAnnotationsFromMeta(configure, 'all').#dispatch(); } async first(): Promise; + async first( + filter: undefined, + configure: (meta: MetaBuilder<'read'>) => void, + ): Promise; async first( filter: (model: ModelAccessor) => WhereArg, + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise; - async first(filter: ShorthandWhereFilter): Promise; + async first( + filter: ShorthandWhereFilter, + configure?: (meta: MetaBuilder<'read'>) => void, + ): Promise; + /** + * Read terminal: return the first matching row, or `null`. + * + * Accepts an optional `filter` (function or shorthand) followed by an + * optional `configure` callback that receives a `MetaBuilder<'read'>` + * for attaching typed user annotations. To attach annotations without + * narrowing further, pass `undefined` as the filter (or chain + * `.where(...)` first): + * + * ```typescript + * await db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * await db.User.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + * ``` + */ async first( filter?: | ((model: ModelAccessor) => WhereArg) | ShorthandWhereFilter, + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise { const scoped = filter === undefined @@ -638,13 +676,22 @@ export class Collection< : typeof filter === 'function' ? this.where(filter) : this.where(filter); - const limited = scoped.take(1); + const limited = scoped.take(1).#withAnnotationsFromMeta(configure, 'first'); const rows = await limited.#dispatch().toArray(); return rows[0] ?? null; } + /** + * Read terminal: run an aggregate query (count, sum, avg, min, max) + * built via the `AggregateBuilder` callback. + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` for attaching typed user annotations. + * Annotations are merged into the compiled plan's `meta.annotations`. + */ async aggregate( fn: (aggregate: AggregateBuilder) => Spec, + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise> { const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); const entries = Object.entries(aggregateSpec); @@ -658,11 +705,11 @@ export class Collection< } } - const compiled = compileAggregate( - this.contract, - this.tableName, - this.state.filters, - aggregateSpec, + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'read', 'aggregate'); + + const compiled = mergeUserAnnotations( + compileAggregate(this.contract, this.tableName, this.state.filters, aggregateSpec), + annotationsMap, ); const rows = await executeQueryPlan>( this.ctx.runtime, @@ -671,14 +718,36 @@ export class Collection< return normalizeAggregateResult(aggregateSpec, rows[0] ?? {}); } - async create(data: ResolvedCreateInput): Promise; - async create(data: MutationCreateInputWithRelations): Promise; + async create( + data: ResolvedCreateInput, + configure?: (meta: MetaBuilder<'write'>) => void, + ): Promise; + async create( + data: MutationCreateInputWithRelations, + configure?: (meta: MetaBuilder<'write'>) => void, + ): Promise; + /** + * Write terminal: insert one row and return it. + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. + * Annotations are merged into the compiled mutation plan's + * `meta.annotations`. + * + * Note: when the input contains nested-mutation callbacks, the + * operation is executed as a graph of internal queries via + * `withMutationScope`. In that path, annotations apply to the + * logical `create()` call but do not currently flow into each + * constituent SQL statement — see `projects/middleware-intercept-and-cache/follow-ups.md`. + */ async create( data: | ResolvedCreateInput | MutationCreateInputWithRelations, + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'create()'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'create'); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -698,9 +767,10 @@ export class Collection< return reloaded; } - const rows = await this.createAll([ - data as ResolvedCreateInput, - ]); + const rows = await this.#createAllWithAnnotations( + [data as ResolvedCreateInput], + annotationsMap, + ); const created = rows[0]; if (created) { return created; @@ -711,6 +781,17 @@ export class Collection< createAll( data: readonly ResolvedCreateInput[], + configure?: (meta: MetaBuilder<'write'>) => void, + ): AsyncIterableResult { + return this.#createAllWithAnnotations( + data, + this.#collectAnnotationsFromMeta(configure, 'write', 'createAll'), + ); + } + + #createAllWithAnnotations( + data: readonly ResolvedCreateInput[], + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { if (data.length === 0) { const generator = async function* (): AsyncGenerator {}; @@ -738,7 +819,7 @@ export class Collection< this.tableName, mappedRows, selectedForInsert, - ); + ).map((plan) => mergeUserAnnotations(plan, annotationsMap)); return dispatchSplitMutationRows({ contract: this.contract, runtime: this.ctx.runtime, @@ -750,11 +831,9 @@ export class Collection< }); } - const compiled = compileInsertReturning( - this.contract, - this.tableName, - mappedRows, - selectedForInsert, + const compiled = mergeUserAnnotations( + compileInsertReturning(this.contract, this.tableName, mappedRows, selectedForInsert), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -923,26 +1002,33 @@ export class Collection< async createCount( data: readonly ResolvedCreateInput[], + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { if (data.length === 0) { return 0; } this.#assertNotMtiVariant('createCount()'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'createCount'); const rows = data as readonly Record[]; const mappedRows = this.#mapCreateRows(rows); applyCreateDefaults(this.ctx, this.tableName, mappedRows); if (this.contract.capabilities?.['sql']?.['defaultInInsert'] !== true) { - const plans = compileInsertCountSplit(this.contract, this.tableName, mappedRows); + const plans = compileInsertCountSplit(this.contract, this.tableName, mappedRows).map((plan) => + mergeUserAnnotations(plan, annotationsMap), + ); for (const plan of plans) { await executeQueryPlan>(this.ctx.runtime, plan).toArray(); } return data.length; } - const compiled = compileInsertCount(this.contract, this.tableName, mappedRows); + const compiled = mergeUserAnnotations( + compileInsertCount(this.contract, this.tableName, mappedRows), + annotationsMap, + ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return data.length; } @@ -952,13 +1038,17 @@ export class Collection< * On conflict, `ON CONFLICT DO NOTHING RETURNING ...` may return zero rows, * so this method may issue a follow-up reload query to return the existing row. */ - async upsert(input: { - create: ResolvedCreateInput; - update: Partial>; - conflictOn?: UniqueConstraintCriterion; - }): Promise { + async upsert( + input: { + create: ResolvedCreateInput; + update: Partial>; + conflictOn?: UniqueConstraintCriterion; + }, + configure?: (meta: MetaBuilder<'write'>) => void, + ): Promise { assertReturningCapability(this.contract, 'upsert()'); this.#assertNotMtiVariant('upsert()'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'upsert'); const mappedCreateRows = this.#mapCreateRows([input.create as Record]); const createValues = mappedCreateRows[0] ?? {}; @@ -979,13 +1069,16 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileUpsertReturning( - this.contract, - this.tableName, - createValues, - updateValues, - conflictColumns, - selectedForUpsert, + const compiled = mergeUserAnnotations( + compileUpsertReturning( + this.contract, + this.tableName, + createValues, + updateValues, + conflictColumns, + selectedForUpsert, + ), + annotationsMap, ); const row = await executeMutationReturningSingleRow({ contract: this.contract, @@ -1015,10 +1108,25 @@ export class Collection< throw new Error(`upsert() for model "${this.modelName}" did not return a row`); } + /** + * Write terminal: update matching rows and return the first one (or + * null when no row matched). + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. + * + * Note: when the input contains nested-mutation callbacks, the + * operation is executed as a graph of internal queries via + * `withMutationScope`. In that path, annotations apply to the logical + * `update()` call but do not currently flow into each constituent SQL + * statement — see `projects/middleware-intercept-and-cache/follow-ups.md`. + */ async update( data: State['hasWhere'] extends true ? MutationUpdateInput : never, + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'update()'); + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'update'); if ( hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) @@ -1038,16 +1146,28 @@ export class Collection< return this.#reloadMutationRowByPrimaryKey(pkCriterion); } - const rows = await this.updateAll( + const rows = await this.#updateAllWithAnnotations( data as State['hasWhere'] extends true ? Partial> : never, + annotationsMap, ); return rows[0] ?? null; } updateAll( data: State['hasWhere'] extends true ? Partial> : never, + configure?: (meta: MetaBuilder<'write'>) => void, + ): AsyncIterableResult { + return this.#updateAllWithAnnotations( + data, + this.#collectAnnotationsFromMeta(configure, 'write', 'updateAll'), + ); + } + + #updateAllWithAnnotations( + data: State['hasWhere'] extends true ? Partial> : never, + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { assertReturningCapability(this.contract, 'updateAll()'); @@ -1062,12 +1182,15 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileUpdateReturning( - this.contract, - this.tableName, - mappedData, - this.state.filters, - selectedForUpdate, + const compiled = mergeUserAnnotations( + compileUpdateReturning( + this.contract, + this.tableName, + mappedData, + this.state.filters, + selectedForUpdate, + ), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -1082,12 +1205,16 @@ export class Collection< async updateCount( data: State['hasWhere'] extends true ? Partial> : never, + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { const mappedData = mapModelDataToStorageRow(this.contract, this.modelName, data); if (Object.keys(mappedData).length === 0) { return 0; } + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'updateCount'); + const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { ...emptyState(), @@ -1100,27 +1227,46 @@ export class Collection< countCompiled, ).toArray(); - const compiled = compileUpdateCount( - this.contract, - this.tableName, - mappedData, - this.state.filters, + const compiled = mergeUserAnnotations( + compileUpdateCount(this.contract, this.tableName, mappedData, this.state.filters), + annotationsMap, ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return matchingRows.length; } + /** + * Write terminal: delete matching rows and return the first one (or + * null when no row matched). + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'write'>` for attaching typed user annotations. + */ async delete( this: State['hasWhere'] extends true ? Collection : never, + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { assertReturningCapability(this.contract, 'delete()'); - const rows = await this.deleteAll().toArray(); + // The `this`-typed receiver narrows when the `where()` gate is + // satisfied, so we can call `deleteAll()` on it directly. + const rows = await (this as Collection) + .#deleteAllWithAnnotations(this.#collectAnnotationsFromMeta(configure, 'write', 'delete')) + .toArray(); return rows[0] ?? null; } deleteAll( this: State['hasWhere'] extends true ? Collection : never, + configure?: (meta: MetaBuilder<'write'>) => void, + ): AsyncIterableResult { + return (this as Collection).#deleteAllWithAnnotations( + this.#collectAnnotationsFromMeta(configure, 'write', 'deleteAll'), + ); + } + + #deleteAllWithAnnotations( + annotationsMap: ReadonlyMap> | undefined, ): AsyncIterableResult { assertReturningCapability(this.contract, 'deleteAll()'); @@ -1129,11 +1275,9 @@ export class Collection< this.state.selectedFields, parentJoinColumns, ); - const compiled = compileDeleteReturning( - this.contract, - this.tableName, - this.state.filters, - selectedForDelete, + const compiled = mergeUserAnnotations( + compileDeleteReturning(this.contract, this.tableName, this.state.filters, selectedForDelete), + annotationsMap, ); return dispatchMutationRows({ contract: this.contract, @@ -1148,7 +1292,11 @@ export class Collection< async deleteCount( this: State['hasWhere'] extends true ? Collection : never, + configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { + // Annotations attach to the write, not the matching read. + const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'deleteCount'); + const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); const countState: CollectionState = { ...emptyState(), @@ -1161,7 +1309,10 @@ export class Collection< countCompiled, ).toArray(); - const compiled = compileDeleteCount(this.contract, this.tableName, this.state.filters); + const compiled = mergeUserAnnotations( + compileDeleteCount(this.contract, this.tableName, this.state.filters), + annotationsMap, + ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); return matchingRows.length; @@ -1293,4 +1444,60 @@ export class Collection< modelName: this.modelName, }); } + + /** + * Invokes the user-supplied configurator (if any) against a freshly + * constructed read meta builder, and returns a clone whose + * `state.userAnnotations` carries the recorded map. Used by read + * terminals that flow annotations through state (`all`, `first`). + * + * Returns the receiver unchanged when no configurator was supplied + * or when the configurator did not call `meta.annotate(...)`. The + * meta builder's `annotate` method enforces applicability at the + * type level and at runtime, so terminal code does not need to + * re-validate. + */ + #withAnnotationsFromMeta( + configure: ((meta: MetaBuilder<'read'>) => void) | undefined, + terminalName: string, + ): this { + if (configure === undefined) { + return this; + } + const meta = createMetaBuilder('read', terminalName); + configure(meta); + if (meta.annotations.size === 0) { + return this; + } + const next = new Map(this.state.userAnnotations); + for (const [namespace, value] of meta.annotations) { + next.set(namespace, value); + } + return this.#clone({ userAnnotations: next }) as this; + } + + /** + * Invokes the user-supplied configurator (if any) against a freshly + * constructed meta builder of the given operation kind, and returns + * the recorded annotation map (or `undefined` when empty). Used by + * terminals where annotations don't flow through `state` — the + * compiled plan is post-wrapped via `mergeUserAnnotations` instead. + * Read terminals `all` and `first` populate `state.userAnnotations` + * via `#withAnnotationsFromMeta` instead; `aggregate` uses this + * post-wrap path because its compile function doesn't take `state`. + * The meta builder's `annotate` method enforces applicability at the + * type level and at runtime. + */ + #collectAnnotationsFromMeta( + configure: ((meta: MetaBuilder) => void) | undefined, + kind: K, + terminalName: string, + ): ReadonlyMap> | undefined { + if (configure === undefined) { + return undefined; + } + const meta = createMetaBuilder(kind, terminalName); + configure(meta); + return meta.annotations.size === 0 ? undefined : meta.annotations; + } } diff --git a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts index d594ad6e8c..4186668d7d 100644 --- a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts +++ b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts @@ -1,4 +1,10 @@ import type { Contract } from '@prisma-next/contract/types'; +import type { + AnnotationValue, + MetaBuilder, + OperationKind, +} from '@prisma-next/framework-components/runtime'; +import { createMetaBuilder } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { AggregateExpr, @@ -13,7 +19,7 @@ import { createAggregateBuilder, isAggregateSelector } from './aggregate-builder import { getFieldToColumnMap } from './collection-contract'; import { mapStorageRowToModelFields } from './collection-runtime'; import { executeQueryPlan } from './execute-query-plan'; -import { compileGroupedAggregate } from './query-plan'; +import { compileGroupedAggregate, mergeUserAnnotations } from './query-plan'; import type { AggregateBuilder, AggregateResult, @@ -82,8 +88,16 @@ export class GroupedCollection< }) as GroupedCollection; } + /** + * Read terminal: run a grouped aggregate query. + * + * Accepts an optional `configure` callback that receives a + * `MetaBuilder<'read'>` for attaching typed user annotations. + * Annotations are merged into the compiled plan's `meta.annotations`. + */ async aggregate( fn: (aggregate: AggregateBuilder) => Spec, + configure?: (meta: MetaBuilder<'read'>) => void, ): Promise< Array< SimplifyDeep< @@ -103,13 +117,25 @@ export class GroupedCollection< } } - const compiled = compileGroupedAggregate( - this.contract, - this.tableName, - this.baseFilters, - this.groupByColumns, - aggregateSpec, - combineWhereExprs(this.havingFilters), + let annotationsMap: ReadonlyMap> | undefined; + if (configure !== undefined) { + const meta = createMetaBuilder('read', 'groupBy.aggregate'); + configure(meta); + if (meta.annotations.size > 0) { + annotationsMap = meta.annotations; + } + } + + const compiled = mergeUserAnnotations( + compileGroupedAggregate( + this.contract, + this.tableName, + this.baseFilters, + this.groupByColumns, + aggregateSpec, + combineWhereExprs(this.havingFilters), + ), + annotationsMap, ); const rows = await executeQueryPlan>( this.ctx.runtime, diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts index e0bce45bf9..3ebddb6d64 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts @@ -1,7 +1,9 @@ import type { Contract, PlanMeta } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { type AnyQueryAst, collectOrderedParamRefs } from '@prisma-next/sql-relational-core/ast'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; +import { ifDefined } from '@prisma-next/utils/defined'; export function deriveParamsFromAst(ast: AnyQueryAst): { params: unknown[]; @@ -19,12 +21,20 @@ export function resolveTableColumns(contract: Contract, tableName: s return Object.keys(table.columns); } -export function buildOrmPlanMeta(contract: Contract): PlanMeta { +export function buildOrmPlanMeta( + contract: Contract, + annotations?: ReadonlyMap>, +): PlanMeta { + const annotationRecord = + annotations !== undefined && annotations.size > 0 + ? Object.freeze(Object.fromEntries(annotations)) + : undefined; return { target: contract.target, targetFamily: contract.targetFamily, storageHash: contract.storage.storageHash, - ...(contract.profileHash !== undefined ? { profileHash: contract.profileHash } : {}), + ...ifDefined('profileHash', contract.profileHash), + ...ifDefined('annotations', annotationRecord), lane: 'orm-client', }; } @@ -33,10 +43,54 @@ export function buildOrmQueryPlan( contract: Contract, ast: AnyQueryAst, params: readonly unknown[], + annotations?: ReadonlyMap>, ): SqlQueryPlan { return Object.freeze({ ast, params: [...params], - meta: buildOrmPlanMeta(contract), + meta: buildOrmPlanMeta(contract, annotations), + }); +} + +/** + * Merges user annotations into an existing `SqlQueryPlan`'s + * `meta.annotations` and returns a new frozen plan. + * + * Used by the ORM dispatch path to attach terminal-call annotations to + * plans produced by mutation compile functions (which don't take user + * annotations as parameters). Reads compile through `compileSelect`- + * family functions that pass `state.userAnnotations` directly to + * `buildOrmQueryPlan`; this helper is the alternate path for write + * terminals where user annotations arrive at the call site, not via + * state. + * + * Returns the input plan unchanged when `userAnnotations` is undefined + * or empty. Reserved framework namespaces (`codecs`, `limit`) on the + * input plan win over user annotations under the same key — see the + * reserved-namespace policy on `defineAnnotation`. + */ +export function mergeUserAnnotations( + plan: SqlQueryPlan, + userAnnotations: ReadonlyMap> | undefined, +): SqlQueryPlan { + if (userAnnotations === undefined || userAnnotations.size === 0) { + return plan; + } + const userEntries: Record> = {}; + for (const [namespace, value] of userAnnotations) { + userEntries[namespace] = value; + } + // User annotations go first so framework-reserved keys on the existing + // plan (codecs, limit) override any user-supplied collision. + const mergedAnnotations = Object.freeze({ + ...userEntries, + ...(plan.meta.annotations ?? {}), + }); + return Object.freeze({ + ...plan, + meta: Object.freeze({ + ...plan.meta, + annotations: mergedAnnotations, + }), }); } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts index 2bec59a4e3..fb33abdf23 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts @@ -448,7 +448,7 @@ export function compileSelect( ); const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); + return buildOrmQueryPlan(contract, ast, params, state.userAnnotations); } export function compileRelationSelect( @@ -524,5 +524,5 @@ export function compileSelectWithIncludeStrategy( ); const { params } = deriveParamsFromAst(ast); - return buildOrmQueryPlan(contract, ast, params); + return buildOrmQueryPlan(contract, ast, params, state.userAnnotations); } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan.ts b/packages/3-extensions/sql-orm-client/src/query-plan.ts index 0fa71fcac8..e9a08ea99f 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan.ts @@ -2,6 +2,7 @@ export { compileAggregate, compileGroupedAggregate, } from './query-plan-aggregate'; +export { mergeUserAnnotations } from './query-plan-meta'; export { compileDeleteCount, compileDeleteReturning, diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 5c0e553b14..5b88b07a8e 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -1,4 +1,5 @@ import type { Contract } from '@prisma-next/contract/types'; +import type { AnnotationValue, OperationKind } from '@prisma-next/framework-components/runtime'; import type { ExtractCodecTypes, ExtractQueryOperationTypes, @@ -79,6 +80,14 @@ export interface CollectionState { readonly limit: number | undefined; readonly offset: number | undefined; readonly variantName: string | undefined; + /** + * User annotations attached to this query at terminal-call time. + * Populated transiently by terminal methods (`first`, `all`, `create`, + * etc.) just before dispatch — `Collection` itself has no chainable + * `.annotate()`. Stored as a `Map` so + * duplicate namespaces last-write-win. Empty on a fresh state. + */ + readonly userAnnotations: ReadonlyMap>; } export function emptyState(): CollectionState { @@ -93,6 +102,7 @@ export function emptyState(): CollectionState { limit: undefined, offset: undefined, variantName: undefined, + userAnnotations: new Map(), }; } diff --git a/packages/3-extensions/sql-orm-client/test/annotations.test.ts b/packages/3-extensions/sql-orm-client/test/annotations.test.ts new file mode 100644 index 0000000000..93cc8b72af --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/annotations.test.ts @@ -0,0 +1,594 @@ +import { defineAnnotation } from '@prisma-next/framework-components/runtime'; +import { describe, expect, it } from 'vitest'; +import { + createCollection, + createCollectionFor, + createReturningCollectionFor, +} from './collection-fixtures'; + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +describe('Collection.all annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); + + expect(runtime.executions).toHaveLength(1); + const stored = runtime.executions[0]!.plan.meta.annotations?.['cache']; + expect(stored).toMatchObject({ + __annotation: true, + namespace: 'cache', + value: { ttl: 60 }, + }); + }); + + it('round-trips through the typed handle.read accessor', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('returns undefined from handle.read on a plan that was never annotated', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all().toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('multiple annotations under different namespaces coexist', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection + .all((meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't-1' })), + ) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('omitting the configurator is a no-op for user annotations', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all().toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('a configurator that records nothing is a no-op for user annotations', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all(() => {}).toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('annotations survive across .where() and .take() chaining', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection + .where((user) => user.name.eq('Alice')) + .take(10) + .all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', () => { + const { collection } = createCollection(); + expect(() => + collection + .all((meta) => { + // Cast bypasses the type-level applicability gate. + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }) + .toArray(), + ).toThrow( + expect.objectContaining({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }), + ); + }); +}); + +describe('Collection.first annotations', () => { + it('writes the applied annotation under its namespace on the executed plan (no filter)', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('writes the applied annotation when invoked with a function filter', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first( + (user) => user.name.eq('Alice'), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('writes the applied annotation when invoked with a shorthand filter', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first({ name: 'Alice' }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('a single function arg is interpreted as a filter (not a configurator)', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + // Passing a single function is treated as a filter callback, matching the + // existing `first(filterFn)` semantics. To attach an annotation without a + // filter, pass `undefined` explicitly as the first arg. + await collection.first((user) => user.name.eq('Alice')); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + }); + + it('multiple annotations coexist under different namespaces', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.first( + (user) => user.name.eq('Alice'), + (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't-1' })), + ); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollection(); + await expect( + collection.first(undefined, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('Collection annotations alongside framework-internal codecs metadata', () => { + it('user annotations coexist with the framework-internal codecs map under its reserved namespace', async () => { + const { collection, runtime } = createCollection(); + runtime.setNextResults([[]]); + + await collection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))).toArray(); + + const plan = runtime.executions[0]!.plan; + // User annotation lives under its own namespace. + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + // Reserved framework namespace, when emitted, lives under 'codecs' and + // is not a branded AnnotationValue (so handle.read with namespace + // 'codecs' would return undefined; we check the raw shape here). + if (plan.meta.annotations?.['codecs'] !== undefined) { + expect(plan.meta.annotations['codecs']).toEqual(expect.any(Object)); + } + }); +}); + +describe('Collection.create annotations', () => { + it('writes the applied write annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(otelAnnotation({ traceId: 't-1' })), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('omitting the configurator leaves the plan without user annotations', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + await expect( + collection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('Collection.createAll annotations', () => { + it('writes the applied annotation onto every plan emitted by the split path', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([ + [{ id: 1, name: 'A', email: 'a@b.com' }], + [{ id: 2, name: 'B', email: 'b@b.com' }], + ]); + + await collection + .createAll( + [ + { id: 1, name: 'A', email: 'a@b.com' }, + { id: 2, name: 'B', email: 'b@b.com' }, + ], + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), + ) + .toArray(); + + expect(runtime.executions.length).toBeGreaterThan(0); + for (const execution of runtime.executions) { + expect(auditAnnotation.read(execution.plan)).toEqual({ actor: 'system' }); + } + }); +}); + +describe('Collection.createCount annotations', () => { + it('writes the applied annotation onto the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[]]); + + await collection.createCount([{ id: 1, name: 'A', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + + expect(runtime.executions.length).toBeGreaterThan(0); + for (const execution of runtime.executions) { + expect(auditAnnotation.read(execution.plan)).toEqual({ actor: 'system' }); + } + }); +}); + +describe('Collection.upsert annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), + ); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + await expect( + collection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }, + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); + +describe('Collection.update annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .update({ name: 'Alice' }, (meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const filtered = collection.where({ id: 1 }); + await expect( + filtered.update({ name: 'Alice' }, (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); + +describe('Collection.updateAll annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .updateAll({ name: 'Alice' }, (meta) => meta.annotate(auditAnnotation({ actor: 'system' }))) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.updateCount annotations', () => { + it('writes the applied annotation onto the update statement (not the matching read)', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + // Two execute calls: matching select first, then the update. + runtime.setNextResults([[{ id: 1 }], []]); + + await collection + .where({ id: 1 }) + .updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + + expect(runtime.executions).toHaveLength(2); + const matchingPlan = runtime.executions[0]!.plan; + const updatePlan = runtime.executions[1]!.plan; + // The matching read does NOT carry the write annotation. + expect(auditAnnotation.read(matchingPlan)).toBeUndefined(); + // The update statement DOES. + expect(auditAnnotation.read(updatePlan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.delete annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .delete((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); + + it('runtime gate rejects a read-only annotation forced through a cast', async () => { + const { collection } = createReturningCollectionFor('User'); + const filtered = collection.where({ id: 1 }); + await expect( + filtered.delete((meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, cacheAnnotation({ ttl: 60 })); + }), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); + +describe('Collection.deleteAll annotations', () => { + it('writes the applied annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + runtime.setNextResults([[{ id: 1, name: 'Alice', email: 'a@b.com' }]]); + + await collection + .where({ id: 1 }) + .deleteAll((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))) + .toArray(); + + const plan = runtime.executions[0]!.plan; + expect(auditAnnotation.read(plan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.deleteCount annotations', () => { + it('writes the applied annotation onto the delete statement (not the matching read)', async () => { + const { collection, runtime } = createReturningCollectionFor('User'); + // Two execute calls: matching select first, then the delete. + runtime.setNextResults([[{ id: 1 }], []]); + + await collection + .where({ id: 1 }) + .deleteCount((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); + + expect(runtime.executions).toHaveLength(2); + const matchingPlan = runtime.executions[0]!.plan; + const deletePlan = runtime.executions[1]!.plan; + // The matching read does NOT carry the write annotation. + expect(auditAnnotation.read(matchingPlan)).toBeUndefined(); + // The delete statement DOES. + expect(auditAnnotation.read(deletePlan)).toEqual({ actor: 'system' }); + }); +}); + +describe('Collection.aggregate annotations', () => { + it('writes the applied read annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't-1' })), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('omitting the configurator leaves the plan without user annotations', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ count: '5' }]]); + + await collection.aggregate((aggregate) => ({ count: aggregate.count() })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollectionFor('Post'); + await expect( + collection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }, + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + category: 'RUNTIME', + }); + }); +}); + +describe('GroupedCollection.aggregate annotations', () => { + it('writes the applied read annotation under its namespace on the executed plan', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + + expect(runtime.executions).toHaveLength(1); + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toEqual({ ttl: 60 }); + }); + + it('accepts a both-kind annotation', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't-1' })), + ); + + const plan = runtime.executions[0]!.plan; + expect(otelAnnotation.read(plan)).toEqual({ traceId: 't-1' }); + }); + + it('omitting the configurator leaves the plan without user annotations', async () => { + const { collection, runtime } = createCollectionFor('Post'); + runtime.setNextResults([[{ user_id: 1, count: '2' }]]); + + await collection.groupBy('userId').aggregate((aggregate) => ({ count: aggregate.count() })); + + const plan = runtime.executions[0]!.plan; + expect(cacheAnnotation.read(plan)).toBeUndefined(); + expect(otelAnnotation.read(plan)).toBeUndefined(); + }); + + it('runtime gate rejects a write-only annotation forced through a cast', async () => { + const { collection } = createCollectionFor('Post'); + await expect( + collection.groupBy('userId').aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => { + const annotateAny = meta.annotate as (annotation: unknown) => unknown; + annotateAny.call(meta, auditAnnotation({ actor: 'system' })); + }, + ), + ).rejects.toMatchObject({ + code: 'RUNTIME.ANNOTATION_INAPPLICABLE', + }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts new file mode 100644 index 0000000000..9088e697c4 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/annotations.types.test-d.ts @@ -0,0 +1,468 @@ +import { + type AnnotationValue, + defineAnnotation, + type MetaBuilder, +} from '@prisma-next/framework-components/runtime'; +import { describe, expectTypeOf, test } from 'vitest'; +import type { Collection } from '../src/collection'; +import type { GroupedCollection } from '../src/grouped-collection'; +import type { TestContract } from './helpers'; + +/** + * Type-level tests for the ORM `Collection` terminal annotations. + * + * Verifies: + * - Read terminals (`all`, `first`, `aggregate`) accept a configurator + * callback whose `meta.annotate(...)` admits read-applicable + * annotations and rejects write-only ones at the type level. + * - Write terminals (`create`, `createAll`, `createCount`, `upsert`, + * `update`, `updateAll`, `updateCount`, `delete`, `deleteAll`, + * `deleteCount`) accept a configurator whose `meta.annotate(...)` + * admits write-applicable annotations and rejects read-only ones. + * - The configurator does not widen the terminal's return type. + * - `first(filter, configure?)` overloads dispatch by argument shape; + * no runtime ambiguity (configurator-only without filter requires + * an explicit `undefined` first argument). + */ + +declare const userCollection: Collection; + +const cacheAnnotation = defineAnnotation<{ ttl: number; skip?: boolean }>()({ + namespace: 'cache', + applicableTo: ['read'], +}); + +const auditAnnotation = defineAnnotation<{ actor: string }>()({ + namespace: 'audit', + applicableTo: ['write'], +}); + +const otelAnnotation = defineAnnotation<{ traceId: string }>()({ + namespace: 'otel', + applicableTo: ['read', 'write'], +}); + +describe('Collection.all (read-typed)', () => { + test('accepts a configurator that applies a read-only annotation', () => { + userCollection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + }); + + test('accepts a configurator that applies a both-kind annotation', () => { + userCollection.all((meta) => meta.annotate(otelAnnotation({ traceId: 't' }))); + }); + + test('accepts a configurator that chains multiple compatible annotations', () => { + userCollection.all((meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't' })), + ); + }); + + test('accepts an omitted configurator', () => { + userCollection.all(); + }); + + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userCollection.all((meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('rejects a configurator that mixes in a write-only annotation (negative)', () => { + userCollection.all((meta) => { + meta.annotate(cacheAnnotation({ ttl: 60 })); + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })); + }); + }); + + test('the configurator does not widen the terminal return type', () => { + const result = userCollection.all((meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + expectTypeOf(result).toHaveProperty('toArray'); + expectTypeOf(result.toArray).returns.toMatchTypeOf>(); + }); + + test('configurator parameter is typed as MetaBuilder<"read">', () => { + userCollection.all((meta) => { + expectTypeOf(meta).toEqualTypeOf>(); + }); + }); +}); + +describe('Collection.first (read-typed)', () => { + test('accepts a configurator after a function filter', () => { + userCollection.first( + (user) => user.name.eq('Alice'), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('accepts a configurator after a shorthand filter', () => { + userCollection.first({ name: 'Alice' }, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + }); + + test('accepts a configurator with explicit undefined filter', () => { + userCollection.first(undefined, (meta) => meta.annotate(cacheAnnotation({ ttl: 60 }))); + }); + + test('accepts a configurator that chains multiple compatible annotations after a filter', () => { + userCollection.first( + (user) => user.name.eq('Alice'), + (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })).annotate(otelAnnotation({ traceId: 't' })), + ); + }); + + test('accepts no arguments at all', () => { + userCollection.first(); + }); + + test('accepts a function filter without a configurator', () => { + userCollection.first((user) => user.name.eq('Alice')); + }); + + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userCollection.first({ name: 'Alice' }, (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('rejects a configurator with explicit undefined filter that applies a write-only annotation (negative)', () => { + userCollection.first(undefined, (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('the return type is Promise', () => { + const result = userCollection.first({ name: 'Alice' }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + expectTypeOf(result).resolves.toMatchTypeOf | null>(); + }); +}); + +describe('Collection has no chainable .annotate (intentional scope cut)', () => { + // Annotations attach via the configurator argument only — there is no + // chainable `.annotate(...)` on Collection. The configurator lives on + // `MetaBuilder` constructed by the terminal; the kind is bound by + // the terminal's operation kind, so a chainable form on Collection + // would have fought the per-terminal kind binding. + test('Collection does not expose an annotate method', () => { + type Keys = keyof Collection; + type HasAnnotate = 'annotate' extends Keys ? true : false; + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('annotation handle types are preserved through the lane', () => { + // The handle's payload type survives the gate — same property the + // framework-components type-d tests verify, exercised here at the ORM + // lane to ensure no widening through the configurator argument. + test('cacheAnnotation construction is assignable through the configurator', () => { + const value = cacheAnnotation({ ttl: 60 }); + expectTypeOf(value).toMatchTypeOf>(); + userCollection.all((meta) => meta.annotate(value)); + }); +}); + +// --------------------------------------------------------------------------- +// Write terminals +// +// Symmetrical contract: each write terminal accepts a configurator whose +// `meta.annotate(...)` admits write-only and both-kind annotations and +// rejects read-only ones at the type level. Return types are preserved. +// --------------------------------------------------------------------------- + +declare const userCollectionWithWhere: Collection< + TestContract, + 'User', + Record, + { + readonly hasOrderBy: false; + readonly hasWhere: true; + readonly hasUniqueFilter: false; + readonly variantName: undefined; + } +>; + +describe('Collection.create (write-typed)', () => { + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('accepts a configurator that applies a both-kind annotation', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(otelAnnotation({ traceId: 't' })), + ); + }); + + test('accepts an omitted configurator', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }); + }); + + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('rejects a configurator that mixes in a read-only annotation (negative)', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + meta.annotate(auditAnnotation({ actor: 'system' })); + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })); + }); + }); + + test('the return type is Promise', () => { + const result = userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + expectTypeOf(result).resolves.toMatchTypeOf>(); + }); + + test('configurator parameter is typed as MetaBuilder<"write">', () => { + userCollection.create({ id: 1, name: 'Alice', email: 'a@b.com' }, (meta) => { + expectTypeOf(meta).toEqualTypeOf>(); + }); + }); +}); + +describe('Collection.createAll (write-typed)', () => { + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('accepts an omitted configurator', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }]); + }); + + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.createAll([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); +}); + +describe('Collection.createCount (write-typed)', () => { + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.createCount([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.createCount([{ id: 1, name: 'Alice', email: 'a@b.com' }], (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('the return type is Promise', () => { + const result = userCollection.createCount( + [{ id: 1, name: 'Alice', email: 'a@b.com' }], + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), + ); + expectTypeOf(result).resolves.toBeNumber(); + }); +}); + +describe('Collection.upsert (write-typed)', () => { + test('accepts a configurator that applies a write-only annotation', () => { + userCollection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + (meta) => meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('rejects a configurator that applies a read-only annotation (negative)', () => { + userCollection.upsert( + { + create: { id: 1, name: 'Alice', email: 'a@b.com' }, + update: { name: 'Alice' }, + conflictOn: { id: 1 }, + }, + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); +}); + +describe('Collection.update / .updateAll / .updateCount (write-typed)', () => { + // Update terminals require the receiver to satisfy the + // `State['hasWhere'] extends true` gate, so we use a separately- + // declared `userCollectionWithWhere` whose State is post-where. + test('update accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.update({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('update rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.update({ name: 'Alice' }, (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('updateAll accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.updateAll({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('updateAll rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.updateAll({ name: 'Alice' }, (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('updateCount accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('updateCount rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('updateCount returns Promise', () => { + const result = userCollectionWithWhere.updateCount({ name: 'Alice' }, (meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + expectTypeOf(result).resolves.toBeNumber(); + }); +}); + +describe('Collection.delete / .deleteAll / .deleteCount (write-typed)', () => { + test('delete accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.delete((meta) => meta.annotate(auditAnnotation({ actor: 'system' }))); + }); + + test('delete rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.delete((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('deleteAll accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.deleteAll((meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('deleteAll rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.deleteAll((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('deleteCount accepts a configurator that applies a write-only annotation', () => { + userCollectionWithWhere.deleteCount((meta) => + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('deleteCount rejects a configurator that applies a read-only annotation (negative)', () => { + userCollectionWithWhere.deleteCount((meta) => + // @ts-expect-error - cache declares applicableTo: ['read'], not 'write' + meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Aggregate terminals (read-typed) +// +// Both `Collection.aggregate(fn, configure?)` and +// `GroupedCollection.aggregate(fn, configure?)` are read terminals that run +// a single SQL aggregation query and accept a configurator after the +// builder callback. +// --------------------------------------------------------------------------- + +describe('Collection.aggregate (read-typed)', () => { + test('accepts a configurator that applies a read-only annotation', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('accepts a configurator that applies a both-kind annotation', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't' })), + ); + }); + + test('accepts an omitted configurator', () => { + userCollection.aggregate((aggregate) => ({ count: aggregate.count() })); + }); + + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); + + test('the aggregation spec type is preserved through the gate', () => { + const result = userCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + expectTypeOf(result).resolves.toMatchTypeOf<{ count: number }>(); + }); +}); + +declare const userGroupedCollection: GroupedCollection; + +describe('GroupedCollection.aggregate (read-typed)', () => { + test('accepts a configurator that applies a read-only annotation', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(cacheAnnotation({ ttl: 60 })), + ); + }); + + test('accepts a configurator that applies a both-kind annotation', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => meta.annotate(otelAnnotation({ traceId: 't' })), + ); + }); + + test('accepts an omitted configurator', () => { + userGroupedCollection.aggregate((aggregate) => ({ count: aggregate.count() })); + }); + + test('rejects a configurator that applies a write-only annotation (negative)', () => { + userGroupedCollection.aggregate( + (aggregate) => ({ count: aggregate.count() }), + (meta) => + // @ts-expect-error - audit declares applicableTo: ['write'], not 'read' + meta.annotate(auditAnnotation({ actor: 'system' })), + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc9f3d7c0..4ee522dc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@prisma-next/ids': specifier: workspace:* version: link:../../packages/1-framework/2-authoring/ids + '@prisma-next/middleware-cache': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-cache '@prisma-next/middleware-telemetry': specifier: workspace:* version: link:../../packages/3-extensions/middleware-telemetry @@ -2395,6 +2398,31 @@ importers: specifier: 'catalog:' version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + packages/3-extensions/middleware-cache: + dependencies: + '@prisma-next/framework-components': + specifier: workspace:* + version: link:../../1-framework/1-core/framework-components + devDependencies: + '@prisma-next/contract': + specifier: workspace:* + version: link:../../1-framework/0-foundation/contract + '@prisma-next/tsconfig': + specifier: workspace:* + version: link:../../0-config/tsconfig + '@prisma-next/tsdown': + specifier: workspace:* + version: link:../../0-config/tsdown + tsdown: + specifier: 'catalog:' + version: 0.18.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.17(@types/node@24.10.4)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.1) + packages/3-extensions/middleware-telemetry: dependencies: '@prisma-next/contract': @@ -3588,6 +3616,9 @@ importers: '@prisma-next/framework-components': specifier: workspace:* version: link:../../packages/1-framework/1-core/framework-components + '@prisma-next/middleware-cache': + specifier: workspace:* + version: link:../../packages/3-extensions/middleware-cache '@prisma-next/middleware-telemetry': specifier: workspace:* version: link:../../packages/3-extensions/middleware-telemetry diff --git a/projects/middleware-intercept-and-cache/api-revision-meta-callback.md b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md new file mode 100644 index 0000000000..fca3b63b17 --- /dev/null +++ b/projects/middleware-intercept-and-cache/api-revision-meta-callback.md @@ -0,0 +1,253 @@ +# API revision: ORM terminal annotations as a meta-callback + +**Status:** landed. Supersedes the variadic `...annotations` shape on ORM terminals shipped in M2 and pinned by `spec.md` Functional Requirement #6. + +**Scope:** ORM `Collection` and `GroupedCollection` terminals only. SQL DSL `.annotate(...)` is unaffected (see "Why SQL DSL is out of scope" below). + +## Summary + +Replace the variadic `...annotations: As & ValidAnnotations` last argument on every ORM terminal with a single optional callback whose parameter is a typed `MetaBuilder`: + +```typescript +// Before (shipped in M2) +await db.orm.User.all(cacheAnnotation({ ttl })); +await db.orm.User.first({ id }, cacheAnnotation({ ttl })); +await db.orm.User.where({ id }).first(cacheAnnotation({ ttl })); + +// After +await db.orm.User.all((meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl }))); +await db.orm.User.where({ id }).first(undefined, (meta) => + meta.annotate(cacheAnnotation({ ttl })), +); +``` + +The callback receives a `MetaBuilder` whose `annotate(annotation)` method takes one annotation, validates it eagerly against the terminal's operation kind `K`, records it, and returns the builder for chaining. + +**Note on `first(...)` shape.** `first` keeps its existing positional dispatch — a single function arg is interpreted as a filter callback (`first((model) => model.field.eq(...))`), matching shipped semantics. To attach a configurator without a filter, pass `undefined` as the first argument: `first(undefined, (meta) => …)`. The runtime cannot disambiguate "single function = filter vs. single function = configurator" without invoking it; explicit `undefined` keeps positional dispatch unambiguous and side-effect-free. The original spec example `where({ id }).first((meta) => …)` therefore becomes `where({ id }).first(undefined, (meta) => …)` — a minor ergonomic concession compared to a probe-based dispatcher, but the implementation is simpler and the semantics are predictable. + +## Why + +**The variadic forecloses on growth.** A terminal whose last positional argument is a variadic of annotations cannot grow new positional or named per-query options without a breaking change. There is no future shape `db.orm.User.find(input, { … }, ann1, ann2)` we can evolve into without churning every call site, and the variadic-tuple inference rules make alternatives like `find(input, { annotations: [ann1, ann2], timeout: 5_000 })` lose the applicability gate that the `As & ValidAnnotations` intersection currently enforces. The callback shape sidesteps both: per-query options become methods on `MetaBuilder`, and adding a method (`meta.timeout(...)`, `meta.tag(...)`, `meta.cancellation(signal)`) is a non-breaking surface extension. + +**The callback drops a load-bearing TypeScript trick.** Today every variadic terminal carries the `As & ValidAnnotations<'read', As>` intersection (see `ValidAnnotations` TSDoc and `follow-ups.md` Open Items). The intersection exists because TypeScript's variadic-tuple inference is too forgiving: without it, an inapplicable annotation would silently typecheck. The callback hands one annotation at a time to `meta.annotate(...)`, which uses an ordinary conditional constraint — no variadic-tuple inference involved, no intersection, no documented "load-bearing trick". + +**The callback removes the `isAnnotationValue` discriminator inside `first`.** The shipped `first(filterOrFirstAnnotation, ...rest)` runtime branches on `isAnnotationValue(filterOrFirstAnnotation)` to decide whether the first positional argument is a filter or an annotation (collection.ts:686). Once annotations move into the callback, `first(filter?, configure?)` is unambiguous: positional 1 is the filter, positional 2 is the configurator. The dispatch shrinks to a few lines. + +**The callback aligns with the rest of the ORM client's mental model.** `.where((model) => …)`, `.include((collection) => …)`, `.aggregate((agg) => …)`, `.having((having) => …)` already use callback configurators. A meta-configurator slots into the same mental model — "the optional last argument is a function that receives a typed builder." + +**A note on what "terminal variadic" means.** The user-facing concern is specifically variadics on terminal methods, where the next argument we want to add is a per-call option. Chainable builder methods like SQL DSL `.annotate(...)` are not terminals — they return a new builder that participates in further chaining — and growth of *that* surface happens via additional methods on the builder, not via additional arguments on `.annotate()` itself. SQL DSL stays as it is. + +## The shape in detail + +### `MetaBuilder` + +```typescript +// packages/1-framework/1-core/framework-components/src/meta-builder.ts (new) +import type { AnnotationValue, OperationKind } from './annotations'; + +/** + * Per-terminal meta configurator. The terminal's operation kind `K` is fixed + * by the terminal that constructed the builder; `annotate` accepts any + * annotation whose declared `Kinds` includes `K`. + * + * The conditional `K extends Kinds ? AnnotationValue : never` + * collapses the parameter type to `never` for inapplicable annotations, + * surfacing the mismatch as a type error at the call site of `meta.annotate`. + * No variadic-tuple inference is involved — TypeScript infers `Kinds` from + * the annotation argument, then checks the conditional. + */ +export interface MetaBuilder { + annotate( + annotation: K extends Kinds ? AnnotationValue : never, + ): this; +} +``` + +The reference implementation is a small class (or factory) that holds the terminal's `kind`, the `terminalName` for error messages, and a `Map>` of recorded annotations. + +`annotate(...)` validates eagerly via `assertAnnotationsApplicable([annotation], this.kind, this.terminalName)` so cast-bypass cases (`as any`) throw `RUNTIME.ANNOTATION_INAPPLICABLE` at the configurator call site rather than later in the terminal. The terminal then reads `meta.annotations` (the recorded `Map`) and threads it into the existing plumbing. + +### Terminal signatures (read) + +```typescript +// Before +all[]>( + ...annotations: As & ValidAnnotations<'read', As> +): AsyncIterableResult; + +// After +all(configure?: (meta: MetaBuilder<'read'>) => void): AsyncIterableResult; +``` + +```typescript +// Before — six overloads with variadic annotations + a runtime branch on isAnnotationValue +async first(): Promise; +async first(filter: (model: ModelAccessor<…>) => WhereArg): Promise; +async first(filter: ShorthandWhereFilter<…>): Promise; +async first(...annotations: As & ValidAnnotations<'read', As>): Promise; +async first(filter: (model: …) => WhereArg, ...annotations: As & ValidAnnotations<'read', As>): Promise; +async first(filter: ShorthandWhereFilter<…>, ...annotations: As & ValidAnnotations<'read', As>): Promise; + +// After — four overloads, no runtime ambiguity (positional dispatch only) +async first(): Promise; +async first( + filter: undefined, + configure: (meta: MetaBuilder<'read'>) => void, +): Promise; +async first( + filter: (model: ModelAccessor<…>) => WhereArg, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise; +async first( + filter: ShorthandWhereFilter<…>, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise; +``` + +`aggregate(fn, configure?)` follows the same pattern: existing builder callback first, optional meta configurator second. + +### Terminal signatures (write) + +```typescript +// Before +async create( + data: ResolvedCreateInput<…>, + ...annotations: As & ValidAnnotations<'write', As> +): Promise; + +// After +async create( + data: ResolvedCreateInput<…>, + configure?: (meta: MetaBuilder<'write'>) => void, +): Promise; +``` + +Same shape for `createAll`, `createCount`, `update`, `updateAll`, `updateCount`, `delete`, `deleteAll`, `deleteCount`, `upsert`. Each terminal pins the kind to `'write'` when constructing its `MetaBuilder`. + +`delete` / `deleteAll` / `deleteCount` (which take only `this`-typed receiver guards today) gain a single optional `configure` argument: + +```typescript +async delete( + this: State['hasWhere'] extends true ? Collection<…> : never, + configure?: (meta: MetaBuilder<'write'>) => void, +): Promise; +``` + +### `GroupedCollection.aggregate` + +```typescript +// Before +async aggregate( + fn: (aggregate: AggregateBuilder<…>) => Spec, + ...annotations: As & ValidAnnotations<'read', As> +): Promise<…>; + +// After +async aggregate( + fn: (aggregate: AggregateBuilder<…>) => Spec, + configure?: (meta: MetaBuilder<'read'>) => void, +): Promise<…>; +``` + +### Multiple annotations per call + +Chainable, since `annotate` returns `this`: + +```typescript +await db.orm.User.find({ id }, (meta) => + meta + .annotate(cacheAnnotation({ ttl })) + .annotate(otelAnnotation({ traceId })), +); +``` + +Or block form, since the callback's return value is unused: + +```typescript +await db.orm.User.find({ id }, (meta) => { + meta.annotate(cacheAnnotation({ ttl })); + meta.annotate(otelAnnotation({ traceId })); +}); +``` + +Last-write-wins on duplicate namespaces, matching the existing semantics. + +### Reading the annotations inside the terminal + +The state-driven path (`all`, `first`) folds the meta builder's recorded map into `state.userAnnotations` at the terminal boundary, replacing the existing `#withAnnotations(annotations, 'read', 'all')` array entry point with a `#withAnnotationsFromMeta(meta)` map entry point. `compileSelect` continues to receive `state.userAnnotations` unchanged. + +The post-wrap path (`aggregate`, all writes) calls `mergeUserAnnotations(compiled, meta.annotations)` directly with the meta builder's recorded map — the function already accepts a `ReadonlyMap>`, so the call-site shape simplifies. + +In both paths the runtime applicability check has already fired inside `meta.annotate(...)`. The terminal does not need to call `assertAnnotationsApplicable` again. + +## What stays the same + +- `defineAnnotation`, `AnnotationValue`, `AnnotationHandle`, `OperationKind`, `assertAnnotationsApplicable` — unchanged. +- SQL DSL `.annotate(...)` — unchanged. +- Annotation storage on `plan.meta.annotations[namespace]` — unchanged. +- `mergeUserAnnotations` and `buildOrmQueryPlan` — unchanged (their input signatures already accept a `ReadonlyMap`). +- Cache middleware behavior, contract semantics, error envelope codes — unchanged. +- Reserved namespaces, plan-identity invariant, transaction-scope guard — unchanged. + +## What goes away + +- `ValidAnnotations` — kept exported for back-compat consumers, but the framework itself no longer relies on it. The variadic-tuple inference workaround (the `As & ValidAnnotations` intersection) is no longer load-bearing on any first-party surface; the documentation can stop calling it out as a trap. +- The `first` runtime branch on `isAnnotationValue(filterOrFirstAnnotation)` — gone (the configurator's function-type identity makes the dispatch unambiguous). +- The `#withAnnotations(annotations, kind, terminal)` array entry point and the `#buildAnnotationsMap(annotations, kind, terminal)` array entry point — replaced by `MetaBuilder`-aware variants that consume the recorded map directly. + +## Why SQL DSL is out of scope + +`SelectQueryImpl.annotate(...)`, `InsertQueryImpl.annotate(...)`, etc. are chainable builder methods on intermediate builders, not terminals. The user-facing concern — that a variadic on a terminal blocks future per-call options — does not apply to a builder method whose host returns a new builder. The natural growth path on the SQL DSL is additional builder methods (`.cache(...)`, `.tag(...)`) that compose with `.annotate(...)` rather than additional positional arguments to `.annotate(...)` itself. Leaving the SQL DSL chainable form alone also keeps the SQL DSL's annotation surface mechanically the same as it is today — only the ORM client's terminal surface changes. + +If a follow-up wants to align the SQL DSL with the ORM client's "single annotation per call, chain for more" idiom, that can land separately. It does not block this revision. + +## Net effect on the codebase + +Mostly localized to the ORM client and a small new module in framework-components. + +1. **`packages/1-framework/1-core/framework-components/src/meta-builder.ts` (new).** `MetaBuilder` interface and a `createMetaBuilder(kind, terminalName)` factory. Eager `assertAnnotationsApplicable` inside `annotate`. Exported from `exports/runtime.ts`. + +2. **`packages/3-extensions/sql-orm-client/src/collection.ts`.** Each terminal's signature swaps `...annotations: As & ValidAnnotations` for `configure?: (meta: MetaBuilder) => void`. The terminal constructs a `MetaBuilder`, invokes the configurator (no-op if absent), and threads `meta.annotations` into the existing plumbing. The `first` overload set collapses from six to two; the runtime branch on `isAnnotationValue` is removed. `#withAnnotations` and `#buildAnnotationsMap` are replaced by `MetaBuilder`-shaped equivalents. + +3. **`packages/3-extensions/sql-orm-client/src/grouped-collection.ts`.** Mirror change for `aggregate`. + +4. **`packages/3-extensions/sql-orm-client/test/`.** Existing unit and type tests — every site that called `db.User.first({ id }, cacheAnnotation({ ttl }))` becomes `db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))`. The negative type tests (write-only annotation on read terminal, read-only on write terminal) move from variadic-position negatives to `meta.annotate(...)` argument-position negatives. Cast-bypass runtime tests target `meta.annotate(annotation as any)` and assert the same `RUNTIME.ANNOTATION_INAPPLICABLE` envelope. + +5. **`packages/3-extensions/middleware-cache/test/` and `examples/prisma-next-demo/`.** Mechanical: every annotated ORM call site gains a `(meta) => meta.annotate(...)` wrapper. + +6. **`docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md` "Lane integration" section.** Update the ORM `Collection` bullet from "variadic argument with the same gated shape" to "optional last argument `configure: (meta: MetaBuilder) => void`". Update the storage paragraph's "Multiple `.annotate()` calls or terminal arguments compose" to "Multiple `meta.annotate(...)` calls compose". + +7. **`packages/3-extensions/middleware-cache/README.md`.** Update the quick-start example and the "Opt-in by annotation" examples. + +The framework-components annotation module (`annotations.ts`) does **not** change — `defineAnnotation`, `AnnotationValue`, `assertAnnotationsApplicable`, `ValidAnnotations` all stay. The runtime gate keeps working unchanged. + +## Acceptance criteria + +- [ ] `MetaBuilder` is exported from `@prisma-next/framework-components/runtime`. Type tests cover: `meta.annotate(readApplicable)` typechecks for `K = 'read'`; `meta.annotate(writeOnly)` does not for `K = 'read'`; mirror image for `K = 'write'`; a `'read' | 'write'` annotation works on both. Type test asserts `meta.annotate` returns `this` (for chaining). +- [ ] `MetaBuilder.annotate` validates eagerly via `assertAnnotationsApplicable` and throws `RUNTIME.ANNOTATION_INAPPLICABLE` on cast-bypass (unit test, both kinds). +- [ ] Every ORM read terminal accepts an optional `configure: (meta: MetaBuilder<'read'>) => void` last argument and threads `meta.annotations` into `plan.meta.annotations` (integration test, one per terminal). +- [ ] Every ORM write terminal accepts an optional `configure: (meta: MetaBuilder<'write'>) => void` last argument and threads `meta.annotations` into the compiled mutation plan(s) via `mergeUserAnnotations` (integration test, one per terminal). +- [ ] Multiple `meta.annotate(...)` calls compose; duplicate namespace = last-write-wins (unit test). +- [ ] Negative type tests: passing a write-only annotation through `meta.annotate(...)` on a read terminal's configurator fails to compile; mirror image. The test set covers the same matrix the variadic form covers today. +- [ ] `Collection.annotate` does not exist as a method (regression — chainable form was never added; this acceptance carries forward). +- [ ] The runtime `first` overload set collapses to two signatures and the `isAnnotationValue(filterOrFirstAnnotation)` branch is removed (unit test asserts both overloads dispatch correctly: `first(configure)` and `first(filter, configure)`). +- [ ] `pnpm test:packages` passes; `pnpm typecheck` passes; `pnpm lint:deps` clean. +- [ ] Cache middleware's stop-condition integration test (`test/integration/test/cross-package/middleware-cache.test.ts`) passes against the new call shape with the annotated ORM read. +- [ ] Demo (`examples/prisma-next-demo/`) updated to use the new shape; run remains green. + +## Sequencing + +Implement after the cache project's M2 merges (the variadic shape ships first, this revision replaces it). Land as a single PR scoped to: +- the new `meta-builder.ts` module, +- the ORM client signature swap, +- the test file rewrites, +- the subsystem doc + middleware-cache README updates. + +Architecturally independent of M3 (cache middleware) and the deferred follow-ups (drop `Row` generic; thread annotations into nested-mutation / MTI). Can ship before or after either. + +## Open questions + +- **Configurator naming.** `meta` matches `plan.meta.annotations` storage and reads naturally at the call site. Alternatives considered: `q` (too short), `query` (overlaps with existing `query`-named identifiers), `options` (suggests a record literal, not a function). Lean: `meta`. Resolve before signature lock-in. +- **Should the configurator be allowed to return a value?** Today the recorded annotations are read off the builder regardless of return value. We could keep the return type `void` for clarity, or `void | undefined | unknown` for tolerance of arrow expressions like `(meta) => meta.annotate(…)` that return the builder. Lean: type the parameter as `(meta: MetaBuilder) => void` and rely on TypeScript's "return value is ignored when the parameter return type is `void`" rule, which makes both block-body and expression-body callbacks compile. Validate with a type test. +- **`ValidAnnotations` deprecation.** Keep exported for now (an external annotation library or contributor middleware may depend on it); revisit deprecation when the broader middleware API redesign lands in May. Not a blocker for this revision. diff --git a/projects/middleware-intercept-and-cache/follow-ups.md b/projects/middleware-intercept-and-cache/follow-ups.md index 3c6316f0e6..bc83e5f5fc 100644 --- a/projects/middleware-intercept-and-cache/follow-ups.md +++ b/projects/middleware-intercept-and-cache/follow-ups.md @@ -90,6 +90,8 @@ Land after TML-2143 M1 merges. Doesn't block M2 or M3. ### What landed in M2 +> **Status update.** The variadic shape described below shipped in M2 and is the current behavior. It is being replaced by a meta-callback configurator (`(meta) => meta.annotate(...)`); see `api-revision-meta-callback.md` for the delta spec. The threading paths described here (state-driven for `all`/`first`, post-wrap for aggregates and writes) carry over unchanged — only the call-site shape and the per-terminal entry-point methods change. + The variadic annotation argument is now available on every user-facing query-issuing terminal of `Collection` and `GroupedCollection`: - **Read terminals (state-driven):** `all`, `first`. Annotations flow via `state.userAnnotations`, which `compileSelect` and `compileSelectWithIncludeStrategy` thread to `buildOrmQueryPlan`. diff --git a/projects/middleware-intercept-and-cache/plan.md b/projects/middleware-intercept-and-cache/plan.md index e3d490a3a5..e6387099ac 100644 --- a/projects/middleware-intercept-and-cache/plan.md +++ b/projects/middleware-intercept-and-cache/plan.md @@ -65,7 +65,7 @@ Lands the additions to `@prisma-next/framework-components/runtime`. Because `int - [ ] **1.6 Mongo parity test.** Extend `test/integration/test/cross-package/cross-family-middleware.test.ts` (the cross-family proof from TML-2255) with a generic mock interceptor that returns canned rows. Run it through both SQL and Mongo runtimes. Assert: driver not invoked in either family; `afterExecute` sees `source: 'middleware'` in both; rows match canned. No production code change required — Mongo inherits `intercept` via `runWithMiddleware`. -- [ ] **1.7 Implement `defineAnnotation` helper with applicability.** Add `packages/1-framework/1-core/framework-components/src/annotations.ts`. Export `OperationKind = 'read' | 'write'`. `defineAnnotation({ namespace: string, applicableTo: readonly Kinds[] }): AnnotationHandle`. Handle has `namespace`, `applicableTo: ReadonlySet` (frozen), `apply(value: Payload): AnnotationValue`, `read(plan: { meta: { annotations?: Record } }): Payload | undefined`. `AnnotationValue` carries `__annotation: true`, `namespace`, `value`, `applicableTo`. Export from `exports/runtime.ts`. +- [ ] **1.7 Implement `defineAnnotation` helper with applicability.** Add `packages/1-framework/1-core/framework-components/src/annotations.ts`. Export `OperationKind = 'read' | 'write'`. **Curried two-step signature:** `defineAnnotation(): (options: { namespace: string; applicableTo: readonly Kinds[] }) => AnnotationHandle`. The first call takes only `Payload` as an explicit type argument; the second call takes the runtime options and infers `Kinds` from `applicableTo` via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated as a type argument. Currying is required because TypeScript does not support partial type-argument inference: with a single-step `defineAnnotation` form, callers would still have to pass both type arguments explicitly (TS2558) since `Payload` is not inferable from anywhere. The returned handle is a **callable function** (not a plain object): `handle(value: Payload)` produces an `AnnotationValue`. The function also carries `namespace: string`, `applicableTo: ReadonlySet` (frozen), and `read(plan: { meta: { annotations?: Record } }): Payload | undefined` as attached properties. Implementation pattern: outer factory returns an inner generic factory; the inner factory defines a local `function handle(value: Payload) { … }`, then `Object.assign(handle, { namespace, applicableTo, read })` and freezes the result before returning. Type the return value as a hybrid `AnnotationHandle` interface that combines a call signature with the property bag. There is intentionally no `.apply(...)` method — calling the handle directly is the sole construction path. `AnnotationValue` carries `__annotation: true`, `namespace`, `value`, `applicableTo`. Export from `exports/runtime.ts`. - [ ] **1.7a Define `ValidAnnotations` mapped tuple.** In the same module. `type ValidAnnotations[]> = { readonly [I in keyof As]: As[I] extends AnnotationValue ? K extends Kinds ? AnnotationValue : never : never }`. Export. This is the gate type lane terminals consume. @@ -74,7 +74,9 @@ Lands the additions to `@prisma-next/framework-components/runtime`. Because `int - [ ] **1.8 Unit + type tests for `defineAnnotation` and `ValidAnnotations`.** In `framework-components/test/annotations.test.ts` and `annotations.types.test-d.ts`: - Two handles with different namespaces do not interfere. - `read` returns `Payload | undefined` with preserved type (negative test on wrong payload). - - `apply` produces an `AnnotationValue` with the `__annotation` brand and frozen `applicableTo`. + - Calling the handle (e.g. `handle({ ttl: 60 })`) produces an `AnnotationValue` with the `__annotation` brand and frozen `applicableTo`. + - Handle is callable as a function (typeof handle === 'function') and exposes `namespace`, `applicableTo`, and `read` as own properties. + - Handle does **not** expose a `.apply` method (negative test: `'apply' extends keyof typeof handle` is `false`, or equivalently `handle.apply` is the inherited `Function.prototype.apply` rather than the construction entry point). - `read` of an absent annotation returns `undefined`. - `read` ignores annotations applied via a different handle even when the namespace string matches (sanity check on the brand). - `ValidAnnotations<'read', [readOnly, both]>` resolves all elements to live `AnnotationValue` types. @@ -111,7 +113,7 @@ Adds the user-facing `.annotate(...)` surface on both query lanes. After this mi - A write-only annotation accepted on `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl`. - A write-only annotation on `SelectQueryImpl` fails to compile (negative). - A `'read' | 'write'` annotation accepted on every builder kind. - - `defineAnnotation<{ ttl: number }, 'read'>({...}).apply({ ttl: 60 })` accepts only the typed payload; wrong payload shape fails to compile (negative). + - A handle defined as `defineAnnotation<{ ttl: number }>()({ namespace, applicableTo: ['read'] })` accepts only the typed payload when called (`handle({ ttl: 60 })` typechecks; `handle({ ttl: 'sixty' })` fails to compile — negative). `Kinds` is inferred from `applicableTo` (`'read'` here) — verify by asserting the handle's static type is `AnnotationHandle<{ ttl: number }, 'read'>`, not `AnnotationHandle<{ ttl: number }, OperationKind>`. - `.annotate()` does not widen the resulting plan's `Row` type. - [ ] **2.5 Add variadic annotation arg to ORM read terminals.** In `packages/3-extensions/sql-orm-client/src/collection.ts` and `model-accessor.ts`, extend each read terminal's signature with a variadic last argument `[]>(...annotations: As & ValidAnnotations<'read', As>)`: `first`, `find`, `all`, `take().all`, `count`, aggregate methods, and any `findMany`-equivalent terminals. **Use the `As & ValidAnnotations<...>` intersection** — without it the type-level gate silently lets inapplicable annotations through (see Open Items). The terminal calls `assertAnnotationsApplicable(annotations, 'read', '')` before plan construction and merges annotations into `meta.annotations` via the existing plan-builder path (`query-plan-select.ts`, `query-plan-aggregate.ts`). Enumerate the exhaustive terminal list during implementation; if `orm-consolidation` reshapes terminals mid-project, rebase mechanically. @@ -121,17 +123,17 @@ Adds the user-facing `.annotate(...)` surface on both query lanes. After this mi - [ ] **2.7 Drop chainable `Collection.annotate()` if any draft survives.** Belt-and-suspenders: confirm no `.annotate()` survives on `Collection` itself or on grouped/include collection types. Annotations only attach via terminal arguments. `Collection` should be type-identical to its pre-annotation shape minus terminal signatures. - [ ] **2.8 Unit tests for ORM annotations.** In `sql-orm-client/test/`: - - `db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 }))` produces a plan with `meta.annotations.cache`. - - `db.User.where({active: true}).take(10).all(cacheAnnotation.apply({ ttl: 60 }))` likewise. - - `db.User.create(input, writeAnnotation.apply(...))` produces a plan with the annotation. + - `db.User.first({ id }, cacheAnnotation({ ttl: 60 }))` produces a plan with `meta.annotations.cache`. + - `db.User.where({active: true}).take(10).all(cacheAnnotation({ ttl: 60 }))` likewise. + - `db.User.create(input, writeAnnotation(...))` produces a plan with the annotation. - Annotation survives `.include(...)` (relation queries) and grouped paths when attached at the appropriate terminal. - Multiple annotations on a single terminal call coexist; duplicate namespace last-wins. - Runtime check: a write-only annotation cast through `as any` and passed to `first()` throws `RUNTIME.ANNOTATION_INAPPLICABLE` at the lane. - Runtime check: read-only annotation via cast on `create()` throws likewise. - [ ] **2.9 Type tests for ORM annotations.** In `sql-orm-client/test/`: - - `db.User.first({ id }, cacheAnnotation.apply({ ttl: 60 }))` typechecks. - - `db.User.create(input, cacheAnnotation.apply({ ttl: 60 }))` does not compile (negative). + - `db.User.first({ id }, cacheAnnotation({ ttl: 60 }))` typechecks. + - `db.User.create(input, cacheAnnotation({ ttl: 60 }))` does not compile (negative). - Read-only annotation accepted on `first` / `find` / `all` / `count` / aggregates; rejected on `create` / `update` / `delete` / `upsert` (one negative case per write terminal). - Write-only annotation: mirror image. - `'read' | 'write'` annotation accepted on every terminal. @@ -152,13 +154,13 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.3 Scaffold `@prisma-next/middleware-cache`.** Create `packages/3-extensions/middleware-cache/` following the `middleware-telemetry` layout (`package.json`, `tsconfig.json`, `tsconfig.prod.json`, `tsdown.config.ts`, `vitest.config.ts`, `biome.jsonc`, `src/exports/index.ts`, `README.md` stub). Add to `pnpm-workspace` if needed. Run `pnpm lint:deps` after scaffold (before adding real code) to verify clean baseline. -- [ ] **3.4 Define `cacheAnnotation` handle and `CachePayload` type.** In `src/cache-annotation.ts`: `cacheAnnotation = defineAnnotation({ namespace: 'cache', applicableTo: ['read'] })`. `CachePayload = { ttl?: number; skip?: boolean; key?: string }`. Export from `exports/index.ts`. +- [ ] **3.4 Define `cacheAnnotation` handle and `CachePayload` type.** In `src/cache-annotation.ts`: `cacheAnnotation = defineAnnotation()({ namespace: 'cache', applicableTo: ['read'] })` (curried form: `Payload` explicit, `Kinds` inferred as `'read'` from `applicableTo`). `CachePayload = { ttl?: number; skip?: boolean; key?: string }`. Export from `exports/index.ts`. - [ ] **3.5 Define `CacheStore` interface and in-memory LRU default.** In `src/cache-store.ts`: `CacheStore` interface (`get`, `set`), `CachedEntry = { rows: readonly Record[]; storedAt: number }`, `createInMemoryCacheStore({ maxEntries }: { maxEntries: number })` factory producing an LRU-with-TTL store. Injectable clock (`now: () => number`) for TTL testing. Export interface, factory, and `CachedEntry`. - [ ] **3.6 Implement `createCacheMiddleware`.** In `src/cache-middleware.ts`. Returns a cross-family `RuntimeMiddleware` (no `familyId`) with `intercept` / `onRow` / `afterExecute` wired. Private `WeakMap[] }>` keyed on the post-lowering plan object identity. Options: `{ store?: CacheStore; maxEntries?: number; clock?: () => number }`. Default `maxEntries`: 1000. The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. -- [ ] **3.7 Resolve cache keys via `contentHash`.** Two-tier resolution: per-query `cacheAnnotation.apply({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The resolved string is an opaque, bounded SHA-512 digest (literal prefix `sha512:` plus 128 lowercase hex chars, 135 chars total) produced by the family runtime via `hashContent` (`@prisma-next/utils/hash-content`, which wraps `crypto.subtle.digest` and is async); the cache middleware awaits `ctx.contentHash(exec)` and uses the resolved string as the `Map` key as-is. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field. No `keyFn` option, no structural probe, no error path for non-SQL plans — Mongo and any future family work day one as long as their runtime populates `contentHash`. The cache package itself does not import `node:crypto`; hashing is the family runtime's responsibility, and the cache package depends only on `@prisma-next/framework-components/runtime`. User-supplied `cacheAnnotation.apply({ key })` is **not** rehashed — that's the user's responsibility (document in M3.20 README that user keys are stored as-is). +- [ ] **3.7 Resolve cache keys via `contentHash`.** Two-tier resolution: per-query `cacheAnnotation({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The resolved string is an opaque, bounded SHA-512 digest (literal prefix `sha512:` plus 128 lowercase hex chars, 135 chars total) produced by the family runtime via `hashContent` (`@prisma-next/utils/hash-content`, which wraps `crypto.subtle.digest` and is async); the cache middleware awaits `ctx.contentHash(exec)` and uses the resolved string as the `Map` key as-is. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field. No `keyFn` option, no structural probe, no error path for non-SQL plans — Mongo and any future family work day one as long as their runtime populates `contentHash`. The cache package itself does not import `node:crypto`; hashing is the family runtime's responsibility, and the cache package depends only on `@prisma-next/framework-components/runtime`. User-supplied `cacheAnnotation({ key })` is **not** rehashed — that's the user's responsibility (document in M3.20 README that user keys are stored as-is). - [ ] **3.8 ~~Mutation guard.~~** **Dropped.** Lane-level applicability gate (M2) makes a separate in-middleware mutation guard redundant. The cache middleware's `intercept` does not classify operation kind. @@ -170,8 +172,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.12 Unit tests — opt-in semantics.** In `middleware-cache/test/cache-middleware.test.ts`: - Un-annotated query: store never called. - - `cacheAnnotation.apply({ skip: true })`: store never called. - - `cacheAnnotation.apply({ })` (no `ttl`): store never called. + - `cacheAnnotation({ skip: true })`: store never called. + - `cacheAnnotation({ })` (no `ttl`): store never called. - Presence of annotation alone without `ttl` does not cache. - [ ] **3.13 Unit tests — store mechanics.** In `middleware-cache/test/cache-store.test.ts`: @@ -190,7 +192,7 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - Default path: `ctx.contentHash(exec)` is invoked and its return value is used directly as the `Map` key (no further transformation in the cache middleware; the family runtime is responsible for hashing inside `contentHash`). - Different `storageHash` → different keys for otherwise-identical SQL (validates SQL `contentHash` impl from 1.0b end-to-end). - Different params → different keys. - - User-supplied `cacheAnnotation.apply({ key })` short-circuits `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). User-supplied keys are stored as-is — no rehashing. + - User-supplied `cacheAnnotation({ key })` short-circuits `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). User-supplied keys are stored as-is — no rehashing. - (Canonicalization stability across object-key order is covered by `canonicalStringify` tests in 1.0a; hash output format / opacity / bounded size are covered by `hashContent` tests in 1.0a'; not duplicated here.) - [ ] **3.16 Integration test — stop condition.** In `test/integration/test/cross-package/middleware-cache.test.ts`. Real Postgres (per `cross-family-middleware.test.ts` pattern) + ORM. Execute the same annotated ORM query twice. Assert: driver invocation count is 1 (mock-spy or driver-level counter); decoded rows equivalent on both calls; second call's `afterExecute` event sees `source: 'middleware'`. @@ -203,7 +205,7 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat - [ ] **3.19a Cross-family unit tests — key resolution and Mongo parity.** In `middleware-cache/test/cache-key.test.ts`: - Default path: `ctx.contentHash(exec)` is invoked; the returned string is used as the cache key. - - Per-query `cacheAnnotation.apply({ key })` overrides `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). + - Per-query `cacheAnnotation({ key })` overrides `ctx.contentHash` (assert `contentHash` is not invoked when annotation `key` is supplied). - Mongo parity: with a mock `RuntimeMiddlewareContext` whose `contentHash` returns a Mongo-style string, the cache middleware works end-to-end against a non-SQL mock plan (no SQL fields present). Demonstrates the package is genuinely family-agnostic. - Two distinct `contentHash` returns produce two distinct cache entries. @@ -233,7 +235,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat | `hashContent` is deterministic and discriminating | Unit | 1.0a' | Trailing chars + separator placement | | `hashContent` output does not embed raw input (opacity) | Unit | 1.0a' | Sensitive-data isolation | | `OperationKind = 'read' \| 'write'` exported | Type test | 1.7 | Binary kind for April | -| `defineAnnotation({namespace, applicableTo})` signature | Type test | 1.7 | Generic over `Payload` + `Kinds` | +| `defineAnnotation

()({namespace, applicableTo})` signature | Type test | 1.7 | Curried; `Payload` explicit, `Kinds` inferred from `applicableTo` via `const` | +| `Kinds` inferred narrowly (not widened to `OperationKind`) | Type test | 1.8 | `applicableTo: ['read']` ⇒ `Kinds = 'read'`, not `'read' \| 'write'` | | `AnnotationHandle.applicableTo` is `ReadonlySet` | Unit | 1.7 | Frozen at construction | | `ValidAnnotations` resolves matching elements | Type test | 1.8 | Positive | | `ValidAnnotations` resolves mismatched elements to `never` | Type test | 1.8 | Negative both directions | @@ -276,7 +279,8 @@ Delivers `@prisma-next/middleware-cache`. Exit: integration test proves a repeat | Mixed chain: A passthrough, B intercepts → only B's intercept | Unit | 1.4 | A's `beforeExecute` not called (it was a hit) | | Intercepted rows go through codec decoding | Integration | 1.5 | Raw → decoded row assertion via SQL runtime | | Mongo runtime observes `intercept` via inherited helper | Integration | 1.6 | Cross-family proof extension | -| `defineAnnotation` preserves `P` across apply/read | Type test | 1.8 | Negative test for wrong payload | +| `defineAnnotation` preserves `P` across call signature and read | Type test | 1.8 | Negative test for wrong payload | +| Handle is callable; no `.apply` method on the handle | Unit + Type | 1.8 | `typeof handle === 'function'`; `handle({...})` is the sole construction path | | Different namespaces do not interfere | Unit | 1.8 | Two handles, disjoint reads | | `read` ignores cross-handle namespace match | Unit | 1.8 | Brand check | | SQL DSL `.annotate()` merges into `meta.annotations` (read + write) | Unit | 2.3 | All five builder kinds | diff --git a/projects/middleware-intercept-and-cache/spec.md b/projects/middleware-intercept-and-cache/spec.md index 1e3e352363..bbfdd54f5e 100644 --- a/projects/middleware-intercept-and-cache/spec.md +++ b/projects/middleware-intercept-and-cache/spec.md @@ -80,7 +80,7 @@ import { cacheAnnotation } from '@prisma-next/middleware-cache' // annotations; Insert/Update/DeleteQueryBuilder accepts write annotations. const plan = db.sql .from(tables.user) - .annotate(cacheAnnotation.apply({ ttl: 60 })) // ✓ select builder, read annotation + .annotate(cacheAnnotation({ ttl: 60 })) // ✓ select builder, read annotation .select({ id: tables.user.columns.id }) .build() @@ -88,12 +88,12 @@ const plan = db.sql // constrains the accepted annotations to the kinds applicable to it. const user = await db.orm.User.first( { id }, - cacheAnnotation.apply({ ttl: 60 }) // ✓ read annotation on read terminal + cacheAnnotation({ ttl: 60 }) // ✓ read annotation on read terminal ) const created = await db.orm.User.create( { email: 'a@b.com' }, - cacheAnnotation.apply({ ttl: 60 }) // ✗ type error: write terminal rejects read-only annotation + cacheAnnotation({ ttl: 60 }) // ✗ type error: write terminal rejects read-only annotation ) ``` @@ -110,13 +110,13 @@ const db = postgres({ // First call: hits the database, caches raw rows. const a = await db.orm.User - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where({ active: true }) .all() // Second call with identical plan: served from cache, driver not invoked. const b = await db.orm.User - .annotate(cacheAnnotation.apply({ ttl: 60 })) + .annotate(cacheAnnotation({ ttl: 60 })) .where({ active: true }) .all() @@ -154,7 +154,7 @@ const c = await db.orm.User.all() // always hits DB 1. **`OperationKind`.** `type OperationKind = 'read' | 'write'`. Exported from `@prisma-next/framework-components/runtime`. Read = `SELECT` / `find` / `first` / `all` / `count` / aggregates. Write = `INSERT` / `UPDATE` / `DELETE` / `create` / `update` / `delete` / `upsert`. Finer-grained kinds (`'select' | 'insert' | 'update' | 'delete' | 'upsert'`) are deferred; if an annotation appears that needs them, we widen. -2. **`defineAnnotation` helper.** Exported from `@prisma-next/framework-components/runtime`. Signature: `defineAnnotation({ namespace: string; applicableTo: readonly Kinds[] }): AnnotationHandle`. The returned handle exposes `namespace`, `applicableTo: ReadonlySet`, `apply(value)`, and `read(plan)`. Handles are the only supported public entry point for reading/writing annotations. +2. **`defineAnnotation` helper.** Exported from `@prisma-next/framework-components/runtime`. Two-step call form: `defineAnnotation()({ namespace: string; applicableTo: readonly Kinds[] }): AnnotationHandle`. The first step takes only `Payload` as an explicit type argument; the second step takes the runtime options and infers `Kinds` from the `applicableTo` array via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated in the type-argument list. (TypeScript does not support partial type-argument inference within a single call: a single-step `defineAnnotation` would still require both type arguments be passed explicitly because `Payload` cannot be inferred from anywhere; currying separates the explicit-from-inferred step. The cost is one extra `()` at definition; once defined, handles are used identically.) The returned handle is **callable**: invoking `handle(value)` produces an `AnnotationValue` ready to pass to a lane terminal's variadic `annotations` argument. The handle also exposes `namespace`, `applicableTo: ReadonlySet`, and `read(plan)` as properties on the function. Handles are the only supported public entry point for reading/writing annotations. (No `.apply(...)` method — calling the handle directly is the sole construction path; this keeps user-facing call sites compact: `cacheAnnotation({ ttl: 60 })` rather than `cacheAnnotation.apply({ ttl: 60 })`.) 3. **Applicability gate type.** `ValidAnnotations[]>` mapped tuple type that resolves each element of `As` to `never` when its declared `Kinds` does not include `K`. Lane terminals use this to constrain their variadic annotation argument. @@ -162,17 +162,17 @@ const c = await db.orm.User.all() // always hits DB 5. **SQL DSL `.annotate()`.** Chainable builder method, typed per builder kind. `SelectQueryImpl` / `GroupedQueryImpl` (in `packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts`) accept `ValidAnnotations<'read', As>`; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` (in `mutation-impl.ts`) accept `ValidAnnotations<'write', As>`. Annotations merge into `plan.meta.annotations` at `.build()` time. Multiple `.annotate()` calls compose; duplicate namespaces use last-write-wins. -6. **ORM terminal-argument annotations.** Each terminal method on `Collection` (`packages/3-extensions/sql-orm-client/src/collection.ts`) accepts a variadic last argument `...annotations: ValidAnnotations` where `K` is the terminal's operation kind. Read terminals (`first`, `find`, `all`, `take().all`, `count`, aggregate methods, `get`, `findMany`-equivalents): `K = 'read'`. Write terminals (`create`, `update`, `delete`, `upsert`, and any in-place mutation entry points): `K = 'write'`. There is **no** chainable `.annotate()` on `Collection`; this is an intentional scope cut from earlier drafts (it would have required a separate mutation-classifier in the cache middleware). +6. **ORM terminal-argument annotations.** Each terminal method on `Collection` (`packages/3-extensions/sql-orm-client/src/collection.ts`) accepts a variadic last argument `...annotations: ValidAnnotations` where `K` is the terminal's operation kind. Read terminals (`first`, `find`, `all`, `take().all`, `count`, aggregate methods, `get`, `findMany`-equivalents): `K = 'read'`. Write terminals (`create`, `update`, `delete`, `upsert`, and any in-place mutation entry points): `K = 'write'`. There is **no** chainable `.annotate()` on `Collection`; this is an intentional scope cut from earlier drafts (it would have required a separate mutation-classifier in the cache middleware). *(Superseded post-M2 by `api-revision-meta-callback.md`: the variadic last argument is replaced by an optional `configure: (meta: MetaBuilder) => void` callback. The applicability gate `K` and its semantics carry over; see the revision spec for the call-site shape and the rationale.)* 7. **Runtime applicability check at the lane.** Each lane terminal walks its variadic `annotations` array and rejects any whose `applicableTo` set does not include the terminal's operation kind, throwing a clear `runtimeError` (e.g. `RUNTIME.ANNOTATION_INAPPLICABLE`) naming the annotation namespace and the terminal. The runtime check is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. -8. **Type safety.** `defineAnnotation` preserves `Payload` and `Kinds` across `apply` and `read`. Reading an absent annotation returns `undefined`. No `any` or unchecked casts in the public surface. +8. **Type safety.** `defineAnnotation` preserves `Payload` and `Kinds` across the handle's call signature and `read`. Reading an absent annotation returns `undefined`. No `any` or unchecked casts in the public surface. ### Cache middleware (`@prisma-next/middleware-cache`, new package) -1. **Opt-in by annotation.** `cacheAnnotation = defineAnnotation({ namespace: 'cache', applicableTo: ['read'] })`. Payload: `{ ttl?: number; skip?: boolean; key?: string }`. A query without `cacheAnnotation`, or with `skip: true`, or without a `ttl`, passes through untouched. Because `cacheAnnotation` declares `applicableTo: ['read']`, the lane gate (type-level + runtime) rejects passing it to a write terminal — there is no in-middleware mutation guard. +1. **Opt-in by annotation.** `cacheAnnotation = defineAnnotation()({ namespace: 'cache', applicableTo: ['read'] })` (`Kinds` inferred as `'read'`). Payload: `{ ttl?: number; skip?: boolean; key?: string }`. A query without `cacheAnnotation`, or with `skip: true`, or without a `ttl`, passes through untouched. Because `cacheAnnotation` declares `applicableTo: ['read']`, the lane gate (type-level + runtime) rejects passing it to a write terminal — there is no in-middleware mutation guard. -2. **Cache key resolution.** Two-tier priority: per-query `cacheAnnotation.apply({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field — it depends only on `@prisma-next/framework-components/runtime`. +2. **Cache key resolution.** Two-tier priority: per-query `cacheAnnotation({ key })` overrides everything; otherwise `ctx.contentHash(exec)` from the family runtime. The cache middleware itself never reads `exec.sql`, `exec.command`, or any other family-specific field — it depends only on `@prisma-next/framework-components/runtime`. 3. **Cache hit path.** On lookup hit, `intercept` returns the cached raw rows. The SQL runtime decodes them through its normal codec pass (which wraps the orchestrator output). `afterExecute` observes `source: 'middleware'`. @@ -215,7 +215,7 @@ const c = await db.orm.User.all() // always hits DB - **`ctx.state` per-query scratch space.** Not needed for the cache middleware; not added opportunistically. Middleware that need correlation use per-instance `WeakMap` keyed on plan identity. - **Middleware ordering metadata (`dependsOn`, `conflictsWith`).** Registration order remains the sole source of truth. - **Cache invalidation strategies beyond TTL and storage-hash keying.** No event-based invalidation, no tag-based invalidation, no `db.orm.User.invalidate()`. Deferred to May. -- **Chainable ORM `.annotate()`.** Annotations attach via the variadic last argument on terminal methods only. The original draft proposed a chainable `Collection.annotate()`; that was dropped because it forced an in-middleware mutation guard and fought the applicability-gate design. May reconsider if a real ergonomic problem surfaces. +- **Chainable ORM `.annotate()`.** Annotations attach via the variadic last argument on terminal methods only. The original draft proposed a chainable `Collection.annotate()`; that was dropped because it forced an in-middleware mutation guard and fought the applicability-gate design. May reconsider if a real ergonomic problem surfaces. *(Update: a real ergonomic problem did surface — variadic-on-terminal forecloses on adding any future per-call options. The replacement is not a chainable `Collection.annotate()` but a meta-callback configurator: `db.orm.User.find({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))`. See `api-revision-meta-callback.md` for the delta spec. The "no chainable on `Collection`" cut still holds — the chainable would have lived on `Collection`; the configurator lives on a `MetaBuilder` constructed by the terminal.)* - **Finer-grained `OperationKind`.** `'read' | 'write'` for April. No `'select' | 'insert' | 'update' | 'delete' | 'upsert'`, no per-aggregate distinctions. Widening is additive — handles already accept a `Kinds` set, so a future split keeps existing handles compiling. - **`contentHash` API surface beyond what the cache middleware needs.** The method returns `Promise` (resolving to a `sha512:HEX128` digest). No structured (`{statement, params}`) shape and no batch variant. Future content-hash-keyed middleware (request coalescing, single-flight) consume the same string. - **Annotation validation at contract-emit time.** Annotations are runtime metadata only; they do not affect the Contract or its hashes. @@ -243,7 +243,7 @@ const c = await db.orm.User.all() // always hits DB ## Annotation surface - [ ] `OperationKind` exported from `@prisma-next/framework-components/runtime` as `'read' | 'write'` (type test). -- [ ] `defineAnnotation({ namespace, applicableTo })` exists in `@prisma-next/framework-components/runtime`, typed as described. +- [ ] `defineAnnotation()({ namespace, applicableTo })` exists in `@prisma-next/framework-components/runtime`, typed as described (curried; `Kinds` inferred from `applicableTo` via a `const` type parameter on the inner call). - [ ] `read` returns `Payload | undefined` with full type preservation (type-level test). - [ ] Handle exposes `applicableTo: ReadonlySet` for runtime checks (unit test). - [ ] Two handles with different namespaces do not interfere (unit test). @@ -252,9 +252,9 @@ const c = await db.orm.User.all() // always hits DB - [ ] SQL DSL `InsertQueryImpl.annotate(...)` accepts a write-only annotation; the same call against `SelectQueryImpl` fails to compile (type test). - [ ] SQL DSL `.annotate(...)` merges into `plan.meta.annotations[namespace]` at `.build()` time across all five builder kinds (unit test). - [ ] Multiple `.annotate()` calls compose; duplicate namespace = last-write-wins (unit test). -- [ ] ORM read-terminal call `db.User.first(where, cacheAnnotation.apply({ ttl: 60 }))` typechecks; `db.User.create(input, cacheAnnotation.apply({ ttl: 60 }))` does not (type test, both directions). +- [ ] ORM read-terminal call `db.User.first(where, cacheAnnotation({ ttl: 60 }))` typechecks; `db.User.create(input, cacheAnnotation({ ttl: 60 }))` does not (type test, both directions). - [ ] ORM write-only annotation accepted on `create` / `update` / `delete` / `upsert`; rejected on `first` / `find` / `all` / `count` (type test). -- [ ] Annotations applicable to both kinds (e.g. `defineAnnotation(...)`) accepted on every terminal (type test). +- [ ] Annotations applicable to both kinds (e.g. `defineAnnotation

()({ namespace, applicableTo: ['read', 'write'] })`) accepted on every terminal (type test). - [ ] An annotated ORM read produces a `SqlQueryPlan` whose `meta.annotations[namespace]` carries the payload (integration test). - [ ] An annotated SQL DSL query produces a `SqlQueryPlan` whose `meta.annotations[namespace]` carries the payload (integration test). - [ ] Lane runtime check rejects an annotation whose `applicableTo` set does not include the terminal's kind, with `RUNTIME.ANNOTATION_INAPPLICABLE` naming the annotation and terminal (unit test, exercised via cast bypass of the type gate). @@ -265,7 +265,7 @@ const c = await db.orm.User.all() // always hits DB - [ ] `@prisma-next/middleware-cache` package exists under `packages/3-extensions/`, following the layering conventions validated by `pnpm lint:deps`. - [ ] `cacheAnnotation` handle is exported; payload shape matches the spec. - [ ] `CacheStore` interface is exported; default in-memory LRU implementation is exported. -- [ ] `createCacheMiddleware(options)` returns a cross-family `RuntimeMiddleware` with `intercept` / `onRow` / `afterExecute` wired. The middleware reads cache keys from `ctx.contentHash(exec)` (or `cacheAnnotation.apply({ key })` when supplied per-query). The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. +- [ ] `createCacheMiddleware(options)` returns a cross-family `RuntimeMiddleware` with `intercept` / `onRow` / `afterExecute` wired. The middleware reads cache keys from `ctx.contentHash(exec)` (or `cacheAnnotation({ key })` when supplied per-query). The package depends only on `@prisma-next/framework-components/runtime` — no SQL or Mongo runtime dependency. - [ ] Un-annotated queries are never cached (unit test). - [ ] Queries with `skip: true` are never cached (unit test). - [ ] Queries with no `ttl` are never cached (unit test). diff --git a/test/integration/package.json b/test/integration/package.json index 2cb9e8a692..1a927454da 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@prisma-next/framework-components": "workspace:*", + "@prisma-next/middleware-cache": "workspace:*", "@prisma-next/middleware-telemetry": "workspace:*", "@prisma-next/mongo-lowering": "workspace:*", "@prisma-next/test-utils": "workspace:*", diff --git a/test/integration/test/cross-package/cross-family-middleware.test.ts b/test/integration/test/cross-package/cross-family-middleware.test.ts index f9aa43b8be..5df4ec271b 100644 --- a/test/integration/test/cross-package/cross-family-middleware.test.ts +++ b/test/integration/test/cross-package/cross-family-middleware.test.ts @@ -138,6 +138,7 @@ const sqlCtx: RuntimeMiddlewareContext = { now: () => Date.now(), log: { info: () => {}, warn: () => {}, error: () => {} }, contentHash: async () => 'mock-hash', + scope: 'runtime', }; describe('cross-family middleware proof', () => { diff --git a/test/integration/test/cross-package/middleware-cache.test.ts b/test/integration/test/cross-package/middleware-cache.test.ts new file mode 100644 index 0000000000..c4cd72dca3 --- /dev/null +++ b/test/integration/test/cross-package/middleware-cache.test.ts @@ -0,0 +1,494 @@ +import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import { emptyCodecLookup } from '@prisma-next/framework-components/codec'; +import { + type ExecutionStackInstance, + instantiateExecutionStack, + type RuntimeDriverInstance, +} from '@prisma-next/framework-components/execution'; +import { cacheAnnotation, createCacheMiddleware } from '@prisma-next/middleware-cache'; +import { createTelemetryMiddleware, type TelemetryEvent } from '@prisma-next/middleware-telemetry'; +import { sql } from '@prisma-next/sql-builder/runtime'; +import { validateContract } from '@prisma-next/sql-contract/validate'; +import { + AndExpr, + BinaryExpr, + ColumnRef, + ParamRef, + type SelectAst, +} from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { + createExecutionContext, + createRuntime, + createSqlExecutionStack, + type Runtime, + type SqlMiddleware, + type SqlRuntimeAdapterInstance, + type SqlRuntimeDriverInstance, + type SqlRuntimeExtensionInstance, +} from '@prisma-next/sql-runtime'; +import { setupTestDatabase } from '@prisma-next/sql-runtime/test/utils'; +import postgresTarget from '@prisma-next/target-postgres/runtime'; +import { createDevDatabase, timeouts } from '@prisma-next/test-utils'; +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { contract } from '../sql-builder/fixtures/contract'; +import type { Contract } from '../sql-builder/fixtures/generated/contract'; + +/** + * Integration tests for `@prisma-next/middleware-cache` against real + * Postgres. Exercises the **April stop condition** for TML-2143 / VP4: + * a repeated query is served from cache without hitting the database. + * + * Tests in this file: + * - 3.16 Stop condition — second annotated query skips the driver. + * - 3.17 Composition with a `beforeCompile`-style rewriter (soft-delete). + * - 3.18 Composition with telemetry — `source` field round-trips. + * - 3.19 Concurrency regression — two parallel calls of the same plan + * don't cross-talk through the per-exec WeakMap buffer. + */ + +const sqlContract = validateContract(contract, emptyCodecLookup); + +type TestStackInstance = ExecutionStackInstance< + 'sql', + 'postgres', + SqlRuntimeAdapterInstance<'postgres'>, + RuntimeDriverInstance<'sql', 'postgres'>, + SqlRuntimeExtensionInstance<'postgres'> +>; + +/** + * A `beforeCompile` middleware that injects a predicate filtering out + * rows where `users.invited_by_id IS NULL` — used as a stand-in for a + * "soft delete" rewriter to exercise composition with the cache. The + * cache key reflects the rewritten SQL because the cache middleware + * sees the post-lowering plan. + */ +function activeUsersOnly(): SqlMiddleware { + return { + name: 'active-users-only', + familyId: 'sql', + async beforeCompile(draft) { + if (draft.ast.kind !== 'select') return undefined; + if (draft.ast.from.kind !== 'table-source') return undefined; + if (draft.ast.from.name !== 'users') return undefined; + const invitedByPresent = BinaryExpr.gte( + ColumnRef.of('users', 'id'), + ParamRef.of(2, { name: 'soft_delete_min_id', codecId: 'pg/int4@1' }), + ); + const newAst: SelectAst = draft.ast.withWhere( + draft.ast.where ? AndExpr.of([draft.ast.where, invitedByPresent]) : invitedByPresent, + ); + return { ...draft, ast: newAst }; + }, + }; +} + +describe( + 'integration: middleware-cache against real Postgres', + { timeout: timeouts.databaseOperation }, + () => { + let context: ExecutionContext; + let driver: SqlRuntimeDriverInstance<'postgres'>; + let stackInstance: TestStackInstance; + let driverExecuteSpy: ReturnType; + const closeFns: Array<() => Promise> = []; + + beforeAll(async () => { + const database = await createDevDatabase(); + const client = new Client({ connectionString: database.connectionString }); + await client.connect(); + + await setupTestDatabase(client, sqlContract, async (c) => { + await c.query(` + CREATE TABLE users ( + id int4 PRIMARY KEY, + name text NOT NULL, + email text NOT NULL, + invited_by_id int4 + ) + `); + await c.query('CREATE EXTENSION IF NOT EXISTS vector'); + await c.query(` + CREATE TABLE posts ( + id int4 PRIMARY KEY, + title text NOT NULL, + user_id int4 NOT NULL, + views int4 NOT NULL, + embedding vector(3) + ) + `); + await c.query(` + CREATE TABLE comments ( + id int4 PRIMARY KEY, + body text NOT NULL, + post_id int4 NOT NULL + ) + `); + await c.query(` + CREATE TABLE profiles ( + id int4 PRIMARY KEY, + user_id int4 NOT NULL, + bio text NOT NULL + ) + `); + await c.query(` + CREATE TABLE articles ( + id uuid PRIMARY KEY, + title text NOT NULL + ) + `); + + await c.query(` + INSERT INTO users (id, name, email, invited_by_id) VALUES + (1, 'Alice', 'alice@example.com', NULL), + (2, 'Bob', 'bob@example.com', 1), + (3, 'Charlie', 'charlie@example.com', 1), + (4, 'Diana', 'diana@example.com', 2) + `); + }); + + const stack = createSqlExecutionStack({ + target: postgresTarget, + adapter: postgresAdapter, + driver: { + ...postgresDriver, + create() { + return postgresDriver.create({ cursor: { disabled: true } }); + }, + }, + extensionPacks: [pgvector], + }); + + stackInstance = instantiateExecutionStack(stack) as TestStackInstance; + context = createExecutionContext({ contract: sqlContract, stack }); + const resolvedDriver = stackInstance.driver; + if (!resolvedDriver) throw new Error('Driver missing'); + driver = resolvedDriver as SqlRuntimeDriverInstance<'postgres'>; + await driver.connect({ kind: 'pgClient', client }); + + // Spy on the driver's execute so we can count round-trips. The + // cache middleware short-circuits via `intercept` upstream of + // `runDriver`, so a hit shows up here as zero invocations. + driverExecuteSpy = vi.spyOn(driver, 'execute'); + + closeFns.push( + () => driver.close(), + () => client.end(), + () => database.close(), + ); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + for (const fn of closeFns) { + try { + await fn(); + } catch { + // ignore cleanup errors + } + } + }); + + function buildRuntime(middleware: SqlMiddleware[]): Runtime { + return createRuntime({ + stackInstance, + context, + driver, + verify: { mode: 'onFirstUse', requireMarker: false }, + middleware, + }); + } + + describe('stop condition (M3.16)', () => { + it('serves a repeated annotated read from cache without hitting the driver', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First call — cache miss; driver invoked. + const first = await runtime.execute(buildPlan()).toArray(); + const driverCallsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(driverCallsAfterFirst).toBeGreaterThan(0); + + // Second call — cache hit; driver not invoked again. + const second = await runtime.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(driverCallsAfterFirst); + + // Both calls produce equivalent decoded rows. + expect(second).toEqual(first); + // Sanity — the table has 4 users so we got real data back. + expect(first.length).toBe(4); + }); + + it('still hits the driver for an un-annotated query (cache is opt-in)', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + driverExecuteSpy.mockClear(); + + await runtime.execute(db.users.select('id').build()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + await runtime.execute(db.users.select('id').build()).toArray(); + // Second un-annotated call hits the driver again. + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('does not cache a query when its cacheAnnotation has skip: true', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60_000, skip: true })) + .build(); + + driverExecuteSpy.mockClear(); + + await runtime.execute(buildPlan()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + + await runtime.execute(buildPlan()).toArray(); + // Both calls hit the driver — skip: true bypasses the cache. + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + }); + + describe('composition with beforeCompile rewriter (M3.17)', () => { + it('cache key reflects the rewritten SQL; rewritten predicate is preserved on the hit path', async () => { + // Order: rewriter first, then cache. The cache sees the + // post-lowering exec, so the rewritten predicate is part of + // the cache key by construction. + const rewriter = activeUsersOnly(); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([rewriter, cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First call: rewriter prepends `id >= 2`, driver executes. + const first = await runtime.execute(buildPlan()).toArray(); + const callsAfterFirst = driverExecuteSpy.mock.calls.length; + expect(callsAfterFirst).toBeGreaterThan(0); + + // The rewriter's `id >= 2` predicate filtered out user 1 + // (Alice), so the cached results don't include her. + expect(first.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + + // Second call: cache hit, driver skipped, but the consumer + // still sees the rewritten (filtered) result set. + const second = await runtime.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterFirst); + expect(second).toEqual(first); + expect(second.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + }); + + it('cache key for the same query differs when registered with vs. without the rewriter', async () => { + // Two runtimes share the same custom CacheStore so we can + // observe whether the rewriter changes the key. + const { createInMemoryCacheStore } = await import('@prisma-next/middleware-cache'); + const sharedStore = createInMemoryCacheStore({ maxEntries: 100 }); + + const cacheNoRewrite = createCacheMiddleware({ store: sharedStore }); + const runtimeNoRewrite = buildRuntime([cacheNoRewrite]); + + const cacheWithRewrite = createCacheMiddleware({ store: sharedStore }); + const runtimeWithRewrite = buildRuntime([activeUsersOnly(), cacheWithRewrite]); + + const db = sql({ context }); + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + + // First runtime (no rewriter) populates the cache under one key. + const noRewrite = await runtimeNoRewrite.execute(buildPlan()).toArray(); + const callsAfterNoRewrite = driverExecuteSpy.mock.calls.length; + expect(noRewrite.map((r) => r['id']).sort()).toEqual([1, 2, 3, 4]); + + // Second runtime (with rewriter) sees a *different* lowered SQL + // and therefore a different contentHash — it must miss and + // hit the driver again. + const withRewrite = await runtimeWithRewrite.execute(buildPlan()).toArray(); + expect(driverExecuteSpy.mock.calls.length).toBeGreaterThan(callsAfterNoRewrite); + expect(withRewrite.map((r) => r['id']).sort()).toEqual([2, 3, 4]); + }); + }); + + describe('composition with telemetry (M3.18)', () => { + it('telemetry observes source: "driver" on miss and source: "middleware" on hit', async () => { + const events: TelemetryEvent[] = []; + const telemetry = createTelemetryMiddleware({ onEvent: (e) => events.push(e) }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + // Cache first so its `intercept` runs upstream of telemetry's + // `beforeExecute`. Telemetry's `afterExecute` still fires on + // both paths and observes the `source` field. + const runtime = buildRuntime([cache, telemetry]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); + + driverExecuteSpy.mockClear(); + events.length = 0; + + // Miss path. + await runtime.execute(buildPlan()).toArray(); + + const missEvents = events.slice(); + // A `beforeExecute` event fires only when the cache misses + // (cache's intercept returned undefined → driver path runs). + expect(missEvents.find((e) => e.phase === 'beforeExecute')).toBeDefined(); + const missAfter = missEvents.find((e) => e.phase === 'afterExecute'); + expect(missAfter).toBeDefined(); + expect(missAfter!.source).toBe('driver'); + + events.length = 0; + driverExecuteSpy.mockClear(); + + // Hit path. + await runtime.execute(buildPlan()).toArray(); + + // beforeExecute is suppressed on the intercepted hit path. + expect(events.find((e) => e.phase === 'beforeExecute')).toBeUndefined(); + const hitAfter = events.find((e) => e.phase === 'afterExecute'); + expect(hitAfter).toBeDefined(); + expect(hitAfter!.source).toBe('middleware'); + expect(driverExecuteSpy.mock.calls.length).toBe(0); + }); + + it('telemetry rowCount and latencyMs populate correctly on both paths', async () => { + const events: TelemetryEvent[] = []; + const telemetry = createTelemetryMiddleware({ onEvent: (e) => events.push(e) }); + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache, telemetry]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .build(); + + // Miss → commit. + await runtime.execute(buildPlan()).toArray(); + // Hit. + events.length = 0; + await runtime.execute(buildPlan()).toArray(); + + const after = events.find((e) => e.phase === 'afterExecute'); + expect(after).toBeDefined(); + expect(after!.rowCount).toBe(4); + expect(typeof after!.latencyMs).toBe('number'); + expect(after!.completed).toBe(true); + }); + }); + + describe('concurrency regression (M3.19)', () => { + it('two parallel executes of the same plan do not cross-talk via the per-exec buffer', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + const buildPlan = () => + db.users + .select('id', 'name') + .annotate(cacheAnnotation({ ttl: 60_000, key: 'concurrency-test' })) + .build(); + + driverExecuteSpy.mockClear(); + + // Two parallel executions of the same logical plan. Each + // produces its own frozen `exec` object inside the runtime + // (executeAgainstQueryable freezes per-call), and the cache + // middleware keys its WeakMap on that identity — so the two + // calls' miss buffers must not interfere. + const [a, b] = await Promise.all([ + runtime.execute(buildPlan()).toArray(), + runtime.execute(buildPlan()).toArray(), + ]); + + // Both calls produce correct, identical results. + expect(a).toEqual(b); + expect(a.length).toBe(4); + + // After both finish, the cache holds a single entry (one of + // the misses commits last; same key, same data). + const third = await runtime.execute(buildPlan()).toArray(); + expect(third).toEqual(a); + }); + + it('parallel executes of two different plans land in distinct cache slots', async () => { + const cache = createCacheMiddleware({ maxEntries: 100 }); + const runtime = buildRuntime([cache]); + const db = sql({ context }); + + driverExecuteSpy.mockClear(); + + const planA = db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-A' })) + .build(); + const planB = db.posts + .select('id', 'title') + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-B' })) + .build(); + + const [a, b] = await Promise.all([ + runtime.execute(planA).toArray(), + runtime.execute(planB).toArray(), + ]); + + expect(a.every((r) => 'id' in r && !('title' in r))).toBe(true); + expect(b.every((r) => 'id' in r && 'title' in r)).toBe(true); + + // The next call to each lands on the cache (driver count + // unchanged after these reads). + const callsAfterParallel = driverExecuteSpy.mock.calls.length; + await runtime + .execute( + db.users + .select('id') + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-A' })) + .build(), + ) + .toArray(); + await runtime + .execute( + db.posts + .select('id', 'title') + .annotate(cacheAnnotation({ ttl: 60_000, key: 'parallel-B' })) + .build(), + ) + .toArray(); + expect(driverExecuteSpy.mock.calls.length).toBe(callsAfterParallel); + }); + }); + }, +);