feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050) #69

Merged
Zayin_Admin merged 2 commits from feat/module-database-abi-executor into main 2026-10-07 21:25:16 +00:00
6 changed files with 300 additions and 62 deletions
Showing only changes of commit 317a4ce410 - Show all commits
+51 -18
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}`);
}
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;
+144 -22
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 () => {
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<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;