Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Row>` β€” 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<Row>` is lazy β€” rows are pulled on demand via `for await` or `.toArray()`. It also implements `PromiseLike<Row[]>`, 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> = T extends AsyncIterable<unknown> ? never : T;
transaction<R>(fn: (tx: TxCtx) => PromiseLike<NoAsyncIterable<R>>): Promise<R>;
```

**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.

Comment on lines +19 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

Make the alternatives comparison axis explicit.

This section compares options, but it does not explicitly state the shared axis (invariant/trade-off) up front. Add one short framing sentence before the alternatives list.

Suggested doc tweak
 ## Alternatives considered
 
+Compared on a shared axis: semantic consistency of `execute()`, runtime safety, memory profile, and developer ergonomics.
+
 ### Eager buffering inside transactions

As per coding guidelines: "Research comparisons must explicitly state the shared axis (knob/invariant/trade-off) being compared."

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/architecture` docs/adrs/ADR 187 - Transaction-scoped streams use
invalidation not buffering.md around lines 19 - 49, Add a single framing
sentence immediately before the "Alternatives considered" list that states the
explicit comparison axis (the invariant/trade-off) being evaluated β€” e.g.,
whether each option favors preserving lazy/streaming semantics inside
transactions versus enforcing eager safety and runtime/type complexity β€” so
readers understand that all alternatives are being compared on the same knob;
place this sentence above the "Eager buffering inside transactions" subsection
and reference the options named there ("Eager buffering inside transactions",
"Type-level prevention", "Runtime error before commit (tracking approach)", "Do
nothing (document the pitfall)") to make clear which approaches are being
evaluated against that axis.

## 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.
Comment on lines +50 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟑 Minor

Trim implementation-specific driver internals from ADR scope.

This section includes low-level library behavior details (e.g., exact pg socket handling) that read like implementation notes. Keep ADR text at decision/constraint level and move deep driver mechanics to subsystem docs or linked investigation notes.

Based on learnings: "ADRs in prisma/prisma-next should document architectural design decisions and the key constraints/assumptions only. Do not include implementation algorithms...".

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/architecture` docs/adrs/ADR 187 - Transaction-scoped streams use
invalidation not buffering.md around lines 50 - 58, Trim the
implementation-specific driver internals from "ADR 187 - Transaction-scoped
streams use invalidation not buffering": remove sentences that describe
low-level library behavior (e.g., the node-postgres socket handling,
tokio-postgres futures/polling model, and exact cursor execution mechanics) and
replace them with a concise decision-level statement that the ADR assumes
drivers will eagerly surface server errors and that cursor semantics are a
separate concern; move the removed details into a linked investigation or
subsystem doc (e.g., "driver-internals" notes) and add a brief pointer/link from
the ADR to that doc so readers can follow up for implementation specifics.


## 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.
88 changes: 88 additions & 0 deletions projects/orm-client-transaction-api/plan.md
Original file line number Diff line number Diff line change
@@ -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<R>(fn: (tx: TransactionContext) => PromiseLike<R>): Promise<R>` 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<TContract>` 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<typeof tx>().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<number>`.
- [ ] **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<T>` | 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.
Loading
Loading