-
Notifications
You must be signed in to change notification settings - Fork 3
docs: add transaction API related spec, plan and ADR #345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| ## 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trim implementation-specific driver internals from ADR scope. This section includes low-level library behavior details (e.g., exact 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 |
||
|
|
||
| ## 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. | ||
| 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
As per coding guidelines: "Research comparisons must explicitly state the shared axis (knob/invariant/trade-off) being compared."
π€ Prompt for AI Agents