diff --git a/docs/architecture/decisions/0050-module-database-abi-v1.md b/docs/architecture/decisions/0050-module-database-abi-v1.md index 26bc0ab..5b94889 100644 --- a/docs/architecture/decisions/0050-module-database-abi-v1.md +++ b/docs/architecture/decisions/0050-module-database-abi-v1.md @@ -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.