From bf9f7c0f0f1c02c984d59da01be90cc44b4999f0 Mon Sep 17 00:00:00 2001 From: Alexey Orlenko Date: Fri, 10 Apr 2026 17:01:57 +0200 Subject: [PATCH] docs: add transaction API related spec, plan and ADR --- ... streams use invalidation not buffering.md | 65 ++++++ projects/orm-client-transaction-api/plan.md | 88 +++++++ projects/orm-client-transaction-api/spec.md | 215 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 docs/architecture docs/adrs/ADR 187 - Transaction-scoped streams use invalidation not buffering.md create mode 100644 projects/orm-client-transaction-api/plan.md create mode 100644 projects/orm-client-transaction-api/spec.md diff --git a/docs/architecture docs/adrs/ADR 187 - Transaction-scoped streams use invalidation not buffering.md b/docs/architecture docs/adrs/ADR 187 - Transaction-scoped streams use invalidation not buffering.md new file mode 100644 index 000000000..eda1eb654 --- /dev/null +++ b/docs/architecture docs/adrs/ADR 187 - Transaction-scoped streams use invalidation not buffering.md @@ -0,0 +1,65 @@ +# ADR 187 — Transaction-scoped streams use invalidation, not buffering + +## At a glance + +When a user calls `db.transaction(callback)`, the callback receives a transaction context with `tx.execute(plan)` that returns `AsyncIterableResult` — the same lazy streaming type used outside transactions. Rather than eagerly buffering results inside transactions or constraining the return type, the transaction scope is **invalidated** on commit/rollback. Any attempt to consume an `AsyncIterableResult` after the transaction ends produces a clear, actionable error. + +## Context + +The callback-based transaction API provides `tx.orm`, `tx.sql`, and `tx.execute(plan)` bound to a single database connection. After the callback completes, the transaction commits (or rolls back on error) and the connection is released. + +`AsyncIterableResult` is lazy — rows are pulled on demand via `for await` or `.toArray()`. It also implements `PromiseLike`, so `await result` eagerly drains it. The concern: if a user returns an unconsumed `AsyncIterableResult` from the callback (especially wrapped in an object), it escapes the transaction scope. The connection is already released, so subsequent iteration would fail or read from a recycled connection. + +## Decision + +**Invalidate the transaction scope on commit/rollback.** The transaction-scoped `RuntimeQueryable` sets an `invalidated` flag after commit/rollback. Any `AsyncIterableResult` created by `tx.execute()` that is consumed after the transaction ends produces an error: + +*"Cannot read from a query result after the transaction has ended. Await the result or call .toArray() inside the transaction callback."* + +## Alternatives considered + +### Eager buffering inside transactions + +`tx.execute` would internally drain all rows to an array, then wrap them in a pre-materialized `AsyncIterableResult`. Same return type, no leak possible. + +**Rejected because:** it silently changes `execute()` semantics inside transactions (always eager) vs outside (lazy/streaming). This is surprising, increases memory pressure, and creates a behavioral difference that is invisible at the type level. Streaming inside transactions is not inherently wrong — the user may want to process a large result set row-by-row within the transaction without materializing all rows in memory. + +### Type-level prevention + +Constrain the callback's return type to exclude `AsyncIterableResult`: + +```typescript +type NoAsyncIterable = T extends AsyncIterable ? never : T; +transaction(fn: (tx: TxCtx) => PromiseLike>): Promise; +``` + +**Rejected because:** conditional types on generic return types interact poorly with inference — `R` may resolve to `never` instead of producing a useful error. Cannot catch `AsyncIterableResult` nested inside returned objects. Adds type complexity without runtime safety. + +### Runtime error before commit (tracking approach) + +Track every `AsyncIterableResult` created by the transaction. Before commit, check if any are unconsumed and throw. + +**Rejected because:** it is an error to have an unconsumed stream only if the user tries to read from it after the transaction — an unconsumed stream that is simply discarded is harmless. Throwing before commit would reject valid patterns where the user intentionally ignores a result. + +### Do nothing (document the pitfall) + +The direct return case (`return tx.execute(plan)`) is already safe because `PromiseLike` causes auto-drain. Only the "wrap in object" case is hazardous. + +**Rejected because:** `return { users: tx.execute(plan1), posts: tx.execute(plan2) }` is a natural and common pattern. Relying on documentation for a failure mode that produces confusing errors (connection gone, recycled connection) is insufficient. + +## Why this is safe: wire protocol analysis + +A separate concern was whether unconsumed result streams could hide database errors (e.g., constraint violations) within a transaction, allowing a COMMIT that should have been a ROLLBACK. Investigation confirmed this is not an issue: + +- **Not a wire protocol issue.** The Postgres wire protocol is push-oriented — the server sends `ErrorResponse` immediately. The delayed error surfacing observed in the Rust tokio-postgres driver was an architectural issue specific to that library's futures/polling model, not a protocol limitation. +- **node-postgres reads eagerly.** The `pg` library attaches `stream.on('data', ...)` to the socket. The event loop drains all server responses regardless of whether user code has consumed the result. +- **DML executes fully.** INSERT/UPDATE/DELETE (with or without RETURNING) executes atomically — cursors (DECLARE CURSOR) are SELECT/VALUES-only. Mutation errors are always surfaced. +- **Failed transaction state is server-side.** If any statement errors, the server marks the transaction as aborted. A subsequent COMMIT returns the command tag ROLLBACK regardless of whether the client read the original error. +- **Cursors defer execution, not errors.** With cursors, unfetched SELECT rows are genuinely never evaluated by the server — this is not error suppression but incomplete execution. For pure reads, this is harmless. For SELECT FOR UPDATE, locks are acquired per-FETCH, but this is only meaningful if the user reads the results to act on them within the same transaction — partial consumption of a FOR UPDATE query is already an application logic bug independent of the streaming API. + +## Consequences + +- `execute()` has consistent lazy semantics everywhere — no hidden behavioral difference inside transactions. +- Users who accidentally leak an `AsyncIterableResult` get a clear error at the point of misuse (when they try to read from it), not a confusing connection error. +- No type-level complexity or inference issues on the `transaction` method signature. +- The `PromiseLike` implementation on `AsyncIterableResult` means `await db.transaction((tx) => tx.execute(plan))` drains eagerly and works correctly — the most common pattern is safe by default. diff --git a/projects/orm-client-transaction-api/plan.md b/projects/orm-client-transaction-api/plan.md new file mode 100644 index 000000000..28b7b6e31 --- /dev/null +++ b/projects/orm-client-transaction-api/plan.md @@ -0,0 +1,88 @@ +# ORM Client Transaction API — Plan + +## Summary + +Add `db.transaction(callback)` to the target client (e.g., `PostgresClient`) so users can execute ORM and SQL builder operations atomically within a single database transaction. The callback receives a transaction context with `tx.orm`, `tx.sql`, and `tx.execute`, all bound to the same connection. Commit on success, rollback on throw. The implementation lives as a reusable `withTransaction` helper in `sql-runtime`, exposed by target clients. + +**Spec:** `projects/orm-client-transaction-api/spec.md` + +## Collaborators + +| Role | Person/Team | Context | +|---|---|---| +| Maker | Alexey | Drives execution | + +## Milestones + +### Milestone 1: Transaction runtime plumbing + +Implement the core transaction lifecycle in `sql-runtime` and expose it on `PostgresClient`. No ORM integration yet — only `tx.execute(plan)` works at this stage. + +**Tasks:** + +- [ ] **1.1** Add `TransactionContext` interface and `withTransaction` helper to `sql-runtime` (`packages/2-sql/5-runtime/src/sql-runtime.ts`). The helper acquires a connection from the `Runtime`, calls `beginTransaction()`, runs the callback with a `RuntimeQueryable` scoped to the transaction, commits on success, rolls back on throw, and releases the connection in `finally`. Add an `invalidated` flag to the transaction-scoped queryable that is set after commit/rollback — any subsequent `execute()` call throws a clear error per ADR 187. +- [ ] **1.2** Export `TransactionContext`, `withTransaction`, and related types from `sql-runtime` exports (`packages/2-sql/5-runtime/src/exports/index.ts`). Also export `RuntimeConnection` and `RuntimeTransaction` interfaces which are currently internal. +- [ ] **1.3** Add `transaction(fn: (tx: TransactionContext) => PromiseLike): Promise` to the `PostgresClient` interface and implementation (`packages/3-extensions/postgres/src/runtime/postgres.ts`). The implementation delegates to `withTransaction`, passing the runtime (lazy-initializing via `getRuntime()` like existing methods). `TransactionContext` exposes `execute` but NOT `transaction` (no nesting). +- [ ] **1.4** Wire `tx.sql` — create the `Db` proxy bound to the transaction's execute. The SQL builder (`sql()` from `sql-builder`) is stateless and only needs an `ExecutionContext`; the transaction context provides `tx.execute(plan)` for running built plans. Expose `tx.sql` on the transaction context so users can build queries against the same table proxies. +- [ ] **1.5** Unit tests for `withTransaction` lifecycle: successful commit, rollback on throw, connection release on both paths, error propagation, return value forwarding, COMMIT failure propagation. Test the invalidation flag — `execute()` after commit/rollback throws with actionable message. +- [ ] **1.6** Integration test against Postgres: two INSERTs in a transaction are both visible after commit. A throw after the first INSERT rolls back both — neither is visible. + +### Milestone 2: ORM integration + +Wire `tx.orm` so ORM collections inside the transaction use the transaction's connection, and the mutation executor's `withMutationScope` reuses it instead of acquiring a new transaction. + +**Tasks:** + +- [ ] **2.1** Add `tx.orm` to `TransactionContext`. Create an ORM client (`orm()`) bound to the transaction's `RuntimeQueryable`. The key: the `RuntimeQueryable` passed to `orm()` must route `execute()` through the transaction scope and must NOT expose `connection()` or `transaction()` — when `withMutationScope` checks `typeof runtime.transaction === 'function'`, it must get `false` (or the method must be absent) so it uses the scope directly rather than starting a nested transaction. +- [ ] **2.2** Verify that `withMutationScope` in `mutation-executor.ts` correctly falls through to `acquireRuntimeScope` when the runtime has no `transaction` method — and that `acquireRuntimeScope` returns the transaction-scoped execute. Read the code paths and add a targeted unit test confirming nested creates within a transaction reuse the transaction scope. +- [ ] **2.3** Integration test: ORM `.create()` with nested relation mutations inside `db.transaction()` — verify all rows are created atomically and a throw rolls back everything including nested creates. +- [ ] **2.4** Integration test: ORM reads inside `transaction` see writes made earlier in the same transaction (read-your-own-writes within tx). + +### Milestone 3: Type safety and edge cases + +Ensure compile-time and runtime safety for the transaction API. + +**Tasks:** + +- [ ] **3.1** Type-level test using vitest `expectTypeOf`: `tx.orm` has the same collection types as `db.orm`. `tx.sql` has the same table proxy types as `db.sql`. +- [ ] **3.2** Negative type test using vitest `expectTypeOf`: `tx` does NOT have a `transaction` property. Use `expectTypeOf().not.toHaveProperty('transaction')`. +- [ ] **3.3** Type-level test using vitest `expectTypeOf`: `db.transaction` correctly infers the callback return type — `db.transaction(async () => 42)` resolves to `Promise`. +- [ ] **3.4** Test: `await db.transaction((tx) => tx.execute(plan))` drains eagerly via `PromiseLike` and returns `Row[]` (the safe common case — `AsyncIterableResult.then()` triggers `.toArray()`). +- [ ] **3.5** Test: `AsyncIterableResult` created inside a transaction, consumed after commit, produces a clear error message mentioning `.toArray()`. +- [ ] **3.6** Test: calling `db.transaction()` before explicit `connect()` auto-connects lazily. +- [ ] **3.7** Test: sequential `db.transaction()` calls reuse pooled connections without leaking. + +### Milestone 4: Close-out + +- [ ] **4.1** Verify all acceptance criteria from `projects/orm-client-transaction-api/spec.md` are met. +- [ ] **4.2** Ensure ADR 187 is finalized and accurate. Migrate any other long-lived docs into `docs/`. +- [ ] **4.3** Strip repo-wide references to `projects/orm-client-transaction-api/**` (replace with canonical `docs/` links or remove). +- [ ] **4.4** Delete `projects/orm-client-transaction-api/`. + +## Test Coverage + +| Acceptance Criterion | Test Type | Task | Notes | +|---|---|---|---| +| `db.transaction(callback)` callable, returns `Promise` | Unit + Type | 1.5, 3.3 | | +| Callback receives context with `orm`, `sql`, `execute` | Unit | 1.5, 2.1 | | +| `tx.orm` has same collection types as `db.orm` | Type | 3.1 | | +| `tx.sql` has same table proxy types as `db.sql` | Type | 3.1 | | +| `tx.execute(plan)` runs against transaction connection | Unit + Integration | 1.5, 1.6 | | +| Successful callback → COMMIT, promise resolves with return value | Unit + Integration | 1.5, 1.6 | | +| Throwing callback → ROLLBACK, promise rejects with original error | Unit + Integration | 1.5, 1.6 | | +| Connection released after commit and rollback | Unit | 1.5 | | +| Sequential transactions reuse pool without leaking | Integration | 3.7 | | +| Two writes visible after commit | Integration | 1.6 | | +| Throw after first write rolls back all writes | Integration | 1.6 | | +| ORM operations use transaction connection (including nested mutations) | Integration | 2.2, 2.3 | | +| ORM reads see earlier writes in same tx | Integration | 2.4 | | +| `tx` has no `transaction` method (compile-time) | Type (negative) | 3.2 | | +| `db.transaction` infers callback return type | Type | 3.3 | | +| Escaped `AsyncIterableResult` produces clear error | Unit | 3.5 | ADR 187 | +| `await db.transaction((tx) => tx.execute(plan))` drains via `PromiseLike` | Unit | 3.4 | | +| `transaction` before `connect()` auto-connects | Integration | 3.6 | | +| COMMIT failure → promise rejects | Unit | 1.5 | Mock commit to throw | + +## Open Items + +None — all spec open questions are resolved. See spec for resolved decisions and design notes. diff --git a/projects/orm-client-transaction-api/spec.md b/projects/orm-client-transaction-api/spec.md new file mode 100644 index 000000000..23b9d7001 --- /dev/null +++ b/projects/orm-client-transaction-api/spec.md @@ -0,0 +1,215 @@ +# Summary + +Add a callback-based transaction API to the ORM client and SQL builder surfaces so users can execute multiple operations atomically within a single database transaction. The transaction commits on callback success and rolls back on error. + +# Description + +The ORM client (`db.orm`) and SQL builder (`db.sql`) currently execute each operation independently — there is no way for users to group multiple operations into an atomic unit. Transactions are used *internally* by the mutation executor for nested create/update mutations (`withMutationScope` in `mutation-executor.ts`), but this plumbing is not exposed to users. + +Users need transactions for common patterns: transferring funds between accounts, creating an entity and its audit log atomically, or performing conditional updates that must not interleave with concurrent writes. Without a transaction API, users must either drop to raw SQL with manual `BEGIN`/`COMMIT` or accept the risk of partial writes. + +The transaction API is a method on the target client (e.g., `PostgresClient`) that accepts a callback. The callback receives an object providing access to both the ORM client and the SQL builder, both bound to the same underlying database connection and transaction. If the callback completes successfully, the transaction commits. If the callback throws, the transaction rolls back and the error propagates to the caller. + +# Before / After + +## API surface + +**Before** — no way to group operations atomically: + +```typescript +const db = postgres({ contractJson, url }); + +// These execute on separate connections — not atomic +await db.orm.account.where({ id: fromId }).update({ balance: fromBalance - amount }); +await db.orm.account.where({ id: toId }).update({ balance: toBalance + amount }); +``` + +**After** — callback-based transaction: + +```typescript +const db = postgres({ contractJson, url }); + +await db.transaction(async (tx) => { + // Both operations execute on the same connection within a single transaction + await tx.orm.account.where({ id: fromId }).update({ balance: fromBalance - amount }); + await tx.orm.account.where({ id: toId }).update({ balance: toBalance + amount }); + + // SQL builder is also available, bound to the same transaction + const plan = tx.sql.from(tables.auditLog).insert({ action: 'transfer', amount }).build(); + await tx.execute(plan); +}); +// Transaction committed here. If any operation threw, everything rolled back. +``` + +# Requirements + +## Functional Requirements + +1. **`transaction` method on the target client.** `db.transaction(callback)` accepts an async callback and returns a `Promise` that resolves with the callback's return value on commit or rejects with the callback's error after rollback. + +2. **Transaction context object.** The callback receives a transaction context (`tx`) that provides: + - `tx.orm` — ORM client (same `Collection` API) bound to the transaction's connection. + - `tx.sql` — SQL builder (`Db`) bound to the transaction's connection. + - `tx.execute(plan)` — executes a built query plan against the transaction's connection. + +3. **Automatic lifecycle management.** The runtime acquires a connection, issues `BEGIN`, runs the callback, issues `COMMIT` on success or `ROLLBACK` on error, and releases the connection. Users never see `BEGIN`/`COMMIT`/`ROLLBACK`. + +4. **Error propagation.** If the callback throws, the transaction rolls back and the original error is re-thrown to the caller. If `ROLLBACK` itself fails, the rollback error wraps or accompanies the original error. + +5. **Return value forwarding.** `db.transaction(async (tx) => { ... return result; })` resolves to `result` after commit. + +6. **ORM nested mutations within transactions.** When the ORM's mutation executor (`withMutationScope`) runs inside a transaction context, it must reuse the transaction's connection rather than acquiring a new one. This means the `RuntimeQueryable` passed to the ORM inside `tx.orm` must route through the transaction scope. + +7. **Connection scoping.** The transaction holds a single connection for its duration. All `tx.orm` and `tx.sql` operations execute on that connection. + +## Non-Functional Requirements + +1. **No connection leaks.** If the callback hangs or the process crashes, the connection must be released (via `finally` semantics). The runtime must not accumulate unreleased connections from failed transactions. + +2. **Type safety.** `tx.orm` must have the same collection types as `db.orm`. `tx.sql` must have the same table types as `db.sql`. `tx.execute` must accept the same plan types as the runtime's `execute`. The `transaction` method must be generic over the callback's return type. + +3. **No nesting.** The transaction context must **not** expose `transaction` — calling `tx.transaction(...)` is a compile-time error (absent from the type) and, if bypassed, a runtime error. + +## Non-goals + +- **Isolation levels.** Default isolation level only (typically `READ COMMITTED` for Postgres). Configurable isolation is deferred. +- **Savepoints / nested transactions.** No `SAVEPOINT` support. Transactions do not nest. +- **Automatic retry on serialization failures.** No retry logic. Users who need retries wrap `transaction` in their own retry loop. +- **Interactive (explicit) transaction API.** No `db.begin()` returning a handle with `.commit()` / `.rollback()`. Callback-only. +- **Timeout / max duration.** No built-in transaction timeout. Deferred to a follow-up. +- **Document (Mongo) family.** SQL-only for now. Mongo transactions can follow the same pattern later. + +# Acceptance Criteria + +## Core API + +- [ ] `db.transaction(callback)` is callable on `PostgresClient` and returns `Promise` where `T` is the callback's return value. +- [ ] The callback receives a context object with `orm`, `sql`, and `execute` properties. +- [ ] `tx.orm` has the same collection types and API as `db.orm`. +- [ ] `tx.sql` has the same table proxy types as `db.sql`. +- [ ] `tx.execute(plan)` executes a query plan against the transaction's connection. + +## Lifecycle + +- [ ] A successful callback triggers `COMMIT` and the promise resolves with the return value. +- [ ] A throwing callback triggers `ROLLBACK` and the promise rejects with the original error. +- [ ] The connection is released after both commit and rollback paths. +- [ ] Multiple sequential transactions can reuse connections from the pool without leaking. + +## Atomicity + +- [ ] Two writes within `transaction` are both visible after commit (integration test against Postgres). +- [ ] A throw after the first write rolls back all writes — neither is visible (integration test). + +## ORM integration + +- [ ] ORM operations inside `transaction` (including nested mutation patterns like `create` with relation callbacks) execute on the transaction's connection, not a new one. +- [ ] ORM reads inside `transaction` see writes made earlier in the same transaction. + +## Type safety + +- [ ] `tx` does **not** have a `transaction` method (compile-time check via negative type test). +- [ ] `db.transaction` infers the callback return type correctly (type-level test). + +## Escaped result safety + +- [ ] An `AsyncIterableResult` created inside a transaction that is consumed after commit/rollback produces a clear error message. +- [ ] `await db.transaction((tx) => tx.execute(plan))` drains eagerly via `PromiseLike` and returns `Row[]` (the safe common case). + +## Edge cases + +- [ ] Calling `transaction` before `connect()` auto-connects lazily (same as `db.runtime()`). +- [ ] If the callback returns without throwing but `COMMIT` fails, the promise rejects with the commit error. + +# Other Considerations + +## Security + +No new security surface. Transactions use the same authenticated connection pool as regular queries. No user-supplied SQL is introduced. + +## Observability + +**Assumption:** Transaction begin/commit/rollback are logged through the existing runtime telemetry pipeline. No new telemetry events are added in this project — the existing `execute` telemetry captures individual statements, and the driver-level `BEGIN`/`COMMIT`/`ROLLBACK` commands flow through the same path. + +## Cost + +No cost impact. Transactions use the same connection pool. Holding a connection for the duration of the callback is inherent to the feature. + +# References + +- Existing internal transaction plumbing: `packages/3-extensions/sql-orm-client/src/mutation-executor.ts` (`withMutationScope`) +- Runtime transaction interfaces: `packages/2-sql/5-runtime/src/sql-runtime.ts` (`Runtime`, `RuntimeConnection`, `RuntimeTransaction`) +- Postgres driver implementation: `packages/3-targets/7-drivers/postgres/src/postgres-driver.ts` (`PostgresConnectionImpl`, `PostgresTransactionImpl`) +- Target client entry point: `packages/3-extensions/postgres/src/runtime/postgres.ts` (`PostgresClient`) +- ORM client factory: `packages/3-extensions/sql-orm-client/src/orm.ts` (`orm()`) +- SQL builder factory: `packages/2-sql/4-lanes/sql-builder/src/runtime/sql.ts` (`sql()`) +- [ADR 187 — Transaction-scoped streams use invalidation, not buffering](../../docs/architecture%20docs/adrs/ADR%20187%20-%20Transaction-scoped%20streams%20use%20invalidation%20not%20buffering.md) + +# Open Questions + +1. **~~Method name~~** — Resolved: `transaction` (not `$transaction`). Since the method lives on `db`, not on the ORM client directly, there is no model-name collision risk. + +2. **~~Pre-connect behavior~~** — Resolved: auto-connects lazily, same as `db.runtime()`. + +3. **~~Implementation location~~** — Resolved: reusable `withTransaction` helper in `sql-runtime` (`packages/2-sql/5-runtime/`). Target clients expose it as `db.transaction(...)` and can override with a custom implementation if needed. + +4. **~~AsyncIterableResult leaking out of the callback~~** — Resolved: the transaction-scoped `RuntimeQueryable` is invalidated on commit/rollback. Any `AsyncIterableResult` consumed after the transaction ends produces a clear, actionable error. Direct returns are already safe because `AsyncIterableResult` implements `PromiseLike`, so `await` drains it. No eager buffering, no type gymnastics — `execute()` semantics stay consistent inside and outside transactions. See the Design Notes section below for the full analysis. + +# Design Notes: AsyncIterableResult and transaction scope + +## The safe case + +`AsyncIterableResult` implements `PromiseLike` (see `packages/1-framework/4-runtime/runtime-executor/src/async-iterable-result.ts`). When the callback is `async` and returns an `AsyncIterableResult`, the implicit `await` on the return value triggers `.then()` → `.toArray()`, draining the iterator before the callback resolves. So the common pattern works correctly: + +```typescript +// SAFE — PromiseLike drains via .then() before callback resolves +const posts = await db.transaction((tx) => tx.orm.posts.all()); +``` + +Similarly, `await tx.execute(plan)` inside the callback drains eagerly via `PromiseLike`. + +## The hazardous case + +If the user wraps an unconsumed `AsyncIterableResult` in a return object, it escapes the transaction scope: + +```typescript +const result = await db.transaction(async (tx) => { + return { + users: tx.execute(userPlan), // AsyncIterableResult — not awaited + posts: tx.execute(postPlan), // AsyncIterableResult — not awaited + }; +}); +// Transaction committed, connection released +for await (const user of result.users) { /* connection gone */ } +``` + +## Design decision: fail clearly on post-transaction reads + +Rather than silently buffering all results or preventing this at the type level, the transaction context should **invalidate its connection scope on commit/rollback**. Any `AsyncIterableResult` created by `tx.execute()` that is consumed after the transaction ends should produce a clear, actionable error: + +*"Cannot read from a query result after the transaction has ended. Await the result or call .toArray() inside the transaction callback."* + +This approach: +- Keeps `execute()` semantics consistent inside and outside transactions (always lazy) +- Avoids silent behavioral differences (no hidden eager buffering) +- Produces a clear error at the point of misuse, not at commit time +- Does not require tracking created results or walking return values — the connection/transaction scope itself becomes invalid, and any attempt to pull rows through it fails + +Implementation: the transaction-scoped `RuntimeQueryable` passed to `tx.execute()` can set an `invalidated` flag after commit/rollback. The underlying async generator checks this flag before each yield or on first pull. + +## Postgres wire protocol and unconsumed result streams (resolved) + +**Concern:** In the Rust tokio-postgres driver used by the previous Prisma engine, errors from the database (e.g. constraint violations) were only surfaced when reading from the result stream, because the connection must be explicitly polled. If this were a Postgres wire protocol issue, unconsumed streams could silently swallow query errors within a transaction. + +**Finding — client-side error delivery:** This is a tokio-postgres architectural issue, not a wire protocol issue. The Postgres wire protocol is push-oriented — the server sends `ErrorResponse` immediately as part of its normal response sequence. In node-postgres (`pg`), the library attaches `stream.on('data', ...)` to the socket, so the event loop eagerly drains all server responses regardless of whether user code has consumed the result. Errors are routed to the active query immediately. Additionally, failed transaction state is tracked server-side — a subsequent `COMMIT` returns the command tag `ROLLBACK` if any statement has failed. + +**Finding — cursor correctness analysis:** With cursors (DECLARE CURSOR + FETCH), execution is truly suspended server-side. Unfetched rows are never evaluated. This has implications: + +- **DML (INSERT/UPDATE/DELETE) without RETURNING:** Does not use cursors. Executes fully and atomically. **No correctness issue.** +- **DML with RETURNING:** SQL `DECLARE CURSOR` is SELECT/VALUES-only. The current driver's cursor path does not apply to DML RETURNING. **No issue.** +- **Plain SELECT:** The server genuinely doesn't execute unfetched rows. A runtime error on row N+1 never occurs if you only FETCH N rows — the error condition was never evaluated. The transaction's writes are independent of SELECT consumption. **No correctness issue for committed data.** +- **SELECT with data-modifying CTEs (`WITH ... INSERT/UPDATE/DELETE`):** The DML substatements run to completion regardless of how much of the outer SELECT is consumed. **No issue.** +- **SELECT with side-effecting VOLATILE functions:** Partial consumption means some function calls never execute. The ORM/SQL builder does not generate these. **Not a concern for current API surface.** +- **SELECT ... FOR UPDATE:** Locks are acquired per-FETCH. Partial consumption means later qualifying rows are neither seen nor locked. However, SELECT FOR UPDATE is only useful if you read the results to act on them within the same transaction — if you don't consume the stream, you have nothing to update, so incomplete locking is a consequence of an already-broken application pattern, not something the transaction API needs to guard against. + +**Conclusion:** For all current and foreseeable operations, there is no correctness issue with unconsumed cursor-based streams in transactions. Mutations always execute fully regardless of streaming. Unconsumed SELECTs mean unread data, not suppressed errors or incorrect commits.