Skip to content

feat: implement caching middleware#385

Draft
aqrln wants to merge 21 commits intomainfrom
cache-middleware-impl
Draft

feat: implement caching middleware#385
aqrln wants to merge 21 commits intomainfrom
cache-middleware-impl

Conversation

@aqrln
Copy link
Copy Markdown
Member

@aqrln aqrln commented Apr 27, 2026

Cache middleware + annotation surface

Refs: TML-2143

Headline

Opt one read into the cache with one configurator argument:

const user = await db.User.first(
  { id },
  (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })),
);

const recent = await db.User.take(10).all(
  (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })),
);

// Type error: cacheAnnotation declares applicableTo: ['read']; create() fixes K = 'write'.
await db.User.create(input, (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })));

SQL DSL keeps its existing chainable variadic — builders are not terminals, so growth happens via additional builder methods rather than additional positional args:

const plan = db.sql
  .from(tables.user)
  .select({ id: tables.user.columns.id })
  .annotate(cacheAnnotation({ ttl: 60_000 }))
  .build();

Wiring the middleware on the runtime:

const db = postgres<Contract, TypeMaps>({
  contractJson,
  url: process.env['DATABASE_URL']!,
  middleware: [createCacheMiddleware({ maxEntries: 1000 })],
});

Summary

Builds on the intercept hook + contentHash(exec) SPI from the prior PR (cache-middleware-intercept) and lands the rest of the cache-middleware project:

  • A typed annotation surface on @prisma-next/framework-components: defineAnnotation / ValidAnnotations / assertAnnotationsApplicable, plus the OperationKind = 'read' | 'write' discriminator and AnnotationHandle / AnnotationValue types.
  • A MetaBuilder<K> module (createMetaBuilder(kind, terminalName) factory) that ORM terminals construct and hand to a user-supplied configurator callback. Public surface is meta.annotate(annotation) with a conditional K extends Kinds ? AnnotationValue<P, Kinds> : never parameter type — no variadic-tuple inference, no load-bearing intersection.
  • Lane integration on every user-facing query surface: chainable .annotate(...) on all five SQL DSL builders (variadic, kept as today), and an optional configure: (meta: MetaBuilder<K>) => void last argument on every ORM Collection / GroupedCollection terminal. Both surfaces fail closed at the type level (the SQL DSL via the variadic-tuple intersection As & ValidAnnotations<K, As>, the ORM via the conditional parameter on meta.annotate) and at runtime (via assertAnnotationsApplicable invoked by meta.annotate and by .build()).
  • A new @prisma-next/middleware-cache package with cacheAnnotation, the CacheStore interface, an in-memory LRU createInMemoryCacheStore, and the cross-family createCacheMiddleware that wires intercept / onRow / afterExecute.
  • A scope: 'runtime' | 'connection' | 'transaction' discriminator on RuntimeMiddlewareContext, populated by both family runtimes, used by the cache middleware to bypass non-runtime executions where caller expectations diverge from the shared cache surface.
  • Demo programs (cache-demo-user, cache-demo-users, cache-demo-sql) under examples/prisma-next-demo, and a real-Postgres integration test that pins the April stop condition: a repeated annotated query is served by the middleware without invoking the driver.
  • Subsystem documentation updates in docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md covering the intercept lifecycle, the annotation surface, the new scope field, and the cache integration as the canonical example.

Motivation

The prior PR added the SPI — intercept, AfterExecuteResult.source, contentHash(exec) — but exposed no user-facing way to drive it. A hand-written RuntimeMiddleware could already short-circuit executions, but no product feature consumed the SPI, and there was no way for a query author to say "cache this read."

Two design constraints shaped the surface:

  1. Caching a mutation has to be structurally impossible. Either the type system rejects it, or the runtime does — preferably both. Threading a per-middleware "is-this-a-mutation?" classifier through every adapter would have been correct but ad-hoc and hard to reuse.

  2. The cache middleware must stay cross-family. The package may not depend on @prisma-next/sql-runtime or @prisma-next/mongo-runtime. Cache key resolution has to flow through the framework-level ctx.contentHash(exec) SPI; the cache itself can read no family-specific field of the lowered exec.

The annotation surface satisfies both: handles carry a declared applicableTo set, the MetaBuilder.annotate parameter resolves to never for inapplicable kinds, and the cache middleware reads the payload through the handle's own read(plan) rather than touching plan.meta.annotations directly. The lane-level applicability gate replaces what would otherwise be an in-middleware mutation guard.

What's in this PR

@prisma-next/framework-components — annotation surface

