fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s

Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 16:12:01 -05:00
co-authored by Claude Opus 5.5
parent 6db50516b7
commit 317a4ce410
6 changed files with 300 additions and 62 deletions
+50 -17
View File
@@ -49,13 +49,18 @@ import {
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports * not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
* it empty. * 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`, * Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `SAVEPOINT`, `RELEASE`, `PREPARE`), or that leaves the connection's transaction status other than * `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* expected, is refused. PostgreSQL has already run it by then, so inside a transaction it also marks * conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* the transaction failed: Core rolls back and never commits it. The command tag and the protocol's * statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* transaction status come from PostgreSQL, not from parsing the SQL. * 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. */ /** 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<T>(state: TransactionState, promise: Promise<T>): Promise<T> { function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then( const shadow = promise.then(
() => undefined, () => undefined,
@@ -379,7 +393,9 @@ export function createModuleDatabaseExecutor(
(raw.command !== null && transactionControlCommands.has(raw.command)) || (raw.command !== null && transactionControlCommands.has(raw.command)) ||
client.getTransactionStatus() !== "I" 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"); throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
} }
return buildResult(raw, prepared.rowMode); return buildResult(raw, prepared.rowMode);
@@ -393,6 +409,7 @@ export function createModuleDatabaseExecutor(
request: unknown, request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> { ): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope); assertActive(scope);
assertUsable(state);
const prepared = prepareQuery(request); const prepared = prepareQuery(request);
scope.pending += 1; scope.pending += 1;
try { try {
@@ -409,7 +426,10 @@ export function createModuleDatabaseExecutor(
(raw.command !== null && transactionControlCommands.has(raw.command)) || (raw.command !== null && transactionControlCommands.has(raw.command)) ||
state.client.getTransactionStatus() === "I" 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.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
} }
return buildResult(raw, prepared.rowMode); return buildResult(raw, prepared.rowMode);
@@ -422,6 +442,7 @@ export function createModuleDatabaseExecutor(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T, callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> { ): Promise<T> {
assertActive(scope); assertActive(scope);
assertUsable(state);
if (typeof callback !== "function") { if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID"); 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. // The enclosing transaction finished first; it rolls back, and nothing more is sent.
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED"); 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 { try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`); await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
if (!outstanding) {
await control(state, `RELEASE SAVEPOINT ${name}`); await control(state, `RELEASE SAVEPOINT ${name}`);
}
} catch (error) { } catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED"); state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error; throw error;
} }
if (outstanding) { throw outcome.error;
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
} }
throw (outcome as { readonly error: unknown }).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 new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
} }
try { try {
await control(state, `RELEASE SAVEPOINT ${name}`); await control(state, `RELEASE SAVEPOINT ${name}`);
@@ -522,14 +551,18 @@ export function createModuleDatabaseExecutor(
await drain(state); await drain(state);
state.finished = true; state.finished = true;
if (outstanding) {
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if ("error" in outcome) { 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); await rollback(state);
throw outcome.error; 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) { if (state.failure !== undefined) {
await rollback(state); await rollback(state);
throw state.failure; throw state.failure;
+135 -13
View File
@@ -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 () => { it("keeps a column named __proto__ a column", async () => {
const result = await database.query({ text: 'SELECT 1 AS "__proto__"' }); const result = await database.query({ text: 'SELECT 1 AS "__proto__"' });
const row = result.rows[0] as Record<string, unknown>; const row = result.rows[0] as Record<string, unknown>;
@@ -838,22 +860,25 @@ describe("transactions", () => {
expect(called).toBe(false); expect(called).toBe(false);
}); });
it("refuses transaction control sent through query, and never commits the transaction", async () => { 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 reset();
for (const statements of [
["SAVEPOINT mine"],
["SAVEPOINT mine", "RELEASE SAVEPOINT mine"],
["SAVEPOINT mine", "ROLLBACK TO SAVEPOINT mine"],
["BEGIN"],
["START TRANSACTION"],
]) {
await expectAbiError( await expectAbiError(
database.transaction(async (tx) => { database.transaction(async (tx) => {
await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }); 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"; return "carried on";
}), }),
"MODULE_DATABASE_REQUEST_INVALID", "MODULE_DATABASE_REQUEST_INVALID",
@@ -862,15 +887,25 @@ describe("transactions", () => {
} }
}); });
it("refuses a COMMIT sent through query and fails the transaction", async () => { 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 reset();
await expectAbiError( await expectAbiError(
database.transaction(async (tx) => { database.transaction(async (tx) => {
await expectAbiError(tx.query({ text: "COMMIT" }), "MODULE_DATABASE_REQUEST_INVALID"); 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"; return "carried on";
}), }),
"MODULE_DATABASE_REQUEST_INVALID", "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 () => { 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([]); 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<unknown> | 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 () => { it("treats an unawaited savepoint as outstanding work", async () => {
await reset(); await reset();
let forgotten: Promise<unknown> | undefined; let forgotten: Promise<unknown> | 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 () => { it("reports a terminated connection without a SQLSTATE of its own making", async () => {
const error = await rejection( const error = await rejection(
single.transaction(async (tx) => { single.transaction(async (tx) => {
+37 -3
View File
@@ -654,20 +654,25 @@ describe("transaction scopes", () => {
expect(texts(script)).not.toContain("COMMIT"); 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 script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" }); const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
let releaseHeld: (() => void) | undefined; let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction(async (tx) => { const outcome = executor.transaction(async (tx) => {
releaseHeld = script.holdNext(); releaseHeld = script.holdNext();
void tx.query({ text: "INSERT INTO example_records VALUES (1)" }); void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw new Error("module failure"); throw own;
}); });
await vi.waitFor(() => expect(releaseHeld).toBeDefined()); 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?.(); 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"); expect(texts(script)).not.toContain("COMMIT");
}); });
@@ -688,6 +693,9 @@ describe("transaction scopes", () => {
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" }); expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
expect(texts(script)).not.toContain("COMMIT"); expect(texts(script)).not.toContain("COMMIT");
expect(texts(script)).toContain("ROLLBACK"); 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 () => { 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 () => { it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
const script = scriptedPool(); const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" }); const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
@@ -181,8 +181,13 @@ transaction(callback, options) → the callback's result
- `"object"` rows are keyed by column name. - `"object"` rows are keyed by column name.
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice, - `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
as joins do, needs this mode. as joins do, needs this mode.
- Core rejects an object-mode result that has duplicate column names, rather than silently - Duplicate column names are invalid in object mode. Core rejects such a result rather than
dropping one. 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`. - **`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 - **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
none. 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, transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
the module throws. the module throws.
- **All work is awaited inside the callback.** Module code must await every database operation it - **All work is awaited inside the callback.** Module code must await every database operation it
starts through `tx` before its callback returns. starts through `tx` before its callback returns. What happens when work is still outstanding
- If the callback settles while an operation launched through that scope is still outstanding, the depends on how the callback settled:
module has used the scope incorrectly. - **Resolved with work outstanding.** The module forgot an `await` and has used the scope
- The transaction is then never committed: Core rolls it back, and the transaction call rejects incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
with the scope-usage `ModuleDatabaseError`. rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains. 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 - 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 the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
never turns into a commit. 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 - **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 finished. Any later call on it fails with the
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its 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. - **Naming.** Core generates every savepoint identifier. Modules never provide one.
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back - **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. to it, releases it, and rejects with the callback's error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback - **Outstanding work.** The awaiting rule for transactions applies unchanged.
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled - A savepoint callback that resolves with operations still outstanding is a scope-usage error.
back, never released, and the call rejects with that 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 - **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 open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
overlap. overlap.
+6 -1
View File
@@ -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 - commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
committed; committed;
- callback-scoped savepoints; - 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. - revoked executors reaching nothing.
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
+21 -7
View File
@@ -105,11 +105,17 @@ export interface ModuleDatabaseTransactionOptions {
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL. * 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 * 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; * 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. * 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 { 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<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>; savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
} }
@@ -120,7 +126,9 @@ export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
*/ */
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable { export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and /** 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<T>( transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T, callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: ModuleDatabaseTransactionOptions, options?: ModuleDatabaseTransactionOptions,
@@ -135,7 +143,12 @@ export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
export const MODULE_DATABASE_ERROR_CODES = [ export const MODULE_DATABASE_ERROR_CODES = [
/** The call is not valid ABI use: a malformed query request or callback, a statement text that /** 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 * 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", "MODULE_DATABASE_REQUEST_INVALID",
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches /** A parameter value is outside the ABI's parameter set. Refused before anything reaches
* PostgreSQL. */ * PostgreSQL. */
@@ -154,8 +167,9 @@ export const MODULE_DATABASE_ERROR_CODES = [
"MODULE_DATABASE_SCOPE_CLOSED", "MODULE_DATABASE_SCOPE_CLOSED",
/** The executor's scope is open but not active, because a savepoint inside it is open. */ /** The executor's scope is open but not active, because a savepoint inside it is open. */
"MODULE_DATABASE_SCOPE_INACTIVE", "MODULE_DATABASE_SCOPE_INACTIVE",
/** A transaction or savepoint callback settled while work it started was still outstanding. The /** A transaction or savepoint callback resolved while work it started was still outstanding: a
* scope was rolled back, never committed or released. */ * 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", "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
] as const; ] as const;