docs: ADR 0050, tighten transaction scope, single-statement, and error rules
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Three narrow clarifications to the proposed contract. No other decision changes. - Outstanding work: module code awaits every operation before its transaction or savepoint callback returns. A callback that settles with work outstanding is a scope-usage error and never commits. Core may let started operations settle before rolling back and releasing the connection, but waiting never turns into a commit. Executors stay revoked once their scope has finished. - One statement per query() call: a correctness and API rule, not a security boundary. It is upheld by a reliable driver or protocol mechanism, never a home-grown SQL parser. An implementation that cannot uphold it stops and reports instead of weakening the ADR. - Errors: `code` is a stable Ekklesia-owned category and always present. `sqlState` is optional, present only when PostgreSQL supplied one. ABI-detected failures (closed scope, invalid parameter, invalid option) carry a code and no SQLSTATE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
759872834d
commit
11e2495484
@@ -168,8 +168,13 @@ query({ text, values, rowMode }) → { command, rowCount, rows, fields }
|
||||
transaction(callback, options) → the callback's result
|
||||
```
|
||||
|
||||
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. Statement text that
|
||||
holds several statements is not part of the ABI, and Core may reject it.
|
||||
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. **One `query` call
|
||||
runs one statement.** This is a correctness and API rule, not a security boundary.
|
||||
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
|
||||
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
|
||||
- It does not require, and must not grow, an Ekklesia SQL parser.
|
||||
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
|
||||
the conflict. It does not weaken this ADR silently.
|
||||
- **`values`** is the ordered list of bound parameters (see
|
||||
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
|
||||
- **`rowMode`** is `"object"` or `"array"`.
|
||||
@@ -280,16 +285,24 @@ Conceptually:
|
||||
ModuleDatabaseError { code, sqlState? }
|
||||
```
|
||||
|
||||
- **`code`** is a small, stable, Ekklesia-defined set. It covers at least:
|
||||
- the statement failed in PostgreSQL;
|
||||
- a parameter or option was refused;
|
||||
- the scope is closed;
|
||||
The two fields mean different things:
|
||||
|
||||
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
|
||||
programmatic handling. Its categories cover at least:
|
||||
- PostgreSQL rejected the statement;
|
||||
- a parameter value was refused;
|
||||
- a transaction option was refused;
|
||||
- the scope was closed or used incorrectly;
|
||||
- the transaction could not be completed.
|
||||
|
||||
The exact names are an implementation detail.
|
||||
- **`sqlState`** is PostgreSQL's five-character SQLSTATE, present when PostgreSQL reported one.
|
||||
Modules legitimately branch on stable conditions such as `23505` (unique violation), `23503`
|
||||
(foreign-key violation), and `40001` (serialization failure).
|
||||
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
|
||||
meaning.
|
||||
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
|
||||
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
|
||||
It is present only when the database supplied one, and Core never invents one.
|
||||
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
|
||||
invalid parameter type, or an invalid transaction option.
|
||||
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
|
||||
- **It never carries:**
|
||||
- SQL text or parameter values;
|
||||
- the driver's message, detail, hint, constraint, table, or column names;
|
||||
@@ -313,13 +326,22 @@ transaction(async (tx) => { … }, { isolation, readOnly })
|
||||
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
|
||||
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
|
||||
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
|
||||
- **Commit and rollback.** Core commits when the callback's promise resolves, and rolls back when it
|
||||
rejects. The transaction call then rejects with the callback's error. There is no `rollback()`
|
||||
method: to roll back, the module throws.
|
||||
- **Unfinished queries.** Before committing or rolling back, Core waits for every query the scope
|
||||
has already started. If one of them fails after the callback resolved, Core rolls back and reports
|
||||
the failure.
|
||||
- **Revocation.** When the callback settles, `tx` is revoked. Any later call on it fails with the
|
||||
- **Commit and rollback.** Core commits only when the callback's promise resolves and no operation
|
||||
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
|
||||
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
|
||||
the module throws.
|
||||
- **All work is awaited inside the callback.** Module code must await every database operation it
|
||||
starts through `tx` before its callback returns.
|
||||
- If the callback settles while an operation launched through that scope is still outstanding, the
|
||||
module has used the scope incorrectly.
|
||||
- The transaction is then never committed: Core rolls it back, and the transaction call rejects
|
||||
with the scope-usage `ModuleDatabaseError`.
|
||||
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains.
|
||||
- Core may let operations that already started finish, or settle, before it rolls back and releases
|
||||
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
|
||||
never turns into a commit.
|
||||
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
|
||||
finished. Any later call on it fails with the
|
||||
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
|
||||
callback can never run a statement on a connection that has gone back to the pool.
|
||||
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
|
||||
@@ -340,6 +362,9 @@ tx.savepoint(async (sp) => { … })
|
||||
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
|
||||
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
|
||||
to it, releases it, and rejects with the callback's error.
|
||||
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback
|
||||
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled
|
||||
back, never released, and the call rejects with that error.
|
||||
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
|
||||
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
|
||||
overlap.
|
||||
|
||||
Reference in New Issue
Block a user