feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050) #69
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user