New module src/annotations.ts (re-exported from exports/runtime):

  • type OperationKind = 'read' | 'write' — binary discrimination for now. Finer-grained kinds ('select' | 'insert' | 'update' | 'delete' | 'upsert') are deferred until a real annotation needs them.

  • defineAnnotation<Payload>()(options) — curried two-step factory. The outer call takes only Payload as an explicit type argument; the inner call takes the runtime options and infers Kinds from applicableTo via a const type parameter. Returns an AnnotationHandle<Payload, Kinds> that is callablehandle(value) constructs an AnnotationValue ready to pass to a lane terminal. The handle also exposes namespace, applicableTo: ReadonlySet<Kinds>, and read(plan): Payload | undefined as own properties on the function. There is intentionally no .apply(...) method on the interface — calling the handle directly is the sole construction path.

    Currying is required because TypeScript does not support partial type-argument inference: a single-step defineAnnotation<Payload, const Kinds> form would still require both type arguments be passed explicitly (TS2558), since Payload is not inferable from anything in the options. Splitting the call separates the explicit-from-inferred step at the cost of one extra () at the definition site.

  • AnnotationValue<Payload, Kinds> — branded with __annotation: true; carries namespace, value: Payload, and applicableTo: ReadonlySet<Kinds>. Stored verbatim in plan.meta.annotations[namespace]. The brand is what handle.read checks before returning, so framework-internal metadata under the same namespace key (e.g. the SQL emitter's meta.annotations.codecs map) does not surface as a user-handle read.

  • ValidAnnotations<K, As> — mapped tuple type that resolves each element of As to never when its declared Kinds does not include K. Consumed by SQL DSL .annotate(...) builders on their variadic ...annotations parameter. The TSDoc records the discovery surfaced during M2: builders must use As & ValidAnnotations<K, As>, not just ValidAnnotations<K, As> — without the intersection, TypeScript's variadic-tuple inference picks an As that makes the call valid even when the gated tuple would contain never. The intersection pins As to the actual call-site tuple AND requires assignability to the gated form. The ORM MetaBuilder deliberately sidesteps this trick by taking one annotation per call (no variadic-tuple inference involved).

  • assertAnnotationsApplicable(annotations, kind, terminalName) — runtime gate. Throws RUNTIME.ANNOTATION_INAPPLICABLE with structured details (namespace, terminalName, kind, applicableTo) on the first inapplicable annotation. Belt-and-suspenders for callers that bypass the type gate via as any / dynamic invocation.

Coverage: 23 runtime tests + 24 type-level tests in framework-components/test/annotations.{test.ts,types.test-d.ts} covering handle metadata, value construction, read/write isolation, the gate type's element-wise behavior, and the runtime check's error paths.

@prisma-next/framework-componentsMetaBuilder<K> module

New module src/meta-builder.ts (re-exported from exports/runtime):

export interface MetaBuilder<K extends OperationKind> {
  annotate<P, Kinds extends OperationKind>(
    annotation: K extends Kinds ? AnnotationValue<P, Kinds> : never,
  ): this;
}

export interface LaneMetaBuilder<K extends OperationKind> extends MetaBuilder<K> {
  readonly annotations: ReadonlyMap<string, AnnotationValue<unknown, OperationKind>>;
}

export function createMetaBuilder<K extends OperationKind>(
  kind: K,
  terminalName: string,
): LaneMetaBuilder<K>;
  • The conditional K extends Kinds ? AnnotationValue<P, Kinds> : never parameter type collapses to never for inapplicable annotations, so meta.annotate(writeOnly) on a 'read' builder fails to compile at the call site. No variadic-tuple inference, no As & ValidAnnotations<K, As> intersection — TypeScript infers Kinds from the annotation argument, then evaluates the conditional.
  • 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.
  • annotate returns this, so chaining (meta.annotate(a).annotate(b)) and block-body callbacks ((meta) => { meta.annotate(a); meta.annotate(b); }) both work.
  • MetaBuilder<K> is the public-facing view (only annotate); LaneMetaBuilder<K> is the lane-side view that exposes the recorded annotations map. Lane terminals construct one via createMetaBuilder, hand it to the user as MetaBuilder<K>, then read meta.annotations after the callback returns.

Coverage: 10 runtime tests + 17 type-level tests in framework-components/test/meta-builder.{test.ts,types.test-d.ts}.

@prisma-next/framework-componentsscope on RuntimeMiddlewareContext

RuntimeMiddlewareContext gains a scope: 'runtime' | 'connection' | 'transaction' field. SqlRuntimeImpl populates 'runtime' for top-level execute, derives a new context with 'connection' for connection.execute, and 'transaction' for transaction.execute and withTransaction. MongoRuntimeImpl populates 'runtime' for now (no transaction surface yet). Coverage in packages/2-sql/5-runtime/test/scope-plumbing.test.ts.

The cache middleware uses this to bypass caching in non-runtime scopes — read-after-write coherence inside a transaction is the caller's expectation, and connection-scoped executions can step outside the shared cache surface in ways the middleware can't reason about.

SQL DSL — .annotate(...) on all five builders

packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts, query-impl.ts, and mutation-impl.ts get an .annotate(...) method on every builder:

  • SelectQueryImpl → kind 'read' (via QueryBase)
  • GroupedQueryImpl → kind 'read' (via QueryBase)
  • InsertQueryImpl → kind 'write'
  • UpdateQueryImpl → kind 'write'
  • DeleteQueryImpl → kind 'write'

Signature on each builder:

annotate<As extends readonly AnnotationValue<unknown, OperationKind>[]>(
  ...annotations: As & ValidAnnotations<K, As>
): this

Behavior:

  • Type-level: read builders reject write-only annotations and vice versa; both-kind annotations are accepted on every builder; negative cases fail to compile.
  • Runtime: assertAnnotationsApplicable runs at .annotate() call time, not deferred to .build(), so casts / any / dynamic invocations fail fast.
  • Chainable in any position. Multiple .annotate() calls compose; duplicate namespaces last-write-win.
  • Annotations merge into plan.meta.annotations at .build() time alongside the framework-internal codecs map under its reserved namespace. Reserved keys win over user annotations under the same key (defensive — see the reserved-namespace policy on defineAnnotation).

Plumbing: BuilderState gains userAnnotations: ReadonlyMap<string, AnnotationValue<unknown, OperationKind>>; mutation builders carry their own private field and thread it through .where() / .returning() clones. buildQueryPlan accepts an optional userAnnotations parameter and composes the final meta.annotations from both maps. A small mergeWriteAnnotations helper deduplicates the validate-clone-set pattern across the three mutation builders.

Why a chainable variadic on the SQL DSL but a callback on the ORM: builders are not terminals — they return new builders that participate in further chaining — and growth happens via additional builder methods (.cache(...), .tag(...)) rather than additional positional arguments to .annotate() itself. The user-facing concern that drove the ORM redesign (a variadic tail on a terminal forecloses on adding new positional or named per-call options) does not apply at the builder layer. Keeping the SQL DSL chainable also keeps existing call sites untouched.

Coverage: 28 runtime tests + 26 type-level tests in sql-builder/test/runtime/annotate.test.ts and sql-builder/test/playground/annotate.test-d.ts, exercising every builder kind plus chain positions, immutability, namespace coexistence, and the runtime gate.

ORM Collection / GroupedCollection — meta-callback configurator on terminals

Per the spec, annotations attach via the terminal's optional configurator argument only — there is no chainable .annotate() on Collection. That scope cut keeps the per-terminal kind binding clean and structurally prevents passing read annotations to mutations.

Read terminals updated:

  • Collection.all and Collection.first accept configure?: (meta: MetaBuilder<'read'>) => void as their last argument.
  • Collection.aggregate and GroupedCollection.aggregate likewise.

Write terminals updated:

  • create / createAll / createCount
  • update / updateAll / updateCount
  • delete / deleteAll / deleteCount
  • upsert

Each accepts configure?: (meta: MetaBuilder<'write'>) => void as its last argument after the existing leading arguments. For terminals that issue both a "matching" read and a write statement (updateCount, deleteCount), annotations attach only to the write — the matching read is internal and not user-facing.

Why a callback rather than a variadic:

  • Future-proof terminal signatures. A terminal whose last positional argument is a variadic of annotations cannot grow new positional or named per-call options without a breaking change. There is no future shape db.orm.User.find(input, { … }, ann1, ann2) to evolve into without churning every call site. Adding a method to MetaBuilder<K> (meta.timeout(...), meta.tag(...), meta.cancellation(signal)) is a non-breaking surface extension.
  • Drops a load-bearing TypeScript trick. A variadic terminal needs the As & ValidAnnotations<K, As> intersection to reject inapplicable annotations through variadic-tuple inference. The callback hands one annotation at a time to meta.annotate(...), which uses an ordinary conditional constraint — no intersection, no documented "load-bearing trick" on the ORM lane.
  • Removes the isAnnotationValue brand-discriminator inside first. The variadic shape disambiguated first(filterOrAnnotation, ...rest) at runtime by checking whether the leading argument was a branded annotation. With annotations in the configurator slot, first(filter?, configure?) is unambiguous: positional 1 is the filter, positional 2 is the configurator. The dispatch shrinks; the isAnnotationValue helper is gone.
  • Aligns with the rest of the ORM client's idioms. .where((model) => …), .include((collection) => …), .aggregate((agg) => …), .having((having) => …) already use callback configurators.

first shape note: the existing positional dispatch is preserved — a single function arg is interpreted as a filter callback, 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.

Plumbing:

  • CollectionState retains userAnnotations: ReadonlyMap<string, AnnotationValue<unknown, OperationKind>>. Empty on a fresh state; populated transiently by terminal calls just before dispatch.
  • Two private helpers replace the old variadic-array helpers:
    • #withAnnotationsFromMeta(configure, terminalName) for state-driven reads (all, first): constructs a MetaBuilder<'read'>, invokes the configurator, folds meta.annotations into state.userAnnotations, and returns a clone (or the receiver unchanged when no configurator was supplied or it recorded nothing).
    • #collectAnnotationsFromMeta<K>(configure, kind, terminalName) for the post-wrap path (aggregate and all writes): same construction, returns the recorded map (or undefined when empty) for mergeUserAnnotations to consume.
  • The runtime gate fires inside meta.annotate(...) itself, so terminals do not re-validate after the callback returns.
  • buildOrmQueryPlan accepts an optional userAnnotations parameter and merges into plan.meta.annotations alongside the reserved codecs map. compileSelect, compileSelectWithIncludeStrategy, and compileRelationSelect thread state.userAnnotations through.
  • Write terminals use a post-wrap mergeUserAnnotations(compiled, annotationsMap) on the compiled mutation plan(s), mirroring the aggregate path. For terminals that produce multiple plans (createAll's compileInsertReturningSplit, createCount's compileInsertCountSplit), the map applies to every plan in the array.
  • update() / delete() preserve their this-typed receivers (TypeScript's this: State['hasWhere'] extends true ? Collection<...> : never trick gates at the type level); the receiver is narrowed via cast just once inside the method body.

Two paths intentionally do not yet thread annotations into their constituent SQL statements: nested-mutation paths (executeNestedCreateMutation, executeNestedUpdateMutation) and MTI variant create paths (#executeMtiCreate). Both are documented as follow-ups in projects/middleware-intercept-and-cache/follow-ups.md. Neither blocks the April stop condition: cacheAnnotation is read-only, so writes don't matter; the type-level gate already prevents read-only annotations on writes regardless.

Coverage: 39 runtime tests + 56 type-level tests in sql-orm-client/test/annotations.{test.ts,types.test-d.ts} covering happy paths, runtime cast bypasses for at least one terminal in each family, multi-namespace coexistence, chain survival, and per-terminal type rejection.

@prisma-next/middleware-cache — new package

Layout mirrors middleware-telemetry. Registered in architecture.config.json under extensions/integrations/runtime so pnpm lint:deps validates it. Depends only on @prisma-next/framework-components — no SQL or Mongo runtime dependency, which is the cross-family constraint enforced by ADR 204.

  • cacheAnnotation (src/cache-annotation.ts): defineAnnotation<CachePayload>()({ namespace: 'cache', applicableTo: ['read'] }). CachePayload = { ttl?: number; skip?: boolean; key?: string }.

  • CacheStore and createInMemoryCacheStore (src/cache-store.ts): pluggable { get, set } interface plus a default in-process LRU-with-TTL store backed by a Map. Iteration order is LRU; both set and get bump recency. Expired entries drop on access without counting against maxEntries on the next write. The clock is injectable so tests verify TTL expiry without real-time waits. Default maxEntries: 1000. The default in-memory store is per-process and not coherent across replicas — users who need a shared cache pass a custom CacheStore (Redis, Memcached, etc.).

  • createCacheMiddleware (src/cache-middleware.ts): wires three RuntimeMiddleware hooks.

    • intercept: bypass when ctx.scope !== 'runtime', when cacheAnnotation is absent, when skip: true, or when ttl is missing. Otherwise resolve the key (per-query key override > ctx.contentHash(exec)) and probe the store. On a hit, log middleware.cache.hit and return the cached rows. On a miss, record a per-exec PendingMiss in a private WeakMap and log middleware.cache.miss.
    • onRow: append each driver row to the pending buffer for that exec.
    • afterExecute: commit the buffer to the store iff completed === true AND source === 'driver'. Cleans up the WeakMap entry in all branches so a failed or middleware-served execution leaves no residue.

    Cross-family by construction: no familyId / targetId, reads keys from ctx.contentHash populated by the family runtime.

Coverage:

  • test/cache-middleware.test.ts — opt-in semantics, bypass branches, hit/miss flow, pending-buffer correctness, scope guard, store interaction, telemetry events.
  • test/cache-store.test.ts — LRU eviction, TTL expiry under injected clock, recency bumps.
  • test/cache-key.test.ts — default contentHash path, per-query key override (asserts contentHash is not invoked), Mongo parity with a mock context whose contentHash returns a Mongo-style string.
  • test/cache-annotation.{test.ts,types.test-d.ts} — handle round-trip and lane-kind gating.

Real-Postgres integration test

test/integration/test/cross-package/middleware-cache.test.ts covers the four April stop-condition scenarios end-to-end:

  • Stop condition. A repeated annotated SQL DSL query is served from cache. Spy on driver.execute and assert call count does not increase between miss and hit.
  • Composition with beforeCompile. An "active users only" rewriter prepends a predicate; the cache key reflects the rewritten lowered SQL. Two runtimes (with vs. without the rewriter) sharing one CacheStore land in distinct slots.
  • Composition with telemetry. afterExecute fires on miss and hit; source round-trips as 'driver' on miss and 'middleware' on hit. beforeExecute is suppressed on the intercepted hit path.
  • Concurrency. Two parallel executes of the same plan produce correct, identical results without cross-talk via the per-exec WeakMap buffer; a third sequential call hits the cache.

Plus opt-in regression tests: un-annotated queries always hit the driver; skip: true bypasses the cache.

Demo additions

examples/prisma-next-demo:

  • src/orm-client/find-user-by-id-cached.tsdb.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl, skip }))).
  • src/orm-client/get-users-cached.tsdb.User.take(n).all((meta) => meta.annotate(cacheAnnotation({ ttl, key? }))), demonstrating the per-query key override.
  • src/queries/get-users-cached.tsdb.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build() (SQL DSL chainable form).
  • Three new CLI commands in src/main.ts: cache-demo-user, cache-demo-users, cache-demo-sql. Each runs the same query twice and prints first-call vs. second-call timings to make the cache short-circuit visible.
  • test/repositories.integration.test.ts extends with a driver-spy assertion on the cache helpers, mirroring the cross-package integration test on the demo's own runtime config.

