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
* 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<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
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<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
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> | T,
): Promise<T> {
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}`);
}
} 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;
}
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 {
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;
+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 () => {
const result = await database.query({ text: 'SELECT 1 AS "__proto__"' });
const row = result.rows[0] as Record<string, unknown>;
@@ -838,22 +860,25 @@ describe("transactions", () => {
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();
for (const statements of [
["SAVEPOINT mine"],
["SAVEPOINT mine", "RELEASE SAVEPOINT mine"],
["SAVEPOINT mine", "ROLLBACK TO SAVEPOINT mine"],
["BEGIN"],
["START TRANSACTION"],
]) {
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");
}
// 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 () => {
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 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";
}),
"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<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 () => {
await reset();
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 () => {
const error = await rejection(
single.transaction(async (tx) => {
+37 -3
View File
@@ -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" });
@@ -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.
+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
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
+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.
*
* 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<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
}
@@ -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<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | 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;