Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f06ab94
feat(framework-components): add scope field to RuntimeMiddlewareContext
aqrln Apr 28, 2026
c89823b
feat(framework-components): add defineAnnotation with operation-kind …
aqrln Apr 28, 2026
5342ca6
test(framework-components): cover defineAnnotation, ValidAnnotations,…
aqrln Apr 28, 2026
2e11d8f
feat(sql-builder): add .annotate() to all SQL DSL builders
aqrln Apr 28, 2026
8498c2b
test(sql-builder): cover .annotate() across all five SQL DSL builders
aqrln Apr 28, 2026
e87f41f
feat(sql-orm-client): add .annotate() arg to read terminals (all, first)
aqrln Apr 28, 2026
07b715d
test(sql-orm-client): cover .annotate() arg on read terminals (all, f…
aqrln Apr 28, 2026
93a44f1
feat(sql-orm-client): add .annotate() arg to write terminals (create,…
aqrln Apr 28, 2026
2c2ef96
feat(sql-orm-client): add .annotate() arg to aggregate terminals plus…
aqrln Apr 28, 2026
cfbd324
feat(middleware-cache): scaffold @prisma-next/middleware-cache package
aqrln Apr 28, 2026
f8f1a65
feat(middleware-cache): add cacheAnnotation handle and CachePayload
aqrln Apr 28, 2026
8b6be5a
feat(middleware-cache): add CacheStore interface and in-memory LRU de…
aqrln Apr 28, 2026
362a34d
feat(middleware-cache): implement createCacheMiddleware with intercep…
aqrln Apr 28, 2026
bcd7706
test(middleware-cache): integration tests against real Postgres
aqrln Apr 28, 2026
f055ec2
docs(runtime-subsystem): document intercept hook, annotations, and ca…
aqrln Apr 28, 2026
29a243a
feat(prisma-next-demo): add cache middleware examples
aqrln Apr 29, 2026
ab56351
docs: plan more ergonomic annotation api
aqrln May 6, 2026
c517d93
feat(framework-components): infer annotations' `applicableTo` type
aqrln May 6, 2026
1a574d9
refactor(framework-components): drop `apply` from annotation handles
aqrln May 6, 2026
7a713ed
docs: revisit the annotation api
aqrln May 6, 2026
f99e5b0
refactor(sql-orm-client): change the annotation api
aqrln May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions architecture.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,12 @@
"layer": "integrations",
"plane": "runtime"
},
{
"glob": "packages/3-extensions/middleware-cache/**",
"domain": "extensions",
"layer": "integrations",
"plane": "runtime"
},
{
"glob": "packages/3-extensions/sql-orm-client/**",
"domain": "extensions",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,56 @@ export interface InterceptResult {

**Plan-identity invariant.** Interceptors that need per-execution scratch space (the cache middleware's miss buffer, request-coalescing pending sets, etc.) commonly key a private `WeakMap<exec, …>` on the post-lowering plan object. This relies on each call producing a fresh `exec` identity. Both family runtimes satisfy this today: SQL `executeAgainstQueryable` constructs a fresh `Object.freeze({...lowered, params: ...})` on every call; Mongo lowers fresh per call. If a future plan-memoization change ever recycles `exec` objects across calls, these middleware would silently leak rows between concurrent executions — which is what the cache middleware's concurrency regression test catches. See [ADR 025 — Plan caching & memoization](../adrs/ADR%20025%20-%20Plan%20Caching%20Memoization.md).

**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. A first-party cache middleware exercising this hook against both SQL and Mongo runtimes is planned as a follow-up.
**Family-agnostic by construction.** `intercept` lives on `RuntimeMiddleware` in `framework-components`, not on `SqlMiddleware` or `MongoMiddleware`. Both family runtimes inherit it via `RuntimeCore.execute → runWithMiddleware`; no per-family wiring is required. The first-party cache middleware ([`@prisma-next/middleware-cache`](../../../packages/3-extensions/middleware-cache/)) is the canonical interceptor example and works against both SQL and Mongo runtimes day one — see its README for the full design (opt-in via annotations, transaction-scope guard, pluggable `CacheStore`, TTL/LRU semantics).

## Annotations

Annotations are a typed, namespaced way to attach per-query metadata that middleware can read. They are the opt-in mechanism the cache middleware uses ("cache this read for 60 seconds"), the audit middleware would use ("attribute this write to this actor"), and any future per-query policy hook will reuse.

The framework provides three pieces:

- `OperationKind = 'read' | 'write'` — the binary discriminator that gates which terminals an annotation can attach to. Finer-grained kinds (`'select' | 'insert' | …`) are deferred; widening is additive.
- `defineAnnotation<Payload>()({ namespace, applicableTo })` — curried two-step factory. The first call takes `Payload` explicitly; the second takes the runtime options and infers `Kinds` from `applicableTo` via a `const` type parameter, so the operation kinds appear once at the call site rather than being repeated as a type argument. The returned handle is a **callable function**: `handle(value)` produces an `AnnotationValue<Payload, Kinds>`. The function also carries `namespace`, `applicableTo: ReadonlySet<Kinds>` (frozen), and `read(plan)` as properties. Both `applicableTo` (off the handle and off each value) feed the type-level and runtime applicability gates.
- `ValidAnnotations<K, As>` — mapped tuple type consumed by lane terminals. Resolves any tuple element whose declared `Kinds` does not include the terminal's `K` to `never`, surfacing the mismatch as a type error at the call site.

```typescript
// packages/1-framework/1-core/framework-components/src/annotations.ts
import { defineAnnotation } from '@prisma-next/framework-components/runtime'

// Read-only annotation. Kinds inferred as 'read'.
export const cacheAnnotation = defineAnnotation<{ ttl?: number }>()({
namespace: 'cache',
applicableTo: ['read'],
})

// Write-only annotation (illustrative). Kinds inferred as 'write'.
export const auditAnnotation = defineAnnotation<{ actor: string }>()({
namespace: 'audit',
applicableTo: ['write'],
})

// Applicable to both kinds (e.g. tracing). Kinds inferred as 'read' | 'write'.
export const otelAnnotation = defineAnnotation<{ traceId: string }>()({
namespace: 'otel',
applicableTo: ['read', 'write'],
})
```

**Lane integration.** SQL DSL and ORM `Collection` adopt different surface shapes appropriate to their idioms; both gate by the terminal's operation kind `K`:

- SQL DSL — `.annotate(...)` is a variadic chainable method on every builder kind: `SelectQueryImpl` / `GroupedQueryImpl` accept `'read'`-applicable annotations; `InsertQueryImpl` / `UpdateQueryImpl` / `DeleteQueryImpl` accept `'write'`-applicable. The variadic parameter is constrained by `As & ValidAnnotations<K, As>`. The intersection is load-bearing — TypeScript's variadic-tuple inference is too forgiving with `ValidAnnotations<K, As>` alone, but the intersection pins `As` to the call-site tuple AND requires assignability to the gated form.
- ORM `Collection` and `GroupedCollection` — every terminal accepts an optional `configure: (meta: MetaBuilder<K>) => void` callback as its last argument. The user calls `meta.annotate(annotation)` (chainable, returns the builder) once per annotation; the conditional `K extends Kinds ? AnnotationValue<P, Kinds> : never` parameter type rejects inapplicable annotations at the call site. There is no chainable `Collection.annotate()` — annotations attach via the configurator only. The callback shape (rather than the variadic shape SQL DSL uses) is deliberate at the ORM layer: a terminal's last argument grows over time (future per-call options), and a callback configurator is extension-friendly where a variadic forecloses on additional arguments. The callback also drops the load-bearing `As & ValidAnnotations<K, As>` intersection — taking one annotation at a time avoids variadic-tuple inference entirely.

**Runtime applicability check.** SQL DSL `.build()` and the ORM `MetaBuilder.annotate(...)` both call `assertAnnotationsApplicable(...)` against the annotation(s) and the terminal's kind. The helper throws `RUNTIME.ANNOTATION_INAPPLICABLE` naming the namespace and terminal on any annotation whose `applicableTo` set lacks `kind`. This is belt-and-suspenders: the type system fails closed for type-aware callers, and the runtime check fails closed for casts / `any` / dynamic invocations. ORM terminals construct the meta builder via `createMetaBuilder(kind, terminalName)` and let the builder's `annotate` perform the gate eagerly; lane code does not re-validate after the user callback returns.

**Storage.** Applied annotations land under `plan.meta.annotations[namespace]` as branded `AnnotationValue<Payload, Kinds>` objects. Multiple `.annotate()` chain calls (SQL DSL) or `meta.annotate(...)` calls inside the configurator (ORM) compose; duplicate namespaces use last-write-wins. Reading via `handle.read(plan)` defensively checks the `__annotation` brand so framework-internal metadata (e.g. `meta.annotations.codecs` written by the SQL emitter) is not mistaken for a user annotation.

**Reserved namespaces.** `codecs` is consumed by the SQL emitter (`meta.annotations.codecs[alias] = 'pg/text@1'`) and read by the SQL runtime's `decodeRow`; it must not be used by user handles. Target-specific keys such as `pg` are similarly reserved. `defineAnnotation` does not structurally prevent a user from naming a reserved namespace — handles are namespaced strings, not branded types — but the framework makes no compatibility guarantees about handles that do.

**Runtime context: `contentHash` and `scope`.** Two `RuntimeMiddlewareContext` fields support the annotation-driven middleware ecosystem:

- `contentHash(exec): Promise<string>` — the family runtime returns an opaque, bounded digest identifying the `(storage, statement, params)` tuple of an execution. SQL composes `meta.storageHash + '|' + exec.sql + '|' + canonicalStringify(exec.params)` and pipes through `hashContent` (SHA-512); Mongo composes `meta.storageHash + '|' + canonicalStringify(exec.command)` and pipes the same way. Two semantically equivalent executions return the same digest. Used by middleware that need per-execution identity (caching, request coalescing). The cache middleware uses the returned string directly as a `Map` key — it is not (and should not be) further hashed by callers.
- `scope: 'runtime' | 'connection' | 'transaction'` — discriminates the queryable surface the execution is running under. Top-level `runtime.execute` populates `'runtime'`; `connection.execute` and `transaction.execute` derive a context with `'connection'` / `'transaction'`. Middleware that should only act at the top level (e.g. the cache middleware) read this field to bypass non-runtime scopes.

## ORM Client Integration

Expand Down Expand Up @@ -476,6 +525,7 @@ export interface RuntimeMiddleware<TPlan extends QueryPlan = QueryPlan> {
- `intercept` is optional and lets a middleware short-circuit execution and supply rows directly. See "Intercepting Execution" above.
- `beforeExecute` runs on the driver path only and is where verification and guardrails live. On the intercepted hit path it is skipped.
- `onRow` is optional and called for each streamed row on the driver path; intercepted rows skip `onRow`. `afterExecute` receives aggregates including the `completed` flag (success vs error path) and the `source` field (`'driver'` vs `'middleware'`).
- `RuntimeMiddlewareContext` carries `contentHash(exec)` (a bounded opaque digest of the execution identity) and `scope` (`'runtime' | 'connection' | 'transaction'`) for middleware that need per-execution identity or scope-aware behavior.

### Family-Specific Middleware Interfaces

Expand Down Expand Up @@ -537,7 +587,7 @@ export const softDelete: SqlMiddleware = {

**Raw SQL lanes.** `beforeCompile` only runs when the lane produces a `SqlQueryPlan` (AST present). Raw SQL plans — which arrive as fully-lowered `SqlExecutionPlan` — bypass the hook entirely (the SQL runtime's `executeAgainstQueryable` branches on plan shape).

**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above) — it lives on `RuntimeMiddleware` rather than in the SQL `beforeCompile` chain. User-authored query annotations are deferred to later milestones; their API is not yet defined.
**Scope.** `beforeCompile` is the current AST-rewrite surface for SQL. Mongo has no typed `beforeCompile` chain today; the abstract base's `runBeforeCompile` defaults to identity for it. Short-circuiting the query with a static result is provided by the cross-family `intercept` hook (see "Intercepting Execution" above); user-authored query annotations are provided by the framework `defineAnnotation` helper plus lane-side `.annotate(...)` (see "Annotations" above) — both are family-agnostic and live on `RuntimeMiddleware` / lane terminals rather than in the SQL `beforeCompile` chain.

## Guardrails via Middleware

Expand Down Expand Up @@ -615,6 +665,10 @@ Tests ensure the middleware contract remains stable, plan identity is preserved,
- `runWithMiddleware` unit tests (registration order, error path with `completed: false`, error swallowing in `afterExecute` during the error path, zero-middleware passthrough, and the full `intercept` matrix: first-wins semantics, hit path skipping `beforeExecute` / `runDriver` / `onRow`, hit + `source: 'middleware'` on `afterExecute`, miss-path zero-change regression, mixed chains, interceptor-throw paths, and the `Iterable` / `AsyncIterable` row-source variants).
- `RuntimeCore` mock-family tests demonstrate that a minimal subclass with concrete `lower` / `runDriver` / `close` (and nothing else) typechecks, constructs, and exercises the lifecycle.
- Cross-family proof: the same generic middleware observes queries from both SQL and Mongo runtimes, **and** the same generic interceptor short-circuits queries from both runtimes via inherited `runWithMiddleware` (preserved and extended from `cross-family-middleware-spi`).
- Annotations: unit + type tests for `defineAnnotation`, `ValidAnnotations<K, As>` (positive + negative on both kinds), `assertAnnotationsApplicable` (cast-bypass coverage), and lane-side `.annotate(...)` on every SQL DSL builder kind plus every ORM terminal.
- Family `contentHash` implementations: SQL and Mongo unit tests cover stability across object-key order, discrimination on `storageHash` and params/command, fixed-size opaque output (`sha512:HEXDIGEST`), and round-trip determinism.
- Cache middleware: package unit tests (opt-in semantics, hit/miss paths, scope guard, key composition incl. user-supplied `key` override and Mongo-style mock parity, store mechanics — LRU + TTL with injected clock) plus integration tests against real Postgres covering the April stop condition (driver invocation count = 1 on a repeated annotated read), composition with a `beforeCompile` rewriter (cache key reflects rewritten SQL), composition with telemetry (`source` round-trip), and concurrency (parallel executes do not cross-talk via the per-exec `WeakMap` buffer).
- SQL runtime scope plumbing: `connection.execute` and `transaction.execute` populate `ctx.scope` correctly; round-trips through `runtime → connection → transaction → runtime` route to the right scope each time.
- Golden SQL and hash stability tests to ensure Plan immutability and identity do not drift.
- Violation matrix ensuring built-in rules behave consistently under `strict` and `permissive` modes.
- Benchmarks to validate overhead budgets.
41 changes: 41 additions & 0 deletions examples/prisma-next-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ The demo includes ORM client examples under `src/orm-client/`:
- `ormClientGetUserInsights(limit, runtime)` — `include().combine()` metrics and latest related row
- `ormClientGetUserKindBreakdown(minUsers, runtime)` — `groupBy().having().aggregate()` breakdown
- `ormClientUpsertUser(data, runtime)` — `upsert()` for create-or-update by primary key
- `ormClientFindUserByIdCached(id, runtime, options?)` — opt-in cached `first({ id })` lookup via `cacheAnnotation({ ttl })` from `@prisma-next/middleware-cache`
- `ormClientGetUsersCached(limit, runtime, options?)` — opt-in cached `User.all()` listing, with optional explicit cache-key override

Run from the CLI:

Expand All @@ -123,6 +125,45 @@ pnpm start -- repo-kind-breakdown 1
pnpm start -- repo-upsert-user 00000000-0000-0000-0000-000000000099 demo@example.com user
```

## Cache Middleware Examples

The demo wires `@prisma-next/middleware-cache` into the Postgres client in `src/prisma/db.ts`. The cache middleware is **opt-in per query** — it only acts on plans whose `meta.annotations` carry a `cacheAnnotation` payload with a `ttl` set. Three CLI commands run a query twice and report the latency of each call so the cache hit is visible:

```bash
# ORM client first({ id }) cached for 60s.
pnpm start -- cache-demo-user 00000000-0000-0000-0000-000000000001

# ORM client User.all() listing cached for 60s.
pnpm start -- cache-demo-users 5

# SQL DSL .annotate(cacheAnnotation({ ttl })) on a select.
pnpm start -- cache-demo-sql 5
```

A representative run looks like:

```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add language specification to the fenced code block.

The fenced code block should specify a language identifier for proper rendering and to satisfy markdown linting rules.

📝 Proposed fix
 A representative run looks like:
 
-```
+```text
 Demonstrating opt-in caching with cacheAnnotation...
 Calling User.first({ id: 00000000-... }) twice — second call should hit cache.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
A representative run looks like:
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 145-145: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/prisma-next-demo/README.md` at line 145, The fenced code block in
README.md that starts with "Demonstrating opt-in caching with
cacheAnnotation..." is missing a language identifier; update that fence (the
triple-backtick block containing that text) to include a language tag such as
"text" or "console" (e.g., ```text) so the block renders correctly and satisfies
markdown lint rules.

Demonstrating opt-in caching with cacheAnnotation...
Calling User.first({ id: 00000000-... }) twice — second call should hit cache.

First call (cache miss): 4.71ms
Second call (cache hit): 0.18ms
Speedup: 26.2x faster
```

The corresponding source files:

- `src/orm-client/find-user-by-id-cached.ts` — `db.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({ ttl })))`
- `src/orm-client/get-users-cached.ts` — `db.User.take(n).all((meta) => meta.annotate(cacheAnnotation({ ttl, key? })))`
- `src/queries/get-users-cached.ts` — `db.sql.user.select(...).annotate(cacheAnnotation({ ttl })).build()`

Relevant points:

- The `cacheAnnotation` handle declares `applicableTo: ['read']`. Passing it to a write terminal is rejected at both the type and runtime levels — a `as any` cast cannot smuggle it past one without failing at the other.
- The default cache key is `RuntimeMiddlewareContext.contentHash(exec)`, a SHA-512 digest of the post-lowering SQL plus parameters. Different parameters land in different cache slots; identical executions hit. Schema migrations rotate `meta.storageHash`, which feeds into `contentHash`, so cached entries do not leak across migrations.
- The default in-memory store is per-process. For shared caching across replicas, supply a custom `CacheStore` (for example a Redis-backed implementation) via `createCacheMiddleware({ store })`.
- Connection-scoped (`runtime.connection().execute(...)`) and transaction-scoped (`runtime.transaction(...)`) executions bypass the cache regardless of annotation, so transactional read-after-write coherence is preserved.

## Setup

1. Install dependencies:
Expand Down
1 change: 1 addition & 0 deletions examples/prisma-next-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@prisma-next/extension-pgvector": "workspace:*",
"@prisma-next/family-sql": "workspace:*",
"@prisma-next/ids": "workspace:*",
"@prisma-next/middleware-cache": "workspace:*",
"@prisma-next/middleware-telemetry": "workspace:*",
"@prisma-next/postgres": "workspace:*",
"@prisma-next/sql-contract": "workspace:*",
Expand Down
Loading
Loading