Documentation

docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md:

  • Updated runWithMiddleware lifecycle to start with the intercept step, with hit-path semantics for skipping beforeExecute / runDriver / onRow.
  • New "Intercepting Execution" section covering chain semantics, hit path, row shape, verification ordering, error path, the WeakMap-correlated plan-identity invariant, and family-agnostic construction with the cache middleware as the canonical example.
  • New "Annotations" subsection: OperationKind, defineAnnotation, ValidAnnotations<K, As>, the split lane integration (SQL DSL chainable variadic; ORM meta-callback via MetaBuilder<K> from createMetaBuilder), the runtime applicability check, plan.meta.annotations storage, and reserved namespaces.
  • Documented the contentHash(exec) and scope fields on RuntimeMiddlewareContext.
  • Updated lifecycle sequence diagram to branch on intercept hit vs. driver path and show source: 'middleware' | 'driver' on afterExecute.
  • Refreshed the Testing Strategy with the intercept matrix, annotation surface, family contentHash impls, cache middleware unit + integration coverage, and SQL runtime scope plumbing.

Updated projects/middleware-intercept-and-cache/spec.md and plan.md to reflect the curried defineAnnotation<Payload>()({ ... }) form, the callable-handle convention (cacheAnnotation({ ttl }) rather than cacheAnnotation.apply({ ttl })), and the meta-callback ORM API. api-revision-meta-callback.md records the design delta from the original variadic-on-terminal shape to the configurator. follow-ups.md captures the deferred work: nested-mutation annotation threading, MTI variant create paths, and the wider read-terminal annotation surface beyond all / first / aggregate.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 07128b10-3eff-44b7-998d-028e36f7a57e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cache-middleware-impl

