Draft
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
48fd964 to
a93a804
Compare
ec8f10b to
1f079b5
Compare
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/extension-arktype-json
@prisma-next/middleware-cache
@prisma-next/middleware-telemetry
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/emitter
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
1f079b5 to
f4187b7
Compare
77d1726 to
e75b5fb
Compare
1a95155 to
ea188e2
Compare
e75b5fb to
7f98b15
Compare
b8f537d to
916c99f
Compare
7a7bdf4 to
46aedab
Compare
fb4f1d9 to
50839c9
Compare
760ec57 to
44d63eb
Compare
1614ed3 to
45a9113
Compare
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
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.
45a9113 to
f99e5b0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cache middleware + annotation surface
Refs: TML-2143
Headline
Opt one read into the cache with one configurator argument:
SQL DSL keeps its existing chainable variadic — builders are not terminals, so growth happens via additional builder methods rather than additional positional args:
Wiring the middleware on the runtime:
Summary
Builds on the
intercepthook +contentHash(exec)SPI from the prior PR (cache-middleware-intercept) and lands the rest of the cache-middleware project:@prisma-next/framework-components:defineAnnotation/ValidAnnotations/assertAnnotationsApplicable, plus theOperationKind = 'read' | 'write'discriminator andAnnotationHandle/AnnotationValuetypes.MetaBuilder<K>module (createMetaBuilder(kind, terminalName)factory) that ORM terminals construct and hand to a user-supplied configurator callback. Public surface ismeta.annotate(annotation)with a conditionalK extends Kinds ? AnnotationValue<P, Kinds> : neverparameter type — no variadic-tuple inference, no load-bearing intersection..annotate(...)on all five SQL DSL builders (variadic, kept as today), and an optionalconfigure: (meta: MetaBuilder<K>) => voidlast argument on every ORMCollection/GroupedCollectionterminal. Both surfaces fail closed at the type level (the SQL DSL via the variadic-tuple intersectionAs & ValidAnnotations<K, As>, the ORM via the conditional parameter onmeta.annotate) and at runtime (viaassertAnnotationsApplicableinvoked bymeta.annotateand by.build()).@prisma-next/middleware-cachepackage withcacheAnnotation, theCacheStoreinterface, an in-memory LRUcreateInMemoryCacheStore, and the cross-familycreateCacheMiddlewarethat wiresintercept/onRow/afterExecute.scope: 'runtime' | 'connection' | 'transaction'discriminator onRuntimeMiddlewareContext, populated by both family runtimes, used by the cache middleware to bypass non-runtime executions where caller expectations diverge from the shared cache surface.cache-demo-user,cache-demo-users,cache-demo-sql) underexamples/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.docs/architecture docs/subsystems/4. Runtime & Middleware Framework.mdcovering theinterceptlifecycle, the annotation surface, the newscopefield, 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-writtenRuntimeMiddlewarecould 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:
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.
The cache middleware must stay cross-family. The package may not depend on
@prisma-next/sql-runtimeor@prisma-next/mongo-runtime. Cache key resolution has to flow through the framework-levelctx.contentHash(exec)SPI; the cache itself can read no family-specific field of the loweredexec.The annotation surface satisfies both: handles carry a declared
applicableToset, theMetaBuilder.annotateparameter resolves toneverfor inapplicable kinds, and the cache middleware reads the payload through the handle's ownread(plan)rather than touchingplan.meta.annotationsdirectly. 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 surfaceNew module
src/annotations.ts(re-exported fromexports/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 onlyPayloadas an explicit type argument; the inner call takes the runtime options and infersKindsfromapplicableTovia aconsttype parameter. Returns anAnnotationHandle<Payload, Kinds>that is callable —handle(value)constructs anAnnotationValueready to pass to a lane terminal. The handle also exposesnamespace,applicableTo: ReadonlySet<Kinds>, andread(plan): Payload | undefinedas 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), sincePayloadis 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; carriesnamespace,value: Payload, andapplicableTo: ReadonlySet<Kinds>. Stored verbatim inplan.meta.annotations[namespace]. The brand is whathandle.readchecks before returning, so framework-internal metadata under the same namespace key (e.g. the SQL emitter'smeta.annotations.codecsmap) does not surface as a user-handle read.ValidAnnotations<K, As>— mapped tuple type that resolves each element ofAstoneverwhen its declaredKindsdoes not includeK. Consumed by SQL DSL.annotate(...)builders on their variadic...annotationsparameter. The TSDoc records the discovery surfaced during M2: builders must useAs & ValidAnnotations<K, As>, not justValidAnnotations<K, As>— without the intersection, TypeScript's variadic-tuple inference picks anAsthat makes the call valid even when the gated tuple would containnever. The intersection pinsAsto the actual call-site tuple AND requires assignability to the gated form. The ORMMetaBuilderdeliberately sidesteps this trick by taking one annotation per call (no variadic-tuple inference involved).assertAnnotationsApplicable(annotations, kind, terminalName)— runtime gate. ThrowsRUNTIME.ANNOTATION_INAPPLICABLEwith structured details (namespace,terminalName,kind,applicableTo) on the first inapplicable annotation. Belt-and-suspenders for callers that bypass the type gate viaas 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-components—MetaBuilder<K>moduleNew module
src/meta-builder.ts(re-exported fromexports/runtime):K extends Kinds ? AnnotationValue<P, Kinds> : neverparameter type collapses toneverfor inapplicable annotations, someta.annotate(writeOnly)on a'read'builder fails to compile at the call site. No variadic-tuple inference, noAs & ValidAnnotations<K, As>intersection — TypeScript infersKindsfrom the annotation argument, then evaluates the conditional.annotate(...)validates eagerly viaassertAnnotationsApplicable([annotation], this.kind, this.terminalName)so cast-bypass cases (as any) throwRUNTIME.ANNOTATION_INAPPLICABLEat the configurator call site rather than later in the terminal.annotatereturnsthis, 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 (onlyannotate);LaneMetaBuilder<K>is the lane-side view that exposes the recordedannotationsmap. Lane terminals construct one viacreateMetaBuilder, hand it to the user asMetaBuilder<K>, then readmeta.annotationsafter 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-components—scopeonRuntimeMiddlewareContextRuntimeMiddlewareContextgains ascope: 'runtime' | 'connection' | 'transaction'field.SqlRuntimeImplpopulates'runtime'for top-levelexecute, derives a new context with'connection'forconnection.execute, and'transaction'fortransaction.executeandwithTransaction.MongoRuntimeImplpopulates'runtime'for now (no transaction surface yet). Coverage inpackages/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 builderspackages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts,query-impl.ts, andmutation-impl.tsget an.annotate(...)method on every builder:SelectQueryImpl→ kind'read'(viaQueryBase)GroupedQueryImpl→ kind'read'(viaQueryBase)InsertQueryImpl→ kind'write'UpdateQueryImpl→ kind'write'DeleteQueryImpl→ kind'write'Signature on each builder:
Behavior:
assertAnnotationsApplicableruns at.annotate()call time, not deferred to.build(), so casts /any/ dynamic invocations fail fast..annotate()calls compose; duplicate namespaces last-write-win.plan.meta.annotationsat.build()time alongside the framework-internalcodecsmap under its reserved namespace. Reserved keys win over user annotations under the same key (defensive — see the reserved-namespace policy ondefineAnnotation).Plumbing:
BuilderStategainsuserAnnotations: ReadonlyMap<string, AnnotationValue<unknown, OperationKind>>; mutation builders carry their own private field and thread it through.where()/.returning()clones.buildQueryPlanaccepts an optionaluserAnnotationsparameter and composes the finalmeta.annotationsfrom both maps. A smallmergeWriteAnnotationshelper 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.tsandsql-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 terminalsPer the spec, annotations attach via the terminal's optional configurator argument only — there is no chainable
.annotate()onCollection. That scope cut keeps the per-terminal kind binding clean and structurally prevents passing read annotations to mutations.Read terminals updated:
Collection.allandCollection.firstacceptconfigure?: (meta: MetaBuilder<'read'>) => voidas their last argument.Collection.aggregateandGroupedCollection.aggregatelikewise.Write terminals updated:
create/createAll/createCountupdate/updateAll/updateCountdelete/deleteAll/deleteCountupsertEach accepts
configure?: (meta: MetaBuilder<'write'>) => voidas 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:
db.orm.User.find(input, { … }, ann1, ann2)to evolve into without churning every call site. Adding a method toMetaBuilder<K>(meta.timeout(...),meta.tag(...),meta.cancellation(signal)) is a non-breaking surface extension.As & ValidAnnotations<K, As>intersection to reject inapplicable annotations through variadic-tuple inference. The callback hands one annotation at a time tometa.annotate(...), which uses an ordinary conditional constraint — no intersection, no documented "load-bearing trick" on the ORM lane.isAnnotationValuebrand-discriminator insidefirst. The variadic shape disambiguatedfirst(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; theisAnnotationValuehelper is gone..where((model) => …),.include((collection) => …),.aggregate((agg) => …),.having((having) => …)already use callback configurators.firstshape 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, passundefinedas the first argument:first(undefined, (meta) => …). The runtime cannot disambiguate "single function = filter vs. single function = configurator" without invoking it; explicitundefinedkeeps positional dispatch unambiguous and side-effect-free.Plumbing:
CollectionStateretainsuserAnnotations: ReadonlyMap<string, AnnotationValue<unknown, OperationKind>>. Empty on a fresh state; populated transiently by terminal calls just before dispatch.#withAnnotationsFromMeta(configure, terminalName)for state-driven reads (all,first): constructs aMetaBuilder<'read'>, invokes the configurator, foldsmeta.annotationsintostate.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 (aggregateand all writes): same construction, returns the recorded map (orundefinedwhen empty) formergeUserAnnotationsto consume.meta.annotate(...)itself, so terminals do not re-validate after the callback returns.buildOrmQueryPlanaccepts an optionaluserAnnotationsparameter and merges intoplan.meta.annotationsalongside the reservedcodecsmap.compileSelect,compileSelectWithIncludeStrategy, andcompileRelationSelectthreadstate.userAnnotationsthrough.mergeUserAnnotations(compiled, annotationsMap)on the compiled mutation plan(s), mirroring the aggregate path. For terminals that produce multiple plans (createAll'scompileInsertReturningSplit,createCount'scompileInsertCountSplit), the map applies to every plan in the array.update()/delete()preserve theirthis-typed receivers (TypeScript'sthis: State['hasWhere'] extends true ? Collection<...> : nevertrick 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 inprojects/middleware-intercept-and-cache/follow-ups.md. Neither blocks the April stop condition:cacheAnnotationis 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 packageLayout mirrors
middleware-telemetry. Registered inarchitecture.config.jsonunderextensions/integrations/runtimesopnpm lint:depsvalidates 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 }.CacheStoreandcreateInMemoryCacheStore(src/cache-store.ts): pluggable{ get, set }interface plus a default in-process LRU-with-TTL store backed by aMap. Iteration order is LRU; bothsetandgetbump recency. Expired entries drop on access without counting againstmaxEntrieson the next write. The clock is injectable so tests verify TTL expiry without real-time waits. DefaultmaxEntries: 1000. The default in-memory store is per-process and not coherent across replicas — users who need a shared cache pass a customCacheStore(Redis, Memcached, etc.).createCacheMiddleware(src/cache-middleware.ts): wires threeRuntimeMiddlewarehooks.intercept: bypass whenctx.scope !== 'runtime', whencacheAnnotationis absent, whenskip: true, or whenttlis missing. Otherwise resolve the key (per-querykeyoverride >ctx.contentHash(exec)) and probe the store. On a hit, logmiddleware.cache.hitand return the cached rows. On a miss, record a per-execPendingMissin a privateWeakMapand logmiddleware.cache.miss.onRow: append each driver row to the pending buffer for that exec.afterExecute: commit the buffer to the store iffcompleted === trueANDsource === 'driver'. Cleans up theWeakMapentry in all branches so a failed or middleware-served execution leaves no residue.Cross-family by construction: no
familyId/targetId, reads keys fromctx.contentHashpopulated 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— defaultcontentHashpath, per-querykeyoverride (assertscontentHashis not invoked), Mongo parity with a mock context whosecontentHashreturns 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.tscovers the four April stop-condition scenarios end-to-end:driver.executeand assert call count does not increase between miss and hit.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 oneCacheStoreland in distinct slots.afterExecutefires on miss and hit;sourceround-trips as'driver'on miss and'middleware'on hit.beforeExecuteis suppressed on the intercepted hit path.WeakMapbuffer; a third sequential call hits the cache.Plus opt-in regression tests: un-annotated queries always hit the driver;
skip: truebypasses the cache.Demo additions
examples/prisma-next-demo:src/orm-client/find-user-by-id-cached.ts—db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl, skip }))).src/orm-client/get-users-cached.ts—db.User.take(n).all((meta) => meta.annotate(cacheAnnotation({ ttl, key? }))), demonstrating the per-querykeyoverride.src/queries/get-users-cached.ts—db.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build()(SQL DSL chainable form).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.tsextends 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:runWithMiddlewarelifecycle to start with the intercept step, with hit-path semantics for skippingbeforeExecute/runDriver/onRow.WeakMap-correlated plan-identity invariant, and family-agnostic construction with the cache middleware as the canonical example.OperationKind,defineAnnotation,ValidAnnotations<K, As>, the split lane integration (SQL DSL chainable variadic; ORM meta-callback viaMetaBuilder<K>fromcreateMetaBuilder), the runtime applicability check,plan.meta.annotationsstorage, and reserved namespaces.contentHash(exec)andscopefields onRuntimeMiddlewareContext.source: 'middleware' | 'driver'onafterExecute.contentHashimpls, cache middleware unit + integration coverage, and SQL runtime scope plumbing.Updated
projects/middleware-intercept-and-cache/spec.mdandplan.mdto reflect the currieddefineAnnotation<Payload>()({ ... })form, the callable-handle convention (cacheAnnotation({ ttl })rather thancacheAnnotation.apply({ ttl })), and the meta-callback ORM API.api-revision-meta-callback.mdrecords the design delta from the original variadic-on-terminal shape to the configurator.follow-ups.mdcaptures the deferred work: nested-mutation annotation threading, MTI variant create paths, and the wider read-terminal annotation surface beyondall/first/aggregate.