| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
Remove 945 redundant and declaration-restating unit tests (#30731) ## Linked issue n/a. This continues the test-suite audit from #30729. ## At a glance This is a typical removal, from `packages/1-framework/3-tooling/migration/test/find-path-with-invariants.test.ts`: ```ts - it('unreachable target → null', () => { - const graph = reconstructGraph([pkg('A', 'B', 'm1')]); - expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); - }); ``` The same file keeps a test with an identical body, at line 145: ```ts it('returns null when target is structurally unreachable', () => { const graph = reconstructGraph([pkg('A', 'B', 'm1')]); expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); }); ``` ## Summary This is the second batch from the test audit. It removes 945 `it`/`test` declarations from package, example, app and script tests. It changes only test files: no production code, no `src/`, and nothing in the integration suite. ## Decision The batch takes only the audit entries that are both rated high-confidence and marked "remove". It includes two kinds of test: - **Redundant:** another test makes the same assertion through the same code path. - **Trivial:** a type test that restates a declared type; a test that compares an id, kind, capability flag or constant with its own literal; a getter test that reads back a constructor argument; a "hook is a function" or `toBeDefined` check; or a test of a test-only helper. It does not include: - unreachable-input entries, because each one needs a judgement about whether the input can actually happen; - integration tests; - anything that would need a test rewritten or production code changed. The audit was written against an older `main`, so every entry was checked again against current code. A test was removed only when the test it duplicates still asserts the same thing or more. About 90 entries were skipped in whole or in part: the test had already gone, the kept test turned out to check less, or the removed test was the only check of some behaviour. ## Reviewer notes - There is one commit per package area: sql, tooling, adapters and drivers, cli, targets, framework and mongo, and extensions and examples. Each commit can be reviewed or reverted on its own. - Some removals are partial. In a few tests, one duplicated assertion was dropped and the rest of the test stays: - the second "reprint stability" `verify` in `check-lifecycle-e2e`; - redundant `.not.toEqualTypeOf<number | string>()` lines in the example type tests. Three tests were renamed after an assertion was dropped, so that the name matches what the test still checks. - 18 files are deleted outright. In each of them, every test was either a duplicate or a restatement. Examples: - `default-functions.test.ts`; - the `codec-factory*` tests of the test-only `defineTestCodec`; - `is-ci.test.ts`; - the mongo and sqlite `create-*-control-client` tests; - several `*.test-d.ts` files that restate declared types. - Test-only helpers and fixtures that became unused were removed with the tests that used them, for example `test/fixtures/exact-prefix-import.ts` in the CLI. ## Testing performed All on the final HEAD: - `pnpm build`, `pnpm lint` and `pnpm lint:deps` pass. - `pnpm typecheck` passes, except `prisma7-adoption#typecheck`, which cannot download the Prisma 7 schema-engine binary for `linux-nixos` (404) in this environment. - `pnpm coverage:packages` ran 24,844 passed, 40 failed and 36 skipped. Then `pnpm coverage:report` gave 0 threshold failures; the 6 warnings are all packages already on the warning-only list. The 40 failures are in files this PR does not change: - 39 are environment-specific: CLI `init-prisma7*` tests (`EACCES` copying fixtures), and the tarball and `module-identity` tests (pnpm trust policy on `@vercel/detect-agent`). - 1 is `no-parallel-ci-detection`, which hit its 500 ms timeout under load and passes when run alone. - The language-server, cli-telemetry and postgres-adapter round-trip suites also timed out during the parallel run. Each passes in full when run alone. - The example tests in mongo-demo, prisma-8-demo and retail-store pass, and so does `pnpm test:scripts`. - `apps/telemetry-backend` `handler.test.ts` fails here because `ingestedAt` comes back as a `Temporal.Instant` where the test expects a `Date`. This PR does not change that test or that app's source; the only file it touches in the app is `rate-limit.test.ts`. ## Skill update n/a — internal only. ## Alternatives considered - **Include the unreachable-input and medium-confidence entries.** Left for a later batch. They need a judgement call on each test, while this batch is limited to deletions that can be checked directly against the code. - **Rewrite per-value test groups as table-driven tests.** Not done here, for example the 36 `1-core/errors` factory tests. A rewrite changes what each test asserts and needs its own review. ## Checklist - [x] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). - [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is scoped to one logical concern. - [x] Tests are updated. - [ ] The PR title is in `TML-NNNN: <sentence-case title>` form. No Linear ticket exists for this work. - [x] The **Skill update** section above is filled in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 20 天前 | |
Remove 945 redundant and declaration-restating unit tests (#30731) ## Linked issue n/a. This continues the test-suite audit from #30729. ## At a glance This is a typical removal, from `packages/1-framework/3-tooling/migration/test/find-path-with-invariants.test.ts`: ```ts - it('unreachable target → null', () => { - const graph = reconstructGraph([pkg('A', 'B', 'm1')]); - expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); - }); ``` The same file keeps a test with an identical body, at line 145: ```ts it('returns null when target is structurally unreachable', () => { const graph = reconstructGraph([pkg('A', 'B', 'm1')]); expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); }); ``` ## Summary This is the second batch from the test audit. It removes 945 `it`/`test` declarations from package, example, app and script tests. It changes only test files: no production code, no `src/`, and nothing in the integration suite. ## Decision The batch takes only the audit entries that are both rated high-confidence and marked "remove". It includes two kinds of test: - **Redundant:** another test makes the same assertion through the same code path. - **Trivial:** a type test that restates a declared type; a test that compares an id, kind, capability flag or constant with its own literal; a getter test that reads back a constructor argument; a "hook is a function" or `toBeDefined` check; or a test of a test-only helper. It does not include: - unreachable-input entries, because each one needs a judgement about whether the input can actually happen; - integration tests; - anything that would need a test rewritten or production code changed. The audit was written against an older `main`, so every entry was checked again against current code. A test was removed only when the test it duplicates still asserts the same thing or more. About 90 entries were skipped in whole or in part: the test had already gone, the kept test turned out to check less, or the removed test was the only check of some behaviour. ## Reviewer notes - There is one commit per package area: sql, tooling, adapters and drivers, cli, targets, framework and mongo, and extensions and examples. Each commit can be reviewed or reverted on its own. - Some removals are partial. In a few tests, one duplicated assertion was dropped and the rest of the test stays: - the second "reprint stability" `verify` in `check-lifecycle-e2e`; - redundant `.not.toEqualTypeOf<number | string>()` lines in the example type tests. Three tests were renamed after an assertion was dropped, so that the name matches what the test still checks. - 18 files are deleted outright. In each of them, every test was either a duplicate or a restatement. Examples: - `default-functions.test.ts`; - the `codec-factory*` tests of the test-only `defineTestCodec`; - `is-ci.test.ts`; - the mongo and sqlite `create-*-control-client` tests; - several `*.test-d.ts` files that restate declared types. - Test-only helpers and fixtures that became unused were removed with the tests that used them, for example `test/fixtures/exact-prefix-import.ts` in the CLI. ## Testing performed All on the final HEAD: - `pnpm build`, `pnpm lint` and `pnpm lint:deps` pass. - `pnpm typecheck` passes, except `prisma7-adoption#typecheck`, which cannot download the Prisma 7 schema-engine binary for `linux-nixos` (404) in this environment. - `pnpm coverage:packages` ran 24,844 passed, 40 failed and 36 skipped. Then `pnpm coverage:report` gave 0 threshold failures; the 6 warnings are all packages already on the warning-only list. The 40 failures are in files this PR does not change: - 39 are environment-specific: CLI `init-prisma7*` tests (`EACCES` copying fixtures), and the tarball and `module-identity` tests (pnpm trust policy on `@vercel/detect-agent`). - 1 is `no-parallel-ci-detection`, which hit its 500 ms timeout under load and passes when run alone. - The language-server, cli-telemetry and postgres-adapter round-trip suites also timed out during the parallel run. Each passes in full when run alone. - The example tests in mongo-demo, prisma-8-demo and retail-store pass, and so does `pnpm test:scripts`. - `apps/telemetry-backend` `handler.test.ts` fails here because `ingestedAt` comes back as a `Temporal.Instant` where the test expects a `Date`. This PR does not change that test or that app's source; the only file it touches in the app is `rate-limit.test.ts`. ## Skill update n/a — internal only. ## Alternatives considered - **Include the unreachable-input and medium-confidence entries.** Left for a later batch. They need a judgement call on each test, while this batch is limited to deletions that can be checked directly against the code. - **Rewrite per-value test groups as table-driven tests.** Not done here, for example the 36 `1-core/errors` factory tests. A rewrite changes what each test asserts and needs its own review. ## Checklist - [x] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). - [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is scoped to one logical concern. - [x] Tests are updated. - [ ] The PR title is in `TML-NNNN: <sentence-case title>` form. No Linear ticket exists for this work. - [x] The **Skill update** section above is filled in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 20 天前 | |
Remove 945 redundant and declaration-restating unit tests (#30731) ## Linked issue n/a. This continues the test-suite audit from #30729. ## At a glance This is a typical removal, from `packages/1-framework/3-tooling/migration/test/find-path-with-invariants.test.ts`: ```ts - it('unreachable target → null', () => { - const graph = reconstructGraph([pkg('A', 'B', 'm1')]); - expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); - }); ``` The same file keeps a test with an identical body, at line 145: ```ts it('returns null when target is structurally unreachable', () => { const graph = reconstructGraph([pkg('A', 'B', 'm1')]); expect(findPathWithInvariants(graph, 'A', 'Z', new Set(['X']))).toBeNull(); }); ``` ## Summary This is the second batch from the test audit. It removes 945 `it`/`test` declarations from package, example, app and script tests. It changes only test files: no production code, no `src/`, and nothing in the integration suite. ## Decision The batch takes only the audit entries that are both rated high-confidence and marked "remove". It includes two kinds of test: - **Redundant:** another test makes the same assertion through the same code path. - **Trivial:** a type test that restates a declared type; a test that compares an id, kind, capability flag or constant with its own literal; a getter test that reads back a constructor argument; a "hook is a function" or `toBeDefined` check; or a test of a test-only helper. It does not include: - unreachable-input entries, because each one needs a judgement about whether the input can actually happen; - integration tests; - anything that would need a test rewritten or production code changed. The audit was written against an older `main`, so every entry was checked again against current code. A test was removed only when the test it duplicates still asserts the same thing or more. About 90 entries were skipped in whole or in part: the test had already gone, the kept test turned out to check less, or the removed test was the only check of some behaviour. ## Reviewer notes - There is one commit per package area: sql, tooling, adapters and drivers, cli, targets, framework and mongo, and extensions and examples. Each commit can be reviewed or reverted on its own. - Some removals are partial. In a few tests, one duplicated assertion was dropped and the rest of the test stays: - the second "reprint stability" `verify` in `check-lifecycle-e2e`; - redundant `.not.toEqualTypeOf<number | string>()` lines in the example type tests. Three tests were renamed after an assertion was dropped, so that the name matches what the test still checks. - 18 files are deleted outright. In each of them, every test was either a duplicate or a restatement. Examples: - `default-functions.test.ts`; - the `codec-factory*` tests of the test-only `defineTestCodec`; - `is-ci.test.ts`; - the mongo and sqlite `create-*-control-client` tests; - several `*.test-d.ts` files that restate declared types. - Test-only helpers and fixtures that became unused were removed with the tests that used them, for example `test/fixtures/exact-prefix-import.ts` in the CLI. ## Testing performed All on the final HEAD: - `pnpm build`, `pnpm lint` and `pnpm lint:deps` pass. - `pnpm typecheck` passes, except `prisma7-adoption#typecheck`, which cannot download the Prisma 7 schema-engine binary for `linux-nixos` (404) in this environment. - `pnpm coverage:packages` ran 24,844 passed, 40 failed and 36 skipped. Then `pnpm coverage:report` gave 0 threshold failures; the 6 warnings are all packages already on the warning-only list. The 40 failures are in files this PR does not change: - 39 are environment-specific: CLI `init-prisma7*` tests (`EACCES` copying fixtures), and the tarball and `module-identity` tests (pnpm trust policy on `@vercel/detect-agent`). - 1 is `no-parallel-ci-detection`, which hit its 500 ms timeout under load and passes when run alone. - The language-server, cli-telemetry and postgres-adapter round-trip suites also timed out during the parallel run. Each passes in full when run alone. - The example tests in mongo-demo, prisma-8-demo and retail-store pass, and so does `pnpm test:scripts`. - `apps/telemetry-backend` `handler.test.ts` fails here because `ingestedAt` comes back as a `Temporal.Instant` where the test expects a `Date`. This PR does not change that test or that app's source; the only file it touches in the app is `rate-limit.test.ts`. ## Skill update n/a — internal only. ## Alternatives considered - **Include the unreachable-input and medium-confidence entries.** Left for a later batch. They need a judgement call on each test, while this batch is limited to deletions that can be checked directly against the code. - **Rewrite per-value test groups as table-driven tests.** Not done here, for example the 36 `1-core/errors` factory tests. A rewrite changes what each test asserts and needs its own review. ## Checklist - [x] All commits are signed off (`git commit -s`) per the [DCO](../CONTRIBUTING.md#developer-certificate-of-origin-dco). - [x] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and the change is scoped to one logical concern. - [x] Tests are updated. - [ ] The PR title is in `TML-NNNN: <sentence-case title>` form. No Linear ticket exists for this work. - [x] The **Skill update** section above is filled in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Steven McClankerton <tatarintsev@prisma.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> | 20 天前 | |
Activity tree close-out: the project's reasoning is in ADR 271, current docs describe the new middleware interface, and the project folders are deleted (#30775) ## What this does > Builds on prisma/orm#30770 (slice 4, approved, auto-merge on). Until it merges, this diff also shows its commits; review the last three commits only. This closes the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). All four slices have merged: - [TML-3559](https://linear.app/prisma-company/issue/TML-3559): statements and transactions are activities. - [TML-3560](https://linear.app/prisma-company/issue/TML-3560): clients start their own activities. - [TML-3561](https://linear.app/prisma-company/issue/TML-3561): every ORM terminal method is one `orm-call` activity, and the cache caches whole ORM calls. - [TML-3400](https://linear.app/prisma-company/issue/TML-3400): a write annotated with `invalidateAnnotation` invalidates once it has committed. The pull request changes docs only, apart from one renamed test variable. ## Changes - **Project folders deleted.** `projects/activity-tree/` and `projects/cache-invalidation-on-write/` are gone. No file outside them linked to them. - **ADR 271 holds the remaining reasoning.** From the project's design document it gains: - the rule that the tree grows as the code runs; - the rejected alternatives the ADR did not yet list; - updated wording for what has shipped. - **Current docs describe the new middleware interface.** `ARCHITECTURE.md`, the Query Lanes subsystem doc and ADR 266 now name the lifecycle events (`activityStarted`, `activityEnded`, `activityFinalized`) and the interceptors (`beforeCompile`, `beforeEncode`, `intercept.onQuery`, `intercept.onExecute`, `intercept.onActivity`, `onRow`) instead of the removed per-statement hooks. - **Older ADRs point to ADR 271.** - ADR 014, which defined the old hook API, is superseded by ADR 271. - ADR 215 is amended by ADR 271. Its decision still stands; only its hook names changed. - ADRs that mention a removed hook in passing open with a one-line note. ## Project definition of done Every item passes. Two reviewers verified each one independently against code, tests and docs. | Item | How it is shown | |---|---| | An annotated ORM `update()` invalidates only after the commit, inside and outside `db.transaction(fn)`, and a rollback leaves the entry | `test/integration/test/cross-package/middleware-cache-write-invalidation.test.ts` | | An annotated ORM read served from the cache sends no statement, and is never served inside a transaction or on a Supabase role session | `middleware-cache-orm.test.ts`, the Supabase role-binding test | | Writes with relation callbacks and multi-table-inheritance creates invalidate | write-invalidation test file | | Only the new middleware interface is used | code; this PR's doc and ADR changes | | ADR 160 and ADR 260 carry supersession notes; the docs describe what shipped | ADRs, runtime subsystem doc, `prisma-8` skill, cache README | | Both project folders are deleted | this PR | ## Known gaps, now Linear tickets These were the project's deferred items. Each existed before the project or is outside its scope: - [TML-3624](https://linear.app/prisma-company/issue/TML-3624): a dropped row iterator never ends its statement or ORM call, and `close()` then waits for ever. - [TML-3625](https://linear.app/prisma-company/issue/TML-3625): `close()` with `closeRefusal: 'when-idle'` can close the driver while hooks still run. - [TML-3626](https://linear.app/prisma-company/issue/TML-3626): a queryable wrapper sees no ORM statements unless it also wraps `[activitySource]`. - [TML-3627](https://linear.app/prisma-company/issue/TML-3627): `intercept.onQuery` and `intercept.onExecute` are not raced against the abort signal. - [TML-3629](https://linear.app/prisma-company/issue/TML-3629): `startActivityOn` fails with a bare invariant on a queryable without an activity source. - [TML-3630](https://linear.app/prisma-company/issue/TML-3630): no test checks the `activitySource` symbol identity and the registry augmentation across published packages. - [TML-3631](https://linear.app/prisma-company/issue/TML-3631): a long transaction holds every finished statement in memory when any middleware implements `activityFinalized`. ## Out of scope `projects/middleware-intercept-and-cache/` belongs to an earlier project, TML-2143. It still describes the old hooks, and its close-out is [TML-3632](https://linear.app/prisma-company/issue/TML-3632). ## Alternatives considered - **Keep the manual QA scripts in `docs/`.** Rejected. They test one release's changes per slice, and the integration tests and the demo now cover the same behaviour. Git history keeps the reports. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added write-driven cache invalidation using `invalidateAnnotation`, with support for cache keys and metadata. - Invalidation occurs after successful completion or transaction commit; rolled-back writes do not invalidate entries. - Unknown outcomes still trigger invalidation, while store errors are logged according to documented behavior. - Added support across ORM and SQL-builder writes, with read operations rejecting the annotation. - **Documentation** - Updated runtime architecture and middleware guidance to describe activity lifecycle events and interceptors. - Clarified replacement and removal of legacy runtime hooks. - Added usage examples, limitations, migration guidance, and error-reference updates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: paulwer <paul@wer-ner.de> | 16 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
Activity tree close-out: the project's reasoning is in ADR 271, current docs describe the new middleware interface, and the project folders are deleted (#30775) ## What this does > Builds on prisma/orm#30770 (slice 4, approved, auto-merge on). Until it merges, this diff also shows its commits; review the last three commits only. This closes the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). All four slices have merged: - [TML-3559](https://linear.app/prisma-company/issue/TML-3559): statements and transactions are activities. - [TML-3560](https://linear.app/prisma-company/issue/TML-3560): clients start their own activities. - [TML-3561](https://linear.app/prisma-company/issue/TML-3561): every ORM terminal method is one `orm-call` activity, and the cache caches whole ORM calls. - [TML-3400](https://linear.app/prisma-company/issue/TML-3400): a write annotated with `invalidateAnnotation` invalidates once it has committed. The pull request changes docs only, apart from one renamed test variable. ## Changes - **Project folders deleted.** `projects/activity-tree/` and `projects/cache-invalidation-on-write/` are gone. No file outside them linked to them. - **ADR 271 holds the remaining reasoning.** From the project's design document it gains: - the rule that the tree grows as the code runs; - the rejected alternatives the ADR did not yet list; - updated wording for what has shipped. - **Current docs describe the new middleware interface.** `ARCHITECTURE.md`, the Query Lanes subsystem doc and ADR 266 now name the lifecycle events (`activityStarted`, `activityEnded`, `activityFinalized`) and the interceptors (`beforeCompile`, `beforeEncode`, `intercept.onQuery`, `intercept.onExecute`, `intercept.onActivity`, `onRow`) instead of the removed per-statement hooks. - **Older ADRs point to ADR 271.** - ADR 014, which defined the old hook API, is superseded by ADR 271. - ADR 215 is amended by ADR 271. Its decision still stands; only its hook names changed. - ADRs that mention a removed hook in passing open with a one-line note. ## Project definition of done Every item passes. Two reviewers verified each one independently against code, tests and docs. | Item | How it is shown | |---|---| | An annotated ORM `update()` invalidates only after the commit, inside and outside `db.transaction(fn)`, and a rollback leaves the entry | `test/integration/test/cross-package/middleware-cache-write-invalidation.test.ts` | | An annotated ORM read served from the cache sends no statement, and is never served inside a transaction or on a Supabase role session | `middleware-cache-orm.test.ts`, the Supabase role-binding test | | Writes with relation callbacks and multi-table-inheritance creates invalidate | write-invalidation test file | | Only the new middleware interface is used | code; this PR's doc and ADR changes | | ADR 160 and ADR 260 carry supersession notes; the docs describe what shipped | ADRs, runtime subsystem doc, `prisma-8` skill, cache README | | Both project folders are deleted | this PR | ## Known gaps, now Linear tickets These were the project's deferred items. Each existed before the project or is outside its scope: - [TML-3624](https://linear.app/prisma-company/issue/TML-3624): a dropped row iterator never ends its statement or ORM call, and `close()` then waits for ever. - [TML-3625](https://linear.app/prisma-company/issue/TML-3625): `close()` with `closeRefusal: 'when-idle'` can close the driver while hooks still run. - [TML-3626](https://linear.app/prisma-company/issue/TML-3626): a queryable wrapper sees no ORM statements unless it also wraps `[activitySource]`. - [TML-3627](https://linear.app/prisma-company/issue/TML-3627): `intercept.onQuery` and `intercept.onExecute` are not raced against the abort signal. - [TML-3629](https://linear.app/prisma-company/issue/TML-3629): `startActivityOn` fails with a bare invariant on a queryable without an activity source. - [TML-3630](https://linear.app/prisma-company/issue/TML-3630): no test checks the `activitySource` symbol identity and the registry augmentation across published packages. - [TML-3631](https://linear.app/prisma-company/issue/TML-3631): a long transaction holds every finished statement in memory when any middleware implements `activityFinalized`. ## Out of scope `projects/middleware-intercept-and-cache/` belongs to an earlier project, TML-2143. It still describes the old hooks, and its close-out is [TML-3632](https://linear.app/prisma-company/issue/TML-3632). ## Alternatives considered - **Keep the manual QA scripts in `docs/`.** Rejected. They test one release's changes per slice, and the integration tests and the demo now cover the same behaviour. Git history keeps the reports. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added write-driven cache invalidation using `invalidateAnnotation`, with support for cache keys and metadata. - Invalidation occurs after successful completion or transaction commit; rolled-back writes do not invalidate entries. - Unknown outcomes still trigger invalidation, while store errors are logged according to documented behavior. - Added support across ORM and SQL-builder writes, with read operations rejecting the annotation. - **Documentation** - Updated runtime architecture and middleware guidance to describe activity lifecycle events and interceptors. - Clarified replacement and removal of legacy runtime hooks. - Added usage examples, limitations, migration guidance, and error-reference updates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: paulwer <paul@wer-ner.de> | 16 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
TML-3559: Middleware sees statements and transactions as activities, with lifecycle events and interceptors (#30703) ## At a glance A middleware now sees statements and transactions as activities, and every activity has the same three lifecycle events: ```ts import type { CrossFamilyMiddleware } from '@prisma/orm-framework/components/runtime'; export const slowStatements: CrossFamilyMiddleware = { name: 'slow-statements', async activityEnded(activity, result, ctx) { if (activity.kind === 'statement' && result.latencyMs > 200) { ctx.log.warn({ event: 'slow-statement', activityId: activity.id, latencyMs: result.latencyMs }); } }, async activityFinalized(activity, result, ctx) { if (activity.kind === 'transaction') ctx.log.info({ event: 'transaction', outcome: result.outcome }); }, }; ``` - `activityStarted`, `activityEnded` (`succeeded`, `failed` or `stopped`) and `activityFinalized` (`committed`, `rolled-back` or `unknown`) fire for every statement and every transaction. - `activityFinalized` fires once the activity's effects are final: right after the end outside a transaction, and when the outermost transaction ends inside one. - The hooks that change or stop a statement are interceptors: `beforeCompile`, `beforeEncode`, `intercept.onQuery`, `intercept.onExecute` and `onRow`. This is the first slice of [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md). It closes [TML-3559](https://linear.app/prisma-company/issue/TML-3559). ## What changed for middleware authors | Before | After | |---|---| | `beforeQuery`, `beforeExecute` | `beforeEncode` (same position and arguments) | | `interceptQuery`, `interceptExecute` | `intercept.onQuery`, `intercept.onExecute` | | `onRow` | `onRow` | | `afterQuery`, `afterExecute` | `activityEnded` on a statement | | `afterTransaction` | `activityFinalized` on a statement | | `RuntimeMiddlewareContext` | `ActivityContext`, one per activity, with `parent`, `state(key)` and `inTransaction()` | | `ctx.planExecutionId` | `activity.id` | The old hooks and types are removed, with no aliases. Upgrade instructions for applications and for extension authors are in `upgrade-instructions/pending/statement-and-transaction-activities/`. ## Rules worth knowing - **Every started statement ends and is finalized exactly once**, including one that fails before it runs (a lint or budget in `beforeEncode`) and one whose caller stops reading. `afterQuery` never fired in those two cases. - **Inside a transaction**, statements are finalized when the transaction ends, children first in start order, then the transaction. A statement still streaming at that moment is finalized when it ends, with the transaction's outcome. `commit()` and `rollback()` resolve after the hooks run. - **A statement sent on a transaction's connection** while the transaction is open is that transaction's child, because the database runs it in the transaction's session. - **Errors**: an error from `activityEnded` fails the activity; one from `activityFinalized` is logged and swallowed. A transaction the database has already settled keeps its outcome: after a resolved `COMMIT`, the caller gets the hook's error and nothing rolls back; after a resolved `ROLLBACK`, the caller gets its original error and the hook's error is logged. - **No cost when unused**: when no middleware implements `activityFinalized`, a transaction keeps no finished statements in memory. ## Behaviour changes users will notice - **The observed-row budget now runs.** `budgets()` counted rows under one plan object and looked them up under another, so the row check never fired. It now does, and follows `severities.rowCount` (`'error'` by default throws `BUDGET.ROWS_EXCEEDED`). - **`withTransaction` no longer leaks a connection** when the transaction cannot start. It releases the connection when a hook refused the start before `BEGIN`, and destroys it otherwise. - `RUNTIME.ABORTED` reports the phase `beforeEncode` instead of `beforeQuery` or `beforeExecute`. ## Docs The runtime subsystem doc describes activities, lifecycle events, interceptors and the context. ADR 271 records the rules this slice settled. ADR 160, ADR 220 and ADR 260 carry notes pointing to it. The glossary defines activity, lifecycle event, interceptor and finalized. The `prisma-8` skill references, the runtime and cache READMEs, and the examples are updated. ## How it was checked - Package tests: framework-components 1057, SQL runtime 461, SQL ORM client 1480, Mongo runtime 173, cache 109, Supabase 110, Postgres 377, SQLite 59. - Each touched integration and end-to-end test file, run on its own. - `pnpm build`, `typecheck`, `lint`, `lint:deps`, `lint:casts`, `lint:throws`, `lint:docs` and `check:upgrade-coverage`. - The upgrade instructions were applied to the code on `main` and reproduce this branch's example and extension code. ## Alternatives considered - **Keep the old hooks beside the new ones for a release.** Rejected: the release-candidate period is when the interface can change, and two interfaces would double every runner. - **One wrapping hook with a `next()` to call.** Rejected in ADR 271: it mixes observing with changing. - **Tag error objects to tell a hook error from a failed `COMMIT`.** Rejected during review: it only taught one caller, and a hook that throws a string cannot be tagged. The transaction records what the database did instead, so every caller behaves the same. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added activity lifecycle events for statements and transactions, with per-activity context, state, and finalization outcomes. - Added unified query and execution interception, plus transaction-aware statement tracking and annotation access. - Row budgets can evaluate observed query results; cache middleware manages in-progress results through activity state. - **Bug Fixes** - Preserved the original operation error when rollback fails after rollback has completed. - **Documentation** - Updated runtime, middleware, extension, and upgrade guides to explain the lifecycle and migration steps. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 23 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 | |
TML-3559: Middleware sees statements and transactions as activities, with lifecycle events and interceptors (#30703) ## At a glance A middleware now sees statements and transactions as activities, and every activity has the same three lifecycle events: ```ts import type { CrossFamilyMiddleware } from '@prisma/orm-framework/components/runtime'; export const slowStatements: CrossFamilyMiddleware = { name: 'slow-statements', async activityEnded(activity, result, ctx) { if (activity.kind === 'statement' && result.latencyMs > 200) { ctx.log.warn({ event: 'slow-statement', activityId: activity.id, latencyMs: result.latencyMs }); } }, async activityFinalized(activity, result, ctx) { if (activity.kind === 'transaction') ctx.log.info({ event: 'transaction', outcome: result.outcome }); }, }; ``` - `activityStarted`, `activityEnded` (`succeeded`, `failed` or `stopped`) and `activityFinalized` (`committed`, `rolled-back` or `unknown`) fire for every statement and every transaction. - `activityFinalized` fires once the activity's effects are final: right after the end outside a transaction, and when the outermost transaction ends inside one. - The hooks that change or stop a statement are interceptors: `beforeCompile`, `beforeEncode`, `intercept.onQuery`, `intercept.onExecute` and `onRow`. This is the first slice of [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md). It closes [TML-3559](https://linear.app/prisma-company/issue/TML-3559). ## What changed for middleware authors | Before | After | |---|---| | `beforeQuery`, `beforeExecute` | `beforeEncode` (same position and arguments) | | `interceptQuery`, `interceptExecute` | `intercept.onQuery`, `intercept.onExecute` | | `onRow` | `onRow` | | `afterQuery`, `afterExecute` | `activityEnded` on a statement | | `afterTransaction` | `activityFinalized` on a statement | | `RuntimeMiddlewareContext` | `ActivityContext`, one per activity, with `parent`, `state(key)` and `inTransaction()` | | `ctx.planExecutionId` | `activity.id` | The old hooks and types are removed, with no aliases. Upgrade instructions for applications and for extension authors are in `upgrade-instructions/pending/statement-and-transaction-activities/`. ## Rules worth knowing - **Every started statement ends and is finalized exactly once**, including one that fails before it runs (a lint or budget in `beforeEncode`) and one whose caller stops reading. `afterQuery` never fired in those two cases. - **Inside a transaction**, statements are finalized when the transaction ends, children first in start order, then the transaction. A statement still streaming at that moment is finalized when it ends, with the transaction's outcome. `commit()` and `rollback()` resolve after the hooks run. - **A statement sent on a transaction's connection** while the transaction is open is that transaction's child, because the database runs it in the transaction's session. - **Errors**: an error from `activityEnded` fails the activity; one from `activityFinalized` is logged and swallowed. A transaction the database has already settled keeps its outcome: after a resolved `COMMIT`, the caller gets the hook's error and nothing rolls back; after a resolved `ROLLBACK`, the caller gets its original error and the hook's error is logged. - **No cost when unused**: when no middleware implements `activityFinalized`, a transaction keeps no finished statements in memory. ## Behaviour changes users will notice - **The observed-row budget now runs.** `budgets()` counted rows under one plan object and looked them up under another, so the row check never fired. It now does, and follows `severities.rowCount` (`'error'` by default throws `BUDGET.ROWS_EXCEEDED`). - **`withTransaction` no longer leaks a connection** when the transaction cannot start. It releases the connection when a hook refused the start before `BEGIN`, and destroys it otherwise. - `RUNTIME.ABORTED` reports the phase `beforeEncode` instead of `beforeQuery` or `beforeExecute`. ## Docs The runtime subsystem doc describes activities, lifecycle events, interceptors and the context. ADR 271 records the rules this slice settled. ADR 160, ADR 220 and ADR 260 carry notes pointing to it. The glossary defines activity, lifecycle event, interceptor and finalized. The `prisma-8` skill references, the runtime and cache READMEs, and the examples are updated. ## How it was checked - Package tests: framework-components 1057, SQL runtime 461, SQL ORM client 1480, Mongo runtime 173, cache 109, Supabase 110, Postgres 377, SQLite 59. - Each touched integration and end-to-end test file, run on its own. - `pnpm build`, `typecheck`, `lint`, `lint:deps`, `lint:casts`, `lint:throws`, `lint:docs` and `check:upgrade-coverage`. - The upgrade instructions were applied to the code on `main` and reproduce this branch's example and extension code. ## Alternatives considered - **Keep the old hooks beside the new ones for a release.** Rejected: the release-candidate period is when the interface can change, and two interfaces would double every runner. - **One wrapping hook with a `next()` to call.** Rejected in ADR 271: it mixes observing with changing. - **Tag error objects to tell a hook error from a failed `COMMIT`.** Rejected during review: it only taught one caller, and a hook that throws a string cannot be tagged. The transaction records what the database did instead, so every caller behaves the same. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added activity lifecycle events for statements and transactions, with per-activity context, state, and finalization outcomes. - Added unified query and execution interception, plus transaction-aware statement tracking and annotation access. - Row budgets can evaluate observed query results; cache middleware manages in-progress results through activity state. - **Bug Fixes** - Preserved the original operation error when rollback fails after rollback has completed. - **Documentation** - Updated runtime, middleware, extension, and upgrade guides to explain the lifecycle and migration steps. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 23 天前 | |
TML-3691: A caller of a cache store passes only the fields it needs (#30822) ```ts const store = createInMemoryCacheStore(); // Before: both failed to compile without `meta: undefined` const entry = await store.get({ key: 'user-1' }); await store.unset({ keys: ['user-1'] }); ``` ## The change `CacheStore.get` and `CacheStore.unset` now take optional fields. Code that calls a store directly passes only what it needs. The README tells users to call the store directly in two cases: to fill the cache ahead of time (`get`, then `set`), and to invalidate through `unset`. Community contributor paulwer reported the problem on Discord. The old shape came from [required-key-undefined-fields.mdc](.agents/rules/required-key-undefined-fields.mdc). That rule covers internal construction inputs, and says user-facing types use optional keys. `CacheStore` is a public interface that users both implement and call. Nothing breaks: - Stores written with the old required fields still fit, because `get` and `unset` are methods. A type test keeps one such store. - The middleware still passes both fields on every call. ## Related changes in this PR - **Omitted means undefined.** The store rules now say a field left out of `get` or `unset` means the same as `undefined`, and a store must not tell the two apart. This is in the doc comment, both READMEs and ADR 266. A test shows the default store treats them alike. - **`invalidate` and `cacheAnnotation` accept `undefined`.** `InvalidationTarget` and `CacheAnnotationOptions` had optional fields without `| undefined`. With `exactOptionalPropertyTypes`, `cache.invalidate({ keys: maybeKeys })` failed to compile when `maybeKeys` could be `undefined`. They now take `| undefined`. - **Upgrade declaration.** [upgrade-instructions/pending/cache-store-optional-fields](upgrade-instructions/pending/cache-store-optional-fields) declares no consumer action. Closes [TML-3691](https://linear.app/prisma-company/issue/TML-3691). Agent: rembrandt-66 --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 15 天前 | |
TML-3691: A caller of a cache store passes only the fields it needs (#30822) ```ts const store = createInMemoryCacheStore(); // Before: both failed to compile without `meta: undefined` const entry = await store.get({ key: 'user-1' }); await store.unset({ keys: ['user-1'] }); ``` ## The change `CacheStore.get` and `CacheStore.unset` now take optional fields. Code that calls a store directly passes only what it needs. The README tells users to call the store directly in two cases: to fill the cache ahead of time (`get`, then `set`), and to invalidate through `unset`. Community contributor paulwer reported the problem on Discord. The old shape came from [required-key-undefined-fields.mdc](.agents/rules/required-key-undefined-fields.mdc). That rule covers internal construction inputs, and says user-facing types use optional keys. `CacheStore` is a public interface that users both implement and call. Nothing breaks: - Stores written with the old required fields still fit, because `get` and `unset` are methods. A type test keeps one such store. - The middleware still passes both fields on every call. ## Related changes in this PR - **Omitted means undefined.** The store rules now say a field left out of `get` or `unset` means the same as `undefined`, and a store must not tell the two apart. This is in the doc comment, both READMEs and ADR 266. A test shows the default store treats them alike. - **`invalidate` and `cacheAnnotation` accept `undefined`.** `InvalidationTarget` and `CacheAnnotationOptions` had optional fields without `| undefined`. With `exactOptionalPropertyTypes`, `cache.invalidate({ keys: maybeKeys })` failed to compile when `maybeKeys` could be `undefined`. They now take `| undefined`. - **Upgrade declaration.** [upgrade-instructions/pending/cache-store-optional-fields](upgrade-instructions/pending/cache-store-optional-fields) declares no consumer action. Closes [TML-3691](https://linear.app/prisma-company/issue/TML-3691). Agent: rembrandt-66 --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 15 天前 | |
Activity tree close-out: the project's reasoning is in ADR 271, current docs describe the new middleware interface, and the project folders are deleted (#30775) ## What this does > Builds on prisma/orm#30770 (slice 4, approved, auto-merge on). Until it merges, this diff also shows its commits; review the last three commits only. This closes the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). All four slices have merged: - [TML-3559](https://linear.app/prisma-company/issue/TML-3559): statements and transactions are activities. - [TML-3560](https://linear.app/prisma-company/issue/TML-3560): clients start their own activities. - [TML-3561](https://linear.app/prisma-company/issue/TML-3561): every ORM terminal method is one `orm-call` activity, and the cache caches whole ORM calls. - [TML-3400](https://linear.app/prisma-company/issue/TML-3400): a write annotated with `invalidateAnnotation` invalidates once it has committed. The pull request changes docs only, apart from one renamed test variable. ## Changes - **Project folders deleted.** `projects/activity-tree/` and `projects/cache-invalidation-on-write/` are gone. No file outside them linked to them. - **ADR 271 holds the remaining reasoning.** From the project's design document it gains: - the rule that the tree grows as the code runs; - the rejected alternatives the ADR did not yet list; - updated wording for what has shipped. - **Current docs describe the new middleware interface.** `ARCHITECTURE.md`, the Query Lanes subsystem doc and ADR 266 now name the lifecycle events (`activityStarted`, `activityEnded`, `activityFinalized`) and the interceptors (`beforeCompile`, `beforeEncode`, `intercept.onQuery`, `intercept.onExecute`, `intercept.onActivity`, `onRow`) instead of the removed per-statement hooks. - **Older ADRs point to ADR 271.** - ADR 014, which defined the old hook API, is superseded by ADR 271. - ADR 215 is amended by ADR 271. Its decision still stands; only its hook names changed. - ADRs that mention a removed hook in passing open with a one-line note. ## Project definition of done Every item passes. Two reviewers verified each one independently against code, tests and docs. | Item | How it is shown | |---|---| | An annotated ORM `update()` invalidates only after the commit, inside and outside `db.transaction(fn)`, and a rollback leaves the entry | `test/integration/test/cross-package/middleware-cache-write-invalidation.test.ts` | | An annotated ORM read served from the cache sends no statement, and is never served inside a transaction or on a Supabase role session | `middleware-cache-orm.test.ts`, the Supabase role-binding test | | Writes with relation callbacks and multi-table-inheritance creates invalidate | write-invalidation test file | | Only the new middleware interface is used | code; this PR's doc and ADR changes | | ADR 160 and ADR 260 carry supersession notes; the docs describe what shipped | ADRs, runtime subsystem doc, `prisma-8` skill, cache README | | Both project folders are deleted | this PR | ## Known gaps, now Linear tickets These were the project's deferred items. Each existed before the project or is outside its scope: - [TML-3624](https://linear.app/prisma-company/issue/TML-3624): a dropped row iterator never ends its statement or ORM call, and `close()` then waits for ever. - [TML-3625](https://linear.app/prisma-company/issue/TML-3625): `close()` with `closeRefusal: 'when-idle'` can close the driver while hooks still run. - [TML-3626](https://linear.app/prisma-company/issue/TML-3626): a queryable wrapper sees no ORM statements unless it also wraps `[activitySource]`. - [TML-3627](https://linear.app/prisma-company/issue/TML-3627): `intercept.onQuery` and `intercept.onExecute` are not raced against the abort signal. - [TML-3629](https://linear.app/prisma-company/issue/TML-3629): `startActivityOn` fails with a bare invariant on a queryable without an activity source. - [TML-3630](https://linear.app/prisma-company/issue/TML-3630): no test checks the `activitySource` symbol identity and the registry augmentation across published packages. - [TML-3631](https://linear.app/prisma-company/issue/TML-3631): a long transaction holds every finished statement in memory when any middleware implements `activityFinalized`. ## Out of scope `projects/middleware-intercept-and-cache/` belongs to an earlier project, TML-2143. It still describes the old hooks, and its close-out is [TML-3632](https://linear.app/prisma-company/issue/TML-3632). ## Alternatives considered - **Keep the manual QA scripts in `docs/`.** Rejected. They test one release's changes per slice, and the integration tests and the demo now cover the same behaviour. Git history keeps the reports. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added write-driven cache invalidation using `invalidateAnnotation`, with support for cache keys and metadata. - Invalidation occurs after successful completion or transaction commit; rolled-back writes do not invalidate entries. - Unknown outcomes still trigger invalidation, while store errors are logged according to documented behavior. - Added support across ORM and SQL-builder writes, with read operations rejecting the annotation. - **Documentation** - Updated runtime architecture and middleware guidance to describe activity lifecycle events and interceptors. - Clarified replacement and removal of legacy runtime hooks. - Added usage examples, limitations, migration guidance, and error-reference updates. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: paulwer <paul@wer-ner.de> | 16 天前 | |
TML-3561: Every SQL ORM terminal method runs as one orm-call activity, and the cache caches whole ORM calls (#30749) ## At a glance ```ts // A cached ORM read. On a hit the ORM sends no statement at all. const user = await db.orm.public.User.first({ id: 1 }, (m) => m.annotate(cacheAnnotation({ key: 'user-1' })), ); // Middleware sees one orm-call activity per terminal method, with the user's annotations on it. const audit: SqlMiddleware = { name: 'audit', familyId: 'sql', async activityStarted(activity) { if (activity.kind !== 'orm-call') return; const actor = auditAnnotation.read(activity); if (actor) log(actor); }, }; ``` Every SQL ORM terminal method now runs as one **`orm-call` activity**. Its statements, its own transaction and the read-back after a nested write are children of that activity. The user's annotations are on the activity, not on the statements. The cache middleware uses this to cache an annotated ORM read as a whole. This is slice 3 of the project [Runtime middleware sees a tree of activities](https://linear.app/prisma-company/project/runtime-middleware-sees-a-tree-of-activities-c691d141b3b6). Closes [TML-3561](https://linear.app/prisma-company/issue/TML-3561). ## Background [ADR 271](docs/architecture%20docs/adrs/ADR%20271%20-%20The%20runtime%20middleware%20observes%20a%20tree%20of%20activities%2C%20each%20started%2C%20ended%20and%20finalized.md) gives runtime middleware a tree of activities. Every activity has the same lifecycle: `activityStarted`, `activityEnded` and `activityFinalized`. The first PR made every statement and transaction an activity. The second let a client start its own activity with `startActivityOn`, and let middleware answer it with `intercept.onActivity`. Until now, nothing in the product started one. One ORM call often sends several statements. A single-row `update()` is a `SELECT` and an `UPDATE` inside a transaction the ORM opens. Middleware saw three unrelated activities, and the cache could only cache statements. It could not skip the call. ## The decision: each terminal method is one activity **Which methods.** `all`, `first`, `firstOrThrow`, `aggregate`, grouped `aggregate`, `create`, `createAll`, `createAndCount`, `upsert`, `update`, `updateAll`, `updateAndCount`, `delete`, `deleteAll`, `deleteAndCount`, and each execution of a prepared ORM collection. **How it runs.** The ORM starts the activity on the queryable it holds and sends every statement through the handle. Its own transaction is `handle.transaction()`. Inside `db.transaction(fn)` the call is a child of the user's transaction and opens none of its own. **How it ends.** The call ends with the value the method returns: model rows, a row, `null`, an aggregate or a count. A failed call ends `failed`. A streaming method such as `all()` ends when the caller reads to the end (`succeeded`), stops early (`stopped`) or the stream fails (`failed`). It collects its rows for the result only when the call carries annotations, so an unannotated `all()` still holds no rows. **When middleware answers it.** If `intercept.onActivity` returns a result, the ORM returns it and runs nothing. A value of the wrong shape fails with `ORM.INTERCEPTED_RESULT_INVALID`. **Annotations.** User annotations live on the call only. Statement plans keep only the reserved keys the ORM sets itself. **Content hashes.** - A read call's hash is a digest of `orm-call`, the terminal method and the content hash of the plan it sends first. - A prepared collection hashes each execution with its bound parameters. The runtime gains `BoundPreparedStatement` and `HashablePlan` so `planContentHashOn` can hash one. - A write call's hash is a digest of `orm-call`, the method and the model. Writes are never cached. - A client activity's content hash is now a function, computed only when middleware asks for it, once per activity. **A queryable without an activity source** fails with `ORM.ACTIVITY_SOURCE_MISSING` rather than dropping the user's annotations silently. Every queryable from `postgres()`, `sqlite()` and `supabase()` has one. Only a hand-built `{ query, execute }` object lacks it. **`close()`.** On a client that closes at once, an ORM call that resolves to one value starts when it is called. `close()` waits for it, including the activities started beneath it. ## The cache caches ORM calls - **Which calls.** For a client activity with a `cacheAnnotation` in runtime scope and outside a transaction, the cache looks up `annotation.key ?? await activity.contentHash()`. - **Hits and misses.** A hit returns the stored result through `intercept.onActivity`, so no statement runs. A miss stores the call's result in `activityEnded` when the call succeeded. - **What it never caches.** Calls inside `db.transaction(fn)`, on a held connection or on a Supabase role session are neither served nor stored. The statement path now also skips statements inside a transaction. - **What it stores.** The cache stores what the call returned: the decoded value the user gets. Stored values are tagged `{ kind: 'statement-rows', rows }` or `{ kind: 'call-result', result }`. A value of the other kind under a key is a miss. - **Store obligations.** Serializing is the store's job, as [ADR 266](docs/architecture%20docs/adrs/ADR%20266%20-%20The%20cache%20middleware%20passes%20data%20to%20its%20store%2C%20and%20the%20store%20decides%20how%20to%20cache.md) already says. ADR 266 now adds that a store must give back an equivalent value. A call result holds `Date`, `bigint`, Temporal values and extension class instances, so a serializing store needs a serializer the application supplies. The README's Redis example shows one. A deploy that changes what a codec returns needs a new key prefix for a shared store. - **The default in-memory store** copies the plain structure of a call result (objects, arrays, `Map`, `Set`, `Date`, typed arrays) on `set` and `get`, and shares every other value, such as class instances, by reference. Statement rows are stored as before. - **`deriveKey`** applies to builder statements only. An ORM call without a `key` is keyed by its content hash. ## Cost with no middleware Each ORM call now creates one activity. This was measured with a driver that answers at once, comparing CPU time against `main` in one process. The machine was under heavy load, so absolute times varied between runs; the difference was steadier: | Call | Extra CPU time per call | |---|---| | `first()` | 3 to 6 µs | | `all()` of 50 rows | 7 to 8 µs | A real database round trip takes 200 µs or more, so this is well under the runtime's 5% overhead target. The first version cost about twice as much. Hook dispatch, activity ending and row streaming now avoid most of the extra promises. ## For users and extension authors Upgrade instructions are in `upgrade-instructions/pending/orm-call-activities/`. Breaking changes: - A middleware that reads an ORM annotation from a statement plan finds nothing. Read it from the `orm-call` activity. - `deriveKey` no longer applies to ORM reads. An app that separates tenants with `deriveKey` must pass a tenant-prefixed `key` on annotated ORM reads, or one tenant's cached read can be served to another. - A cache store holds `CachedValue`, not `CachedRows`. `createInMemoryCacheStore` takes only the meta type. - `orm()` given a hand-built `{ query, execute }` object fails with `ORM.ACTIVITY_SOURCE_MISSING`. - A pending ORM call at `close()` now completes instead of rejecting with "Runtime is closed". - A wrapper around a queryable must also wrap its `[activitySource]` member, or it does not see ORM statements. ## Testing - **Package tests.** Unit tests cover each terminal's activity, children, result, intercepted path, content hash and annotations, plus the streaming lifecycle with overlapping `next()` and `return()` calls. - **Postgres integration tests.** - An annotated ORM read served from the cache sends no statement. - It is not cached inside `withTransaction` or the facade's `db.transaction(fn)`. - A Supabase role-session read never touches the store. - A serializing store round-trips a `Date` and a `bigint`. - **Manual QA.** The demo, a tracer middleware written from the upgrade instructions, and the cache scenarios, recorded in `projects/activity-tree/manual-qa-reports/2026-10-16-qa-slice-3.md`. 28 of 29 checks passed. The one failure was the overhead, fixed in this PR. ## Known gaps These are recorded in `projects/activity-tree/deferred.md`: - A dropped `all()` iterator leaves its call open, so `close()` waits for ever. - A queryable wrapper that does not wrap `[activitySource]` misses ORM statements. ## Alternatives considered - **Cache the raw rows of the call's statements, keyed by the call.** Rejected. The call's result is the decoded value the ORM returns, and the cache would have to know how a client turns a call into statements. - **Let the client supply a serializer for its result.** Rejected. Storing values is the cache's and the store's concern, not the ORM's. - **Skip the activity when no middleware is registered.** Rejected, because middleware must see the same activities whatever else is registered. The remaining cost is the one activity per call. Agent: twain-20 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * ORM reads and writes now run as observable activities, allowing middleware to inspect, annotate, or intercept complete operations. * Cache middleware can store ORM call results as well as SQL statement rows, keeping the two value types distinct. * Activity content hashes are calculated on first request and reused thereafter. * **Behavior Changes** * ORM terminal operations require a runtime activity source; calls without one fail with a specific error. Intercepted results must match the operation’s expected shape. * Closing the database waits for in-progress ORM calls to finish. Cache reads and writes are bypassed within transactions. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io> | 17 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 17 天前 | ||
| 20 天前 | ||
| 20 天前 | ||
| 20 天前 | ||
| 16 天前 | ||
| 17 天前 | ||
| 17 天前 | ||
| 17 天前 | ||
| 16 天前 | ||
| 17 天前 | ||
| 23 天前 | ||
| 17 天前 | ||
| 23 天前 | ||
| 15 天前 | ||
| 15 天前 | ||
| 16 天前 | ||
| 17 天前 |