Comment @coderabbitai help to get the list of available commands and usage tips.

@aqrln aqrln force-pushed the cache-middleware-docs branch from 48fd964 to a93a804 Compare April 27, 2026 16:13
@aqrln aqrln force-pushed the cache-middleware-impl branch from ec8f10b to 1f079b5 Compare April 27, 2026 16:45
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 27, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@385

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@385

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@385

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@385

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-arktype-json@385

@prisma-next/middleware-cache

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-cache@385

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@385

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@385

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@385

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@385

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@385

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@385

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@385

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@385

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@385

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@385

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@385

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@385

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@385

@prisma-next/errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/errors@385

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@385

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@385

@prisma-next/ts-render

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ts-render@385

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@385

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@385

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@385

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@385

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@385

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@385

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@385

prisma-next

npm i https://pkg.pr.new/prisma/prisma-next@385

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@385

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@385

@prisma-next/mongo-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract@385

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@385

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@385

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-ts@385

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-emitter@385

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@385

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@385

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@385

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@385

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@385

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@385

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@385

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@385

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@385

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@385

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@385

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@385

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@385

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@385

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@385

@prisma-next/sql-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-builder@385

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@385

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@385

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@385

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@385

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@385

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@385

commit: f99e5b0

@aqrln aqrln force-pushed the cache-middleware-impl branch from 1f079b5 to f4187b7 Compare April 28, 2026 12:47
@aqrln aqrln force-pushed the cache-middleware-docs branch 2 times, most recently from 77d1726 to e75b5fb Compare April 28, 2026 16:20
@aqrln aqrln force-pushed the cache-middleware-impl branch 2 times, most recently from 1a95155 to ea188e2 Compare April 28, 2026 16:21
@aqrln aqrln force-pushed the cache-middleware-docs branch from e75b5fb to 7f98b15 Compare April 28, 2026 16:25
@aqrln aqrln force-pushed the cache-middleware-impl branch 3 times, most recently from b8f537d to 916c99f Compare April 29, 2026 17:46
@aqrln aqrln force-pushed the cache-middleware-docs branch 7 times, most recently from 7a7bdf4 to 46aedab Compare May 5, 2026 11:39
Base automatically changed from cache-middleware-docs to main May 5, 2026 11:53
@aqrln aqrln changed the base branch from main to cache-middleware-intercept May 6, 2026 12:43
@aqrln aqrln force-pushed the cache-middleware-impl branch from fb4f1d9 to 50839c9 Compare May 6, 2026 12:45
@aqrln aqrln force-pushed the cache-middleware-intercept branch from 760ec57 to 44d63eb Compare May 7, 2026 11:41
@aqrln aqrln force-pushed the cache-middleware-impl branch from 1614ed3 to 45a9113 Compare May 7, 2026 11:41
Base automatically changed from cache-middleware-intercept to main May 7, 2026 12:10
aqrln added 4 commits May 7, 2026 14:21
Adds the 'runtime' | 'connection' | 'transaction' scope discriminator
to RuntimeMiddlewareContext so middleware can distinguish top-level
runtime executions from those running inside connections or
transactions. Used by the upcoming cache middleware to bypass caching
in non-runtime scopes (where read-after-write coherence is the
caller's expectation, or the user has explicitly stepped outside the
shared cache surface).

SqlRuntimeImpl populates 'runtime' for top-level execute, derives a
new context with 'connection' for connection.execute, and 'transaction'
for transaction.execute (and withTransaction). MongoRuntimeImpl
populates 'runtime' for now (no transaction surface yet).

