diff --git a/core/src/modules/database-abi.ts b/core/src/modules/database-abi.ts index 9019110..ccf7a48 100644 --- a/core/src/modules/database-abi.ts +++ b/core/src/modules/database-abi.ts @@ -49,13 +49,18 @@ import { * not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports * it empty. * - * ### Transaction control belongs to Core + * ### Transaction control belongs to the callbacks * - * A statement whose command tag is transaction control (`BEGIN`, `START`, `COMMIT`, `ROLLBACK`, - * `SAVEPOINT`, `RELEASE`, `PREPARE`), or that leaves the connection's transaction status other than - * expected, is refused. PostgreSQL has already run it by then, so inside a transaction it also marks - * the transaction failed: Core rolls back and never commits it. The command tag and the protocol's - * transaction status come from PostgreSQL, not from parsing the SQL. + * Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, + * `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a + * conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a + * statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL + * has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time + * Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself + * reports (the statement's command tag, and the protocol's transaction status), and fail safely: + * the call rejects, an enclosing transaction is never committed by Core and rejects too, and the + * connection is destroyed rather than returned to the pool. The commit and rollback guarantees of + * the transaction API apply to conforming use. */ /** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */ @@ -281,6 +286,15 @@ function assertActive(scope: Scope): void { } } +/** Once a transaction can no longer be committed, its scopes send nothing more. After a + * transaction-control violation in particular, a later statement would otherwise run outside any + * transaction, as its own autocommitted statement. */ +function assertUsable(state: TransactionState): void { + if (state.failure !== undefined) { + throw new ModuleDatabaseError(state.failure.code); + } +} + function track(state: TransactionState, promise: Promise): Promise { const shadow = promise.then( () => undefined, @@ -379,7 +393,9 @@ export function createModuleDatabaseExecutor( (raw.command !== null && transactionControlCommands.has(raw.command)) || client.getTransactionStatus() !== "I" ) { - // The connection is destroyed below if the statement left a transaction open. + // A transaction-control violation. PostgreSQL has already run it; the connection is + // destroyed below rather than returned in a state Core did not choose. + healthy = false; throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); } return buildResult(raw, prepared.rowMode); @@ -393,6 +409,7 @@ export function createModuleDatabaseExecutor( request: unknown, ): Promise> { assertActive(scope); + assertUsable(state); const prepared = prepareQuery(request); scope.pending += 1; try { @@ -409,7 +426,10 @@ export function createModuleDatabaseExecutor( (raw.command !== null && transactionControlCommands.has(raw.command)) || state.client.getTransactionStatus() === "I" ) { + // A transaction-control violation. PostgreSQL has already run it, and Core cannot undo + // that; it can only never commit this transaction itself, and destroy the connection. state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); + state.healthy = false; throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); } return buildResult(raw, prepared.rowMode); @@ -422,6 +442,7 @@ export function createModuleDatabaseExecutor( callback: (savepoint: ModuleDatabaseTransaction) => Promise | T, ): Promise { assertActive(scope); + assertUsable(state); if (typeof callback !== "function") { throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); } @@ -444,20 +465,28 @@ export function createModuleDatabaseExecutor( // The enclosing transaction finished first; it rolls back, and nothing more is sent. throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED"); } - if (outstanding || "error" in outcome) { + if ("error" in outcome) { + // A rejected callback's own error stays the error, even if sibling work it started was + // still settling: that work has been drained, and is undone here with everything else. try { await control(state, `ROLLBACK TO SAVEPOINT ${name}`); - if (!outstanding) { - await control(state, `RELEASE SAVEPOINT ${name}`); - } + await control(state, `RELEASE SAVEPOINT ${name}`); } catch (error) { state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED"); throw error; } - if (outstanding) { - throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING"); + throw outcome.error; + } + if (outstanding) { + // A callback that resolved with work outstanding forgot an await. Roll back to the + // savepoint and never release it. + try { + await control(state, `ROLLBACK TO SAVEPOINT ${name}`); + } catch (error) { + state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED"); + throw error; } - throw (outcome as { readonly error: unknown }).error; + throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING"); } try { await control(state, `RELEASE SAVEPOINT ${name}`); @@ -522,14 +551,18 @@ export function createModuleDatabaseExecutor( await drain(state); state.finished = true; - if (outstanding) { - await rollback(state); - throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING"); - } if ("error" in outcome) { + // The callback's own error stays primary, even when sibling work it started (say, the + // other half of a `Promise.all`) was still settling. That work has been drained, and rolls + // back with the rest; outstanding work never leads to a commit. await rollback(state); throw outcome.error; } + if (outstanding) { + // The callback resolved with work it never awaited: a forgotten `await`. + await rollback(state); + throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING"); + } if (state.failure !== undefined) { await rollback(state); throw state.failure; diff --git a/core/tests/module-database-abi.integration.ts b/core/tests/module-database-abi.integration.ts index d3476a2..57aedc9 100644 --- a/core/tests/module-database-abi.integration.ts +++ b/core/tests/module-database-abi.integration.ts @@ -228,6 +228,28 @@ describe("query", () => { ); }); + it("finds duplicate object-mode columns only after the statement ran, so atomicity needs a transaction", async () => { + await reset(); + // At the top level, the mutation has happened by the time the result's shape is known. + await expectAbiError( + database.query({ + text: "INSERT INTO example_records (id, value) VALUES (1, 'a') RETURNING id, id", + }), + "MODULE_DATABASE_REQUEST_INVALID", + ); + expect(await storedIds()).toEqual([1]); + // Inside a transaction, the same error rolls the mutation back with everything else. + await expectAbiError( + database.transaction((tx) => + tx.query({ + text: "INSERT INTO example_records (id, value) VALUES (2, 'b') RETURNING id, id", + }), + ), + "MODULE_DATABASE_REQUEST_INVALID", + ); + expect(await storedIds()).toEqual([1]); + }); + it("keeps a column named __proto__ a column", async () => { const result = await database.query({ text: 'SELECT 1 AS "__proto__"' }); const row = result.rows[0] as Record; @@ -838,22 +860,25 @@ describe("transactions", () => { expect(called).toBe(false); }); - it("refuses transaction control sent through query, and never commits the transaction", async () => { - await reset(); - for (const statements of [ - ["SAVEPOINT mine"], - ["SAVEPOINT mine", "RELEASE SAVEPOINT mine"], - ["SAVEPOINT mine", "ROLLBACK TO SAVEPOINT mine"], - ["BEGIN"], - ["START TRANSACTION"], - ]) { + it("detects transaction control sent through query once PostgreSQL has run it, then sends nothing more and commits nothing", async () => { + // Forbidden by ADR 0050, and not refusable beforehand without parsing SQL. Core detects each + // from PostgreSQL's command tag or the protocol's transaction status. + for (const statement of ["SAVEPOINT mine", "BEGIN", "START TRANSACTION", "ROLLBACK", "ABORT"]) { + await reset(); await expectAbiError( database.transaction(async (tx) => { await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }); - for (const statement of statements) { - // PostgreSQL has run each statement by the time it is refused. - await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID"); - } + await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID"); + // Anything after the violation is refused without being sent: it would otherwise run + // outside any transaction. + await expectAbiError( + tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }), + "MODULE_DATABASE_REQUEST_INVALID", + ); + await expectAbiError( + tx.savepoint(async () => undefined), + "MODULE_DATABASE_REQUEST_INVALID", + ); return "carried on"; }), "MODULE_DATABASE_REQUEST_INVALID", @@ -862,15 +887,25 @@ describe("transactions", () => { } }); - it("refuses a COMMIT sent through query and fails the transaction", async () => { - await reset(); - await expectAbiError( - database.transaction(async (tx) => { - await expectAbiError(tx.query({ text: "COMMIT" }), "MODULE_DATABASE_REQUEST_INVALID"); - return "carried on"; - }), - "MODULE_DATABASE_REQUEST_INVALID", - ); + it("cannot undo a COMMIT sent through query: PostgreSQL has committed, and Core reports the violation and commits nothing more", async () => { + for (const statement of ["COMMIT", "END"]) { + await reset(); + await expectAbiError( + database.transaction(async (tx) => { + await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }); + await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID"); + await expectAbiError( + tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }), + "MODULE_DATABASE_REQUEST_INVALID", + ); + return "carried on"; + }), + "MODULE_DATABASE_REQUEST_INVALID", + ); + // The documented limit: the work before the module's own COMMIT was committed by PostgreSQL + // when it ran that statement. Nothing after it ran. + expect(await storedIds()).toEqual([1]); + } }); it("applies Core's statement bound inside a transaction, and transaction-local settings work", async () => { @@ -917,6 +952,65 @@ describe("outstanding work", () => { expect(await storedIds()).toEqual([]); }); + it("keeps the failing query's own error when one half of a Promise.all fails while the other is still running", async () => { + await reset(); + await database.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }); + let sibling: Promise | undefined; + await expectAbiError( + database.transaction(async (tx) => { + const conflicting = tx.query({ + text: "INSERT INTO example_records (id, value) VALUES (1, 'dup')", + }); + sibling = tx.query({ + text: "INSERT INTO example_records (id, value) SELECT 2, 'b' FROM pg_sleep(0.1)", + }); + sibling.catch(() => undefined); + await Promise.all([conflicting, sibling]); + }), + // The PostgreSQL failure, not MODULE_DATABASE_SCOPE_WORK_OUTSTANDING. + "MODULE_DATABASE_QUERY_FAILED", + "23505", + ); + // The sibling was still outstanding when the callback rejected: it settled afterwards (failing, + // because the transaction was already aborted), before Core rolled back. + await expect(sibling).rejects.toMatchObject({ sqlState: "25P02" }); + expect(await storedIds()).toEqual([1]); + }); + + it("keeps a savepoint's failing query error from a Promise.all, and the transaction carries on", async () => { + await reset(); + await database.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }); + await database.transaction(async (tx) => { + await expectAbiError( + tx.savepoint(async (sp) => { + const sibling = sp.query({ + text: "INSERT INTO example_records (id, value) SELECT 2, 'b' FROM pg_sleep(0.1)", + }); + sibling.catch(() => undefined); + await Promise.all([ + sp.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'dup')" }), + sibling, + ]); + }), + "MODULE_DATABASE_QUERY_FAILED", + "23505", + ); + await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (3, 'c')" }); + }); + expect(await storedIds()).toEqual([1, 3]); + }); + + it("commits a Promise.all whose queries all succeed", async () => { + await reset(); + await database.transaction(async (tx) => { + await Promise.all([ + tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }), + tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }), + ]); + }); + expect(await storedIds()).toEqual([1, 2]); + }); + it("treats an unawaited savepoint as outstanding work", async () => { await reset(); let forgotten: Promise | undefined; @@ -1259,6 +1353,34 @@ describe("pool safety", () => { }); } + it("destroys, never reuses, a connection after transaction-control misuse", async () => { + const pid = async (executor: ModuleDatabaseExecutor | ModuleDatabaseTransaction) => + ( + (await executor.query({ text: "SELECT pg_backend_pid() AS pid" })).rows[0] as { + pid: number; + } + ).pid; + for (const statement of ["COMMIT", "ROLLBACK", "SAVEPOINT mine", "BEGIN"]) { + let used: number | undefined; + await rejection( + single.transaction(async (tx) => { + used = await pid(tx); + await rejection(tx.query({ text: statement })); + }), + ); + expect(used).toBeTypeOf("number"); + expect(await pid(single)).not.toBe(used); + expect(await nextBorrower(onePool)).toEqual(clean); + } + for (const statement of ["BEGIN", "COMMIT"]) { + const before = await pid(single); + // With one connection, the next top-level query would reuse it unless it was destroyed. + await rejection(single.query({ text: statement })); + expect(await pid(single)).not.toBe(before); + expect(await nextBorrower(onePool)).toEqual(clean); + } + }); + it("reports a terminated connection without a SQLSTATE of its own making", async () => { const error = await rejection( single.transaction(async (tx) => { diff --git a/core/tests/module-database-abi.test.ts b/core/tests/module-database-abi.test.ts index 40ead6f..3384e64 100644 --- a/core/tests/module-database-abi.test.ts +++ b/core/tests/module-database-abi.test.ts @@ -654,20 +654,25 @@ describe("transaction scopes", () => { expect(texts(script)).not.toContain("COMMIT"); }); - it("reports outstanding work even when the callback itself rejected", async () => { + it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => { const script = scriptedPool(); const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" }); + const own = new Error("module failure"); let releaseHeld: (() => void) | undefined; const outcome = executor.transaction(async (tx) => { releaseHeld = script.holdNext(); void tx.query({ text: "INSERT INTO example_records VALUES (1)" }); - throw new Error("module failure"); + throw own; }); await vi.waitFor(() => expect(releaseHeld).toBeDefined()); + // The sibling statement is drained before Core rolls back. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(texts(script)).not.toContain("ROLLBACK"); releaseHeld?.(); - await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING"); + expect(await rejection(outcome)).toBe(own); + expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]); expect(texts(script)).not.toContain("COMMIT"); }); @@ -688,6 +693,9 @@ describe("transaction scopes", () => { expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" }); expect(texts(script)).not.toContain("COMMIT"); expect(texts(script)).toContain("ROLLBACK"); + // The connection is destroyed, never reset and reused. + expect(texts(script)).not.toContain("DISCARD ALL"); + expect(script.released).toEqual([true]); }); it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => { @@ -794,6 +802,32 @@ describe("savepoint scopes", () => { ]); }); + it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => { + const script = scriptedPool(); + const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" }); + const own = new Error("savepoint failure"); + + await executor.transaction(async (tx) => { + const error = await rejection( + tx.savepoint(async (sp) => { + const release = script.holdNext(); + void sp.query({ text: "INSERT INTO example_records VALUES (1)" }); + setTimeout(release, 10); + throw own; + }), + ); + expect(error).toBe(own); + }); + + expect(texts(script).slice(2, -2)).toEqual([ + "SAVEPOINT ekklesia_savepoint_1", + "INSERT INTO example_records VALUES (1)", + "ROLLBACK TO SAVEPOINT ekklesia_savepoint_1", + "RELEASE SAVEPOINT ekklesia_savepoint_1", + ]); + expect(texts(script)).toContain("COMMIT"); + }); + it("counts an unawaited savepoint as the transaction's outstanding work", async () => { const script = scriptedPool(); const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" }); diff --git a/docs/architecture/decisions/0050-module-database-abi-v1.md b/docs/architecture/decisions/0050-module-database-abi-v1.md index d107aca..484c176 100644 --- a/docs/architecture/decisions/0050-module-database-abi-v1.md +++ b/docs/architecture/decisions/0050-module-database-abi-v1.md @@ -181,8 +181,13 @@ transaction(callback, options) → the callback's result - `"object"` rows are keyed by column name. - `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice, as joins do, needs this mode. - - Core rejects an object-mode result that has duplicate column names, rather than silently - dropping one. + - Duplicate column names are invalid in object mode. Core rejects such a result rather than + silently dropping a column. + - With the current PostgreSQL and driver execution path, the result's shape is only known once + the statement has run. So a mutating statement can already have had its effects when the + result-shape error is reported. + - This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI + transaction, where the error rolls the mutation back with everything else. - **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`. - **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports none. @@ -331,15 +336,37 @@ transaction(async (tx) => { … }, { isolation, readOnly }) 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. + starts through `tx` before its callback returns. What happens when work is still outstanding + depends on how the callback settled: + - **Resolved with work outstanding.** The module forgot an `await` and has used the scope + incorrectly. The transaction is 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. + - **Rejected with work outstanding.** The callback's own rejection stays the transaction's error. + Core rolls back and does not replace that error, a PostgreSQL failure included, with the + scope-usage error just because sibling work was still settling. This is what happens when one + query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running. + - Either way, outstanding work never leads to a commit. - 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. +- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through + `query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and + every other statement that starts, ends, or marks a transaction. Modules control a transaction's + lifecycle only through the `transaction` and `savepoint` callbacks. + - This is a conformance rule for trusted module code, not a sandbox or a security guarantee. + - Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL + executes it before Core can see its result. + - Core detects a violation where the driver and protocol make that possible: from the statement's + command tag, and from the connection's transaction status. It then fails safely: + - the call rejects; + - the transaction's scopes refuse every later statement without sending it, because it would + otherwise run outside any transaction; + - Core never commits the enclosing transaction itself, and that transaction rejects too; + - the connection is destroyed rather than reused. + - Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A + `COMMIT` sent through a transaction's `query` has already committed the work before it. + - The commit and rollback guarantees in this section apply to conforming use of the ABI. - **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 @@ -362,9 +389,12 @@ 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. +- **Outstanding work.** The awaiting rule for transactions applies unchanged. + - A savepoint callback that resolves with operations still outstanding is a scope-usage error. + The savepoint is rolled back, never released, and the call rejects with that error. + - A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any + rejection. + - Either way, outstanding work never leads to a release. - **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. diff --git a/docs/development/testing.md b/docs/development/testing.md index 3cd6214..ffa962a 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -522,7 +522,12 @@ because every claim is a property of PostgreSQL or of the protocol: - commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being committed; - callback-scoped savepoints; -- a forgotten `await` rolling back instead of committing; +- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a + failed `Promise.all`) keeps its own error; +- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core + cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything + after, never commits, and destroys the connection; +- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction; - revoked executors reaching nothing. The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO diff --git a/packages/contracts/src/module-database-abi.ts b/packages/contracts/src/module-database-abi.ts index 7c066d1..8ef9b59 100644 --- a/packages/contracts/src/module-database-abi.ts +++ b/packages/contracts/src/module-database-abi.ts @@ -105,11 +105,17 @@ export interface ModuleDatabaseTransactionOptions { * that callback settles it is revoked, and every later call fails without reaching PostgreSQL. * * Only the innermost open scope may run statements: while a savepoint is open, its parent refuses - * statements and new savepoints. A callback must await every operation it starts before it returns; - * one that settles with work outstanding is never committed or released. + * statements and new savepoints. A callback must await every operation it starts before it returns. + * One that resolves with work outstanding forgot an `await`: it fails with + * `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error. + * Either way, outstanding work never leads to a commit or a release. + * + * Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is + * forbidden through `query`; the callbacks control the transaction. */ export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable { - /** Core names the savepoint. A resolved callback releases it; a rejected one rolls back to it. */ + /** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected + * one rolls back to it and keeps its own error. */ savepoint(callback: (savepoint: ModuleDatabaseTransaction) => Promise | T): Promise; } @@ -120,7 +126,9 @@ export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable { */ export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable { /** Core begins the transaction, commits when `callback` resolves with no work outstanding, and - * rolls back otherwise. Core never retries it. */ + * rolls back otherwise; a rejected callback's own error is the transaction's error. Core never + * retries it. These guarantees apply to conforming use: no transaction-control SQL through + * `query`. */ transaction( callback: (transaction: ModuleDatabaseTransaction) => Promise | T, options?: ModuleDatabaseTransactionOptions, @@ -135,7 +143,12 @@ export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable { export const MODULE_DATABASE_ERROR_CODES = [ /** The call is not valid ABI use: a malformed query request or callback, a statement text that * holds no statement, an object-mode result with duplicate column names, or a transaction-control - * statement sent through `query`. A programming error. */ + * statement sent through `query`. A programming error. + * + * The last two are only detectable once PostgreSQL has run the statement, so its effects may + * already have happened: a mutation with duplicate result columns has been applied (inside a + * transaction, it rolls back with it), and transaction control has taken effect, which Core cannot + * undo. */ "MODULE_DATABASE_REQUEST_INVALID", /** A parameter value is outside the ABI's parameter set. Refused before anything reaches * PostgreSQL. */ @@ -154,8 +167,9 @@ export const MODULE_DATABASE_ERROR_CODES = [ "MODULE_DATABASE_SCOPE_CLOSED", /** The executor's scope is open but not active, because a savepoint inside it is open. */ "MODULE_DATABASE_SCOPE_INACTIVE", - /** A transaction or savepoint callback settled while work it started was still outstanding. The - * scope was rolled back, never committed or released. */ + /** A transaction or savepoint callback resolved while work it started was still outstanding: a + * forgotten `await`. The scope was rolled back, never committed or released. (A callback that + * rejects keeps its own error instead.) */ "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING", ] as const;