Closes M3.1 of the cache-middleware project.
…applicability

Adds the user-facing annotation surface that lane terminals (SQL DSL
builders' .build(), ORM Collection terminals) consume to constrain
which annotations may attach to which operations.

Public API:
- OperationKind = 'read' | 'write'. Binary discrimination for April;
  finer-grained kinds ('select' | 'insert' | 'update' | 'delete' |
  'upsert') are deferred until a real annotation needs them.
- defineAnnotation<Payload, Kinds>({ namespace, applicableTo }) →
  AnnotationHandle<Payload, Kinds>. Creates a typed handle.
- AnnotationHandle<P, K>: { namespace, applicableTo, apply(value),
  read(plan) }. Handles are the only supported public entry point for
  reading and writing annotations.
- AnnotationValue<P, K>: the result of handle.apply(value). Carries
  the namespace, payload, applicableTo set, and a __annotation: true
  brand. Stored in plan.meta.annotations[namespace] verbatim.
- ValidAnnotations<K, As>: type-level mapped tuple that resolves each
  element to never when its declared Kinds does not include K. Lane
  terminals consume this on their variadic ...annotations parameter.
- assertAnnotationsApplicable(annotations, kind, terminalName): the
  runtime applicability gate. Throws RUNTIME.ANNOTATION_INAPPLICABLE
  with the offending namespace and terminal named. Used by lane
  terminals so casts / any / dynamic invocations cannot bypass the
  type-level gate.

Stored shape (read isolates user annotations from framework-internal
metadata):
  plan.meta.annotations[namespace] = AnnotationValue<P, K>  (branded)

handle.read() checks the __annotation brand before returning, which
means framework-internal entries under the same namespace key (e.g.
the SQL emitter's meta.annotations.codecs map of alias → codec id) do
not surface as user-handle reads. defineAnnotation does not
structurally prevent a user from naming a reserved namespace
('codecs', target-specific keys like 'pg'); the TSDoc lists them and
states no compatibility guarantee is made for handles that collide.

The applicability gate makes 'caching a mutation' — the obvious
footgun for an opt-in cache annotation — structurally impossible:
cacheAnnotation declares applicableTo: ['read'], so passing it to
db.User.create(...) produces a never-tuple at the type level and a
RUNTIME.ANNOTATION_INAPPLICABLE error at runtime. The cache middleware
needs no separate mutation classifier.

Tests land in the next commit (M1.8).

Refs: TML-2143
… and assertAnnotationsApplicable

Adds two test files for the annotation surface from the previous
commit:

annotations.test.ts (23 runtime tests):
- Handle metadata: namespace echoed; applicableTo is a frozen
  ReadonlySet narrowed to declared kinds; separate handles do not
  share state.
- apply: produces __annotation-branded values; embeds namespace,
  payload, applicableTo; values are frozen; repeated apply yields
  independent values.
- read: returns the payload for a value applied through the same
  handle; returns undefined when annotation is absent; returns
  undefined when the stored value lacks the brand (defends against
  framework-internal metadata sharing the same namespace key —
  e.g. the SQL emitter's meta.annotations.codecs map); two handles
  with different namespaces don't interfere; two handles claiming
  the same namespace string are isolated by the stored value's
  embedded namespace field; payload object identity is preserved.
- assertAnnotationsApplicable: passes silently on empty arrays,
  matching kinds, both-kind annotations on either kind, mixed
  compatible annotations; throws RUNTIME.ANNOTATION_INAPPLICABLE
  with structured details (namespace, terminalName, kind,
  applicableTo) on mismatched kinds; the error message names the
  offending namespace and the terminal; the runtime check fires
  on opaquely-typed annotations forced through casts (the
  belt-and-suspenders for callers that bypass the type gate).

annotations.types.test-d.ts (24 type tests):
- defineAnnotation: handle type preserves Payload and Kinds;
  applicableTo is ReadonlySet narrowed to declared kinds;
  apply preserves payload and kinds in AnnotationValue; apply
  rejects payloads of the wrong shape (negative, three cases);
  read returns Payload | undefined.
- ValidAnnotations: matching elements typed; mismatched elements
  resolve to never (both directions); empty tuple = empty tuple;
  mixed tuples propagate per-element; an inapplicable element
  makes the gated tuple unassignable from a value containing it.
- Lane-terminal call shapes: read terminals accept read-only and
  both-kind annotations; reject write-only (negative); reject mixes
  containing write-only (negative). Write terminals: mirror image.
  Empty variadic accepted.
- Type narrowness preserved: payload type survives the gate.
- Defensive: non-AnnotationValue tuple elements resolve to never.

One discovery surfaced and is now documented in production TSDoc:
lane terminals must use 'As & ValidAnnotations<K, As>', not just
'ValidAnnotations<K, As>'. TypeScript's variadic-tuple inference
is too forgiving when the parameter type refers to As only through
ValidAnnotations: it picks an As that makes the call valid even
when the gated tuple would contain never. The intersection pins
As to the actual call-site tuple AND requires assignability to
the gated form, surfacing inapplicable arguments as type errors.
The terminal simulators in the type-d file use this pattern; M2
lane-terminal implementations must as well.

Refs: TML-2143
Adds the user-facing annotation surface on the SQL DSL. All five
builders gain an .annotate(...) method that accepts variadic
AnnotationValue arguments constrained by their operation kind:

  - SelectQueryImpl  → read   (via QueryBase)
  - GroupedQueryImpl → read   (via QueryBase)
  - InsertQueryImpl  → write
  - UpdateQueryImpl  → write
  - DeleteQueryImpl  → write

Signature on each builder:

  annotate<As extends readonly AnnotationValue<unknown, OperationKind>[]>(
    ...annotations: As & ValidAnnotations<K, As>
  ): this

The 'As & ValidAnnotations<K, As>' intersection is load-bearing — see
the discovery note on ValidAnnotations TSDoc. TypeScript's variadic-
tuple inference with ValidAnnotations<K, As> alone is too forgiving
and lets inapplicable annotations through; the intersection pins As
to the call-site tuple AND requires assignability to the gated form.

Behavior:

  - Type-level: read builders reject write-only annotations and vice
    versa. Both-kind annotations ('read' | 'write' applicableTo) are
    accepted on every builder. Negative cases fail to compile.
  - Runtime: assertAnnotationsApplicable runs at .annotate() call
    time (not deferred to .build()) so casts / 'any' / dynamic
    invocations fail fast with RUNTIME.ANNOTATION_INAPPLICABLE.
  - Chainable in any position. Multiple .annotate() calls compose;
    duplicate namespaces last-write-win.
  - Annotations are merged into plan.meta.annotations at .build()
    time, alongside the framework-internal 'codecs' map under its
    reserved namespace. Reserved 'codecs' wins over user annotations
    under the same key (defensive — see the reserved-namespace policy
    on defineAnnotation).

Plumbing:

  - BuilderState gains userAnnotations: ReadonlyMap<string,
    AnnotationValue<unknown, OperationKind>>. Mutation builders
    don't share BuilderState; each carries its own private
    #userAnnotations field plus a constructor parameter, threaded
    through .where()/.returning() clones.
  - buildQueryPlan accepts an optional userAnnotations parameter and
    composes the final meta.annotations from both the codecs map and
    the user annotations.
  - mergeWriteAnnotations helper in mutation-impl.ts deduplicates the
    repeated 'validate, clone the map, set the new entries' pattern
    across the three mutation builders.

The interface declarations (SelectQuery, GroupedQuery, InsertQuery,
UpdateQuery, DeleteQuery) all gain .annotate as a typed method so
consumers see it via the public types, not just the impl.

Tests land in the next commit (M2.3 + M2.4).

Refs: TML-2143
aqrln added 17 commits May 7, 2026 14:21
Two test files, organized by builder kind via describe blocks:

annotate.test.ts (28 runtime tests):
  - SelectQuery (12 tests): writes the applied annotation; round-trips
    via handle.read; absent annotation reads as undefined; multiple
    namespaces coexist; multiple annotations in one call; duplicate
    namespace last-write-wins; immutability (annotate does not mutate
    the original builder); chainable in three positions (immediately
    after .select, between .select and .where, after .where before
    .limit); annotate does not affect the AST shape; empty variadic
    is a no-op for user annotations; runtime gate rejects write-only
    via cast.
  - GroupedQuery (4 tests): writes annotation; chainable between
    .select and .groupBy; chainable after .groupBy before .orderBy;
    runtime gate.
  - InsertQuery (4 tests): writes annotation; accepts both-kind;
    survives across .returning chaining; runtime gate.
  - UpdateQuery (3 tests): writes annotation; survives .where +
    .returning chaining; runtime gate.
  - DeleteQuery (3 tests): writes annotation; survives .where +
    .returning chaining; runtime gate.
  - Reserved-namespace coexistence (1 test): user annotations live
    alongside the framework-internal 'codecs' map without collision.

annotate.test-d.ts (26 type-level tests):
  - SelectQuery (9 tests): accepts read-only, both-kind, multi
    compatible; rejects write-only and write-bearing mixes (negative);
    accepts empty variadic; chainable in three positions preserves
    the row type.
  - GroupedQuery (4 tests): accepts read-only, both-kind; rejects
    write-only (negative); chainable preserves row type.
  - InsertQuery (5 tests): accepts write-only, both-kind; rejects
    read-only and read-bearing mixes (negative); chainable before
    .returning preserves the resulting row type.
  - UpdateQuery (4 tests): mirror of Insert.
  - DeleteQuery (4 tests): mirror of Insert.

The test-d file rediscovers a quirk already documented on the
ValidAnnotations TSDoc: TypeScript's variadic-tuple inference
reports the call-site error on the function-call line, not on the
inapplicable argument line. The @ts-expect-error directives sit on
the call line for mixed cases (matching the framework-components
type-d tests).

Refs: TML-2143
Adds a variadic annotation argument to the ORM Collection's read
terminals. Per the spec, annotations attach via terminal arguments
only — there is no chainable .annotate() on Collection (that scope
cut keeps the per-terminal kind binding clean and structurally
prevents passing read annotations to mutations).

Read terminals updated in this commit:

  all<As extends readonly AnnotationValue<unknown, OperationKind>[]>(
    ...annotations: As & ValidAnnotations<'read', As>
  ): AsyncIterableResult<Row>

  first<As extends readonly AnnotationValue<unknown, OperationKind>[]>(
    filterOrFirstAnnotation?: filter | AnnotationValue,
    ...rest: AnnotationValue[]
  ): Promise<Row | null>

The 'As & ValidAnnotations<'read', As>' intersection is load-bearing —
see the ValidAnnotations TSDoc on the framework annotation module.
TypeScript's variadic-tuple inference with ValidAnnotations alone
would silently let inapplicable annotations through; the intersection
pins As to the call-site tuple AND requires assignability to the
gated form so write-only annotations fail to compile.

Plumbing:

  - CollectionState gains userAnnotations: ReadonlyMap<string,
    AnnotationValue<unknown, OperationKind>>. Empty on a fresh state;
    populated transiently by terminal calls just before dispatch.
  - A private #withAnnotations(annotations, kind, terminalName)
    helper validates kinds via assertAnnotationsApplicable (the
    runtime gate fails closed for callers that bypass the type gate
    via cast / 'any') and returns a #clone with the merged map.
  - buildOrmQueryPlan accepts an optional userAnnotations parameter
    and merges into plan.meta.annotations alongside the framework-
    internal 'codecs' map under its reserved namespace. Reserved keys
    win over user annotations under the same key (defensive).
  - compileSelect and compileSelectWithIncludeStrategy thread
    state.userAnnotations to buildOrmQueryPlan; compileRelationSelect
    inherits via compileSelect.

The first() overload set is extended so a leading argument may be
either a filter or an annotation. Disambiguation runs at call time
via an internal isAnnotationValue type guard checking the
__annotation brand. Empty variadic returns the receiver unchanged.

Deferred to a follow-up (captured in projects/.../follow-ups.md):
write terminals (create, update, delete, upsert, createCount), count,
aggregate. The mergeUserAnnotations helper added in query-plan-meta.ts
is the seam those will use (post-wrap rather than threading through
each compile fn). The cache middleware (M3) only intercepts reads,
so the deferred set is not load-bearing for the April stop condition.

Tests land in the next commit (M2.8 + M2.9).

Refs: TML-2143
…irst)

Two test files for the ORM Collection terminal annotations from the
previous commit:

annotations.test.ts (14 runtime tests):
  - Collection.all (8 tests): writes the applied annotation; round-
    trips via handle.read; absent annotation reads as undefined;
    multiple namespaces coexist; zero annotations is a no-op for
    user annotations; survives across .where / .take chaining;
    runtime gate rejects write-only via cast.
  - Collection.first (5 tests): writes annotation with no filter;
    with function filter; with shorthand filter; disambiguates a
    leading AnnotationValue from a shorthand (the leading arg is
    treated as the annotation, not as a where clause); runtime gate.
  - Reserved-namespace coexistence (1 test): user annotations live
    alongside the framework-internal 'codecs' map without collision.

annotations.types.test-d.ts (19 type-level tests):
  - Collection.all: accepts read-only, both-kind, multi-compatible
    and empty variadic; rejects write-only and write-bearing mixes
    (negative); return type is not widened.
  - Collection.first: accepts read-only with no filter, after
    function filter, after shorthand filter; multi-compatible;
    accepts no-filter / no-annotation; accepts function filter
    only; rejects write-only with no filter and after shorthand
    (negative); return type is Promise<Row | null>.
  - Collection has no chainable .annotate (intentional scope cut).
  - Annotation handle types preserved through the lane.

The negative tests for mixed-annotation arguments use a
'biome-ignore format: keep on one line so @ts-expect-error attaches
to the call' directive: TypeScript reports the type error on the
call line, but biome's line-length rule would split the call across
multiple lines, breaking the @ts-expect-error attachment. The
single-arg negatives don't need this.

Refs: TML-2143
… update, delete, upsert)

Adds a variadic write-typed annotation argument to the ORM
Collection's write terminals, mirroring M2.5 for read terminals.
Annotations attach via terminal arguments only — no chainable
.annotate() on Collection.

Write terminals updated:

  create<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    Promise<Row>

  createAll<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    AsyncIterableResult<Row>

  createCount<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    Promise<number>

  update<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    Promise<Row | null>

  updateAll<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    AsyncIterableResult<Row>

  updateCount<As>(data, ...annotations: As & ValidAnnotations<'write', As>):
    Promise<number>

  delete<As>(...annotations: As & ValidAnnotations<'write', As>):
    Promise<Row | null>

  deleteAll<As>(...annotations: As & ValidAnnotations<'write', As>):
    AsyncIterableResult<Row>

  deleteCount<As>(...annotations: As & ValidAnnotations<'write', As>):
    Promise<number>

  upsert<As>(input, ...annotations: As & ValidAnnotations<'write', As>):
    Promise<Row>

The 'As & ValidAnnotations<'write', As>' intersection is load-bearing
(see ValidAnnotations TSDoc) — without it, TypeScript's variadic-tuple
inference would silently let read-only annotations through.

Plumbing:

  - A new private helper #buildAnnotationsMap(annotations, kind,
    terminalName) validates kinds via assertAnnotationsApplicable and
    returns a ReadonlyMap<string, AnnotationValue> ready for
    mergeUserAnnotations. Returns undefined for an empty variadic so
    callers can skip the rewrap entirely. Parameterized over
    OperationKind so it's also usable by the aggregate read terminal
    in the next commit.
  - Each write terminal calls mergeUserAnnotations(compiled,
    annotationsMap) on the compiled mutation plan(s) before dispatch.
    For terminals that produce multiple plans (createAll's
    compileInsertReturningSplit, createCount's compileInsertCountSplit),
    the map applies to every plan in the array.
  - update() and delete() preserve their this-typed receivers (they
    use TypeScript's 'this: State[hasWhere] extends true ?
    Collection<...> : never' trick to gate at the type level).
    The receiver is narrowed via cast just once inside the method body.
  - For terminals that issue both a 'matching' read and a write
    statement (updateCount, deleteCount), annotations attach only to
    the write — the matching read is internal and not user-facing.

Two paths intentionally do not yet thread annotations into their
constituent SQL statements:

  1. Nested-mutation paths (executeNestedCreateMutation,
     executeNestedUpdateMutation): the operation runs as a graph of
     internal queries via withMutationScope. Annotations apply to the
     logical create()/update() call and the runtime gate fires at the
     terminal, but the per-statement mutations don't see them.
  2. MTI variant create paths (#executeMtiCreate): same shape — a
     multi-statement transaction.

Both are documented as follow-ups in
projects/middleware-intercept-and-cache/follow-ups.md. Neither blocks
the April stop condition: the cache middleware only intercepts reads
(cacheAnnotation declares applicableTo: ['read']), and the type-level
gate already prevents write-only annotations from reaching reads.

Tests for these terminals land in the next commit alongside aggregate
terminal tests.

Refs: TML-2143
… tests for write+aggregate

Adds the variadic write-typed annotation argument to two more
terminals — Collection.aggregate and GroupedCollection.aggregate —
completing the lane-level annotation surface called for in M2.

Source changes:

  Collection.aggregate(fn, ...annotations: As & ValidAnnotations<'read', As>):
    Promise<AggregateResult<Spec>>

  GroupedCollection.aggregate(fn, ...annotations: As & ValidAnnotations<'read', As>):
    Promise<Array<grouped row & AggregateResult<Spec>>>

Both are read terminals — aggregate queries are SELECTs. They use
the post-wrap pattern via mergeUserAnnotations rather than
threading through state, mirroring the write-terminal approach
(compileAggregate and compileGroupedAggregate don't take state).

The #buildAnnotationsMap helper added in the previous commit is
parameterized over OperationKind, so aggregate uses the same code
path with kind = 'read'.

Test additions:

  annotations.test.ts (24 new runtime tests):
    - Write terminals (16): create, createAll, createCount, upsert,
      update, updateAll, updateCount, delete, deleteAll, deleteCount.
      Each tests the happy path and a runtime cast-bypass for at
      least one terminal in the family. updateCount and deleteCount
      verify that write annotations attach to the write statement,
      NOT to the matching read.
    - Collection.aggregate (4): writes annotation; both-kind; zero
      annotations; runtime gate.
    - GroupedCollection.aggregate (4): same shape as Collection
      version.

  annotations.types.test-d.ts (37 new type tests):
    - Write terminals (27): every terminal accepts write-only and
      both-kind annotations and rejects read-only ones at the type
      level. update/delete/their {All,Count} cousins use a
      hasWhere-gated Collection fixture (userCollectionWithWhere)
      to satisfy their 'this: State[hasWhere] extends true ?
      Collection : never' constraint, so the negative tests isolate
      the annotation gate from the receiver gate.
    - Collection.aggregate (6): accepts read-only, both-kind, empty
      variadic; rejects write-only and write-bearing mixes
      (negative); preserves the AggregateResult<Spec> return type.
    - GroupedCollection.aggregate (4): same pattern.

This commit conflates source and tests because the two were
authored together when the helper rename happened. Future commits
in the stack go back to separate source/test commits per the
convention.

Refs: TML-2143
Creates the new package mirroring middleware-telemetry's layout:
package.json, tsconfig.json + tsconfig.prod.json, tsdown.config.ts,
vitest.config.ts, biome.jsonc, src/exports/index.ts placeholder, and
the README documenting opt-in behavior, cache-key composition,
CacheStore pluggability, transaction-scope guard, TTL/LRU semantics,
and the package's caveats.

Registers the package in architecture.config.json under
extensions/integrations/runtime so pnpm lint:deps picks it up. The
new package depends only on @prisma-next/framework-components — no
SQL or Mongo runtime dependency, which is the cross-family
constraint enforced by ADR 204.

Closes M3.3 and M3.20 of the cache-middleware project.
cacheAnnotation = defineAnnotation<CachePayload, 'read'>({
  namespace: 'cache',
  applicableTo: ['read'],
}).

CachePayload carries optional ttl, skip, and key fields. Declared
with applicableTo: ['read'] so the lane gate (M2) rejects passing it
to write terminals at both type and runtime levels — cache-mutation
is structurally impossible without an 'as any' cast bypass. The cache
middleware reads the payload via cacheAnnotation.read(plan).

Closes M3.4 of the cache-middleware project.
…fault

Defines:
- CacheStore — pluggable interface (get, set) for the cache backend.
- CachedEntry — { rows, storedAt } shape stored under a key.
- createInMemoryCacheStore({ maxEntries, clock? }) — default in-process
  LRU-with-TTL store backed by a Map. Iteration order is the LRU order;
  re-set and get both bump recency. Expired entries are dropped on
  access without counting against maxEntries on the next write.

The clock is injectable so tests can verify TTL expiry without
real-time waits. The default in-memory store is per-process and not
coherent across replicas — users who need a shared cache provide a
custom CacheStore (Redis, Memcached, etc.).

Closes M3.5 of the cache-middleware project.
…t/onRow/afterExecute

Wires the three RuntimeMiddleware hooks for opt-in caching:

- intercept: bypass when ctx.scope !== 'runtime', when no cacheAnnotation
  is present, when skip: true, or when ttl is missing. Otherwise resolve
  the key (per-query 'key' override > ctx.identityKey(exec)) and probe
  the store. On a hit, log 'middleware.cache.hit' and return the cached
  rows. On a miss, record a per-exec PendingMiss in a private WeakMap
  and log 'middleware.cache.miss'.
- onRow: append each driver row to the pending buffer for that exec.
- afterExecute: commit the buffer to the store iff completed === true
  AND source === 'driver'. Cleans up the WeakMap entry in all branches
  so a failed or middleware-served execution leaves no residue.

Cross-family by construction: no familyId/targetId, depends only on
@prisma-next/framework-components, reads keys from ctx.identityKey
(populated by SqlRuntimeImpl and MongoRuntimeImpl).

Defaults to a built-in in-memory LRU store with maxEntries=1000;
options accept a custom CacheStore for Redis / Memcached / etc.

Closes M3.6, M3.7, M3.9, M3.10, M3.11 of the cache-middleware project.
Adds test/integration/test/cross-package/middleware-cache.test.ts
covering the four integration scenarios from the M3 plan:

- M3.16 Stop condition. A repeated annotated SQL DSL query is served
  from cache without invoking the driver. The April milestone exit
  criterion: spy on driver.execute and assert call count does not
  increase between miss and hit.
- M3.17 Composition with a beforeCompile rewriter. An 'active users
  only' rewriter prepends a predicate; the cache key reflects the
  rewritten lowered SQL because the cache middleware sees the
  post-lowering plan. Two runtimes (with vs. without the rewriter)
  sharing one CacheStore land in distinct cache slots.
- M3.18 Composition with telemetry. afterExecute fires on both miss
  and hit; source rounds-trips as 'driver' on miss and 'middleware'
  on hit. beforeExecute is suppressed on the intercepted hit path.
- M3.19 Concurrency regression. Two parallel executes of the same
  plan produce correct, identical results without cross-talk via the
  per-exec WeakMap buffer; a third sequential call hits the cache.

Plus opt-in regression tests: un-annotated queries always hit the
driver; skip: true bypasses the cache.

Closes M3.16, M3.17, M3.18, M3.19 of the cache-middleware project.
…che integration

Updates docs/architecture docs/subsystems/4. Runtime & Middleware Framework.md
to reflect the M1 + M2 + M3 work that landed under TML-2143:

- Updated the runWithMiddleware lifecycle to start with the intercept
  step, with hit-path semantics for skipping beforeExecute / runDriver /
  onRow.
- Added the source field to the AfterExecuteResult shape shown in the
  Middleware API section.
- New 'Intercepting Execution' section documenting RuntimeMiddleware.intercept:
  chain semantics, hit path, row shape, verification ordering, error
  path, the WeakMap-correlated plan-identity invariant (with a forward
  reference to ADR 025), and family-agnostic construction with the cache
  middleware as the canonical example.
- New 'Annotations' subsection covering OperationKind, defineAnnotation,
  ValidAnnotations<K, As>, lane integration on SQL DSL + ORM Collection,
  the runtime applicability check, plan.meta.annotations storage, and
  reserved namespaces (codecs, target keys).
- Documented the new RuntimeMiddlewareContext fields: identityKey(exec)
  (BLAKE2b-512 digest of the execution identity, populated by family
  runtimes) and scope ('runtime' | 'connection' | 'transaction').
- Updated the lifecycle sequence diagram to branch on the intercept hit
  vs. driver path and show source: 'middleware' / 'driver' on
  afterExecute.
- Updated the Execution Pipeline and Connection Lifecycle text snippets
  to reflect the intercept step.
- Updated the Rewriting ASTs scope note: short-circuiting via intercept
  and user-authored annotations are no longer 'deferred to later
  milestones' — they are documented above.
- Extended the Testing Strategy with the intercept matrix, annotation
  surface, family identityKey impls, cache middleware unit + integration
  coverage, and SQL runtime scope plumbing.

Closes C.2 of the cache-middleware project.
@aqrln aqrln force-pushed the cache-middleware-impl branch from 45a9113 to f99e5b0 Compare May 7, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant