Files
Ekklesia/core/tests/module-database-abi.test.ts
ekklesiaandClaude Opus 5.5 317a4ce410
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
fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
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]>
2026-10-07 16:12:01 -05:00

868 lines
32 KiB
TypeScript

import { readFile } from "node:fs/promises";
import {
isModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
import {
createModuleDatabaseExecutor,
moduleDatabaseExecutorModuleId,
} from "../src/modules/database-abi.js";
import {
decodeModuleDatabaseValue,
encodeModuleDatabaseParameters,
MODULE_DATABASE_MAX_PARAMETERS,
parsePostgresArrayText,
} from "../src/modules/database-abi-values.js";
/**
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
*
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
* transactions, savepoints, session settings) is proven against a real database in
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
*/
async function rejection(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error("Expected the operation to reject.");
}
async function expectAbiError(
promise: Promise<unknown>,
code: ModuleDatabaseErrorCode,
sqlState?: string,
): Promise<void> {
const error = await rejection(promise);
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code });
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
}
function syncThrows(run: () => unknown): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the call to throw.");
}
// --- Parameters --------------------------------------------------------------------------------
describe("encodeModuleDatabaseParameters", () => {
it("encodes every scalar category as text, or bytes, and nothing else", () => {
const bytes = new Uint8Array([0, 1, 255]);
const encoded = encodeModuleDatabaseParameters([
null,
true,
false,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
9_007_199_254_740_993n,
'it\'s \\ "quoted"',
bytes,
]);
expect(encoded.slice(0, 9)).toEqual([
null,
"true",
"false",
"1.5",
"NaN",
"Infinity",
"-Infinity",
"9007199254740993",
'it\'s \\ "quoted"',
]);
expect(Buffer.isBuffer(encoded[9])).toBe(true);
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
});
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
const bytes = new Uint8Array([1, 2, 3]);
const view = bytes.subarray(1);
const [encoded] = encodeModuleDatabaseParameters([view]);
bytes[1] = 9;
expect([...(encoded as Buffer)]).toEqual([2, 3]);
});
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
expect(
encodeModuleDatabaseParameters([
[1, null, 3],
['a"b', "c\\d", "", "NULL", "{,}"],
[
[1, 2],
[3, 4],
],
[new Uint8Array([0, 255])],
[true, 2n],
[],
]),
).toEqual([
'{"1",NULL,"3"}',
'{"a\\"b","c\\\\d","","NULL","{,}"}',
'{{"1","2"},{"3","4"}}',
'{"\\\\x00ff"}',
'{"true","2"}',
"{}",
]);
});
it("treats omitted values as no parameters", () => {
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
});
class Point {
readonly x = 1;
}
const refused: readonly [string, unknown][] = [
["a Date", new Date(0)],
["a plain object", { a: 1 }],
["a null-prototype object", Object.create(null)],
["undefined", undefined],
["a function", () => 1],
["a symbol", Symbol("s")],
["a class instance", new Point()],
["a Map", new Map()],
["another typed array", new Int16Array([1])],
["an ArrayBuffer", new ArrayBuffer(1)],
["a boxed string", new String("x")],
["an array holding a Date", [new Date(0)]],
["an array holding undefined", [1, undefined]],
["an array holding an object", [{ a: 1 }]],
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
["a sparse array", [1, , 3]],
["scalars mixed with sub-arrays", [1, [2]]],
["a null where a sub-array belongs", [[1], null]],
["ragged sub-arrays", [[1], [1, 2]]],
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
];
for (const [label, value] of refused) {
it(`refuses ${label}, without naming the value`, () => {
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
});
}
it("accepts exactly PostgreSQL's six array dimensions", () => {
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
});
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
for (const values of [
"x",
{ 0: "x", length: 1 },
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
]) {
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
}
expect(
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
});
it("refuses a self-referencing array instead of recursing forever", () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
});
});
});
// --- Results -----------------------------------------------------------------------------------
describe("decodeModuleDatabaseValue", () => {
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
"0.1000000000000000000001",
);
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
'{"a": 12345678901234567890}',
);
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
"2026-10-07 12:00:00+00",
);
// An unknown OID (an enum, a domain, an extension type) is text.
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
});
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
});
it("decodes built-in arrays element by element", () => {
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
"1",
null,
"9223372036854775807",
]);
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
[1, 2],
[3, 4],
]);
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
// An array of a type the table does not know arrives as text, like the type itself.
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
});
});
describe("parsePostgresArrayText", () => {
const text = (value: string) => value;
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
'a"b',
"c,d",
null,
"NULL",
"",
"{}",
null,
]);
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
});
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
});
it("refuses text that is not an array literal", () => {
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
}
});
});
// --- The executor, over a scripted client -------------------------------------------------------
interface ScriptedStatement {
readonly text: string;
readonly values?: readonly unknown[];
}
interface Script {
readonly pool: Pool;
readonly statements: ScriptedStatement[];
readonly released: (boolean | undefined)[];
/** Resolve a statement only when `release` is called, to model in-flight work. */
holdNext(): () => void;
failNext(error: unknown): void;
/** Fail the next statement whose text is exactly `text`. */
failWhen(text: string, error: unknown): void;
connectCount(): number;
}
function commandOf(text: string): string {
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
return word === "END" ? "COMMIT" : word;
}
/**
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
*/
function scriptedPool(): Script {
const statements: ScriptedStatement[] = [];
const released: (boolean | undefined)[] = [];
let status = "I";
let connects = 0;
let hold: Promise<void> | undefined;
let failure: unknown;
const failures = new Map<string, unknown>();
const client = {
getTransactionStatus: () => status,
on: () => client,
removeListener: () => client,
async query(config: string | { text: string; values?: unknown[] }) {
const text = typeof config === "string" ? config : config.text;
statements.push(
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
);
const held = hold;
hold = undefined;
if (held !== undefined) {
await held;
}
if (failure !== undefined || failures.has(text)) {
const error = failure ?? failures.get(text);
failure = undefined;
failures.delete(text);
if (status === "T") {
status = "E";
}
throw error;
}
const command = commandOf(text);
if (command === "BEGIN" || command === "START") {
status = "T";
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
const aborted = status === "E";
status = "I";
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
}
return {
command,
fields: [{ dataTypeID: 23, name: "n" }],
rowCount: 1,
rows: [["1"]],
};
},
release: (destroy?: boolean) => {
released.push(destroy);
},
};
return {
connectCount: () => connects,
failNext(error) {
failure = error;
},
failWhen(text, error) {
failures.set(text, error);
},
holdNext() {
let release!: () => void;
hold = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
pool: {
connect: vi.fn(async () => {
connects += 1;
return client;
}),
} as unknown as Pool,
released,
statements,
};
}
function pgError(code: string): DatabaseError {
const error = new DatabaseError(
'duplicate key value violates unique constraint "example_secret_key"',
100,
"error",
);
error.code = code;
error.detail = "Key (secret)=(hunter2) already exists.";
error.hint = "a hint";
error.table = "example_records";
error.constraint = "example_secret_key";
return error;
}
const pin =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
function texts(script: Script): string[] {
return script.statements.map((statement) => statement.text);
}
describe("createModuleDatabaseExecutor", () => {
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
expect(executor.abiVersion).toBe(1);
expect(Object.isFrozen(executor)).toBe(true);
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
expect(forbidden in executor).toBe(false);
}
expect(script.connectCount()).toBe(0);
});
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
expect(() =>
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
).toThrow(TypeError);
});
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
expect(result).toEqual({
command: "SELECT",
fields: [{ name: "n", typeOid: 23 }],
rowCount: 1,
rows: [{ n: 1 }],
});
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
expect(script.released).toEqual([undefined]);
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.rows)).toBe(true);
expect(Object.isFrozen(result.fields)).toBe(true);
});
it("refuses a malformed request before borrowing a connection", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const query = executor.query as (request: unknown) => Promise<unknown>;
for (const request of [
undefined,
null,
"SELECT 1",
[],
{ text: "" },
{ text: 1 },
{ text: "SELECT 1", rowMode: "rows" },
{ text: "SELECT 1", name: "prepared" },
{ text: "SELECT 1", types: [] },
]) {
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
}
await expectAbiError(
query({ text: "SELECT $1", values: [new Date()] }),
"MODULE_DATABASE_PARAMETER_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen("SELECT 1", pgError("23505"));
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
for (const secret of [
"hunter2",
"example_secret_key",
"example_records",
"duplicate",
"hint",
]) {
expect(serialized).not.toContain(secret);
}
expect((error as Error).cause).toBeUndefined();
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
expect(script.released).toEqual([undefined]);
});
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen(
"SELECT 1",
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
);
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
expect(JSON.stringify(error)).not.toContain("postgres://");
expect(script.released).toEqual([true]);
});
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
expect(script.released).toEqual([true]);
expect(texts(script)).not.toContain("DISCARD ALL");
});
});
describe("transaction options", () => {
const invalid: readonly unknown[] = [
null,
"serializable",
[],
{ isolation: "read uncommitted" },
{ isolation: "SERIALIZABLE" },
{ isolation: "constructor" },
{ readOnly: "yes" },
{ readOnly: 1 },
{ deferrable: true },
{ isolation: "serializable", retries: 3 },
];
for (const options of invalid) {
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const callback = vi.fn();
await expectAbiError(
executor.transaction(callback, options as never),
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
);
expect(callback).not.toHaveBeenCalled();
expect(script.connectCount()).toBe(0);
});
}
it("refuses a callback that is not a function", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(
executor.transaction("SELECT 1" as never),
"MODULE_DATABASE_REQUEST_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("begins every transaction with an explicit isolation level and access mode", async () => {
const cases: readonly [unknown, string][] = [
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
[
{ isolation: "serializable", readOnly: false },
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
],
];
for (const [options, begin] of cases) {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async () => undefined, options as never);
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
}
});
});
describe("transaction scopes", () => {
it("commits a resolved callback and returns its value", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const value = await executor.transaction(async (tx) => {
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
expect(Object.isFrozen(tx)).toBe(true);
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
return "done";
});
expect(value).toBe("done");
expect(texts(script).slice(1)).toEqual([
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
"INSERT INTO example_records VALUES (1)",
"COMMIT",
"DISCARD ALL",
]);
});
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
const error = await rejection(
executor.transaction(async (tx) => {
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
}),
);
expect(error).toBe(own);
expect(texts(script)).toContain("ROLLBACK");
expect(texts(script)).not.toContain("COMMIT");
});
it("revokes the executor when the callback settles; later use sends nothing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let escaped: ModuleDatabaseTransaction | undefined;
await executor.transaction((tx) => {
escaped = tx;
});
const sent = script.statements.length;
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await expectAbiError(
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_CLOSED",
);
expect(script.statements).toHaveLength(sent);
});
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction((tx) => {
releaseHeld = script.holdNext();
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// Core waits for the started statement to settle before rolling back the connection.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
await expect(forgotten).resolves.toBeDefined();
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
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 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?.();
expect(await rejection(outcome)).toBe(own);
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const error = await rejection(
executor.transaction(async (tx) => {
await expectAbiError(
tx.query({ text: "SAVEPOINT mine" }),
"MODULE_DATABASE_REQUEST_INVALID",
);
return "carried on";
}),
);
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 () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const outcome = executor.transaction(async (tx) => {
script.failNext(pgError("22012"));
await expectAbiError(
tx.query({ text: "SELECT 1/0" }),
"MODULE_DATABASE_QUERY_FAILED",
"22012",
);
});
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
});
});
describe("savepoint scopes", () => {
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
"SAVEPOINT ekklesia_savepoint_2",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
"RELEASE SAVEPOINT ekklesia_savepoint_2",
]);
});
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await expectAbiError(
tx.savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_INACTIVE",
);
await sp.savepoint(async (inner) => {
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await inner.query({ text: "SELECT 2" });
});
await sp.query({ text: "SELECT 3" });
});
await tx.query({ text: "SELECT 4" });
});
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
expect(texts(script)).toContain("COMMIT");
});
it("revokes an escaped savepoint executor", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
let escaped: ModuleDatabaseTransaction | undefined;
await tx.savepoint((sp) => {
escaped = sp;
});
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await tx.query({ text: "SELECT 2" });
});
expect(texts(script)).toContain("COMMIT");
});
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await expectAbiError(
tx.savepoint((sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
]);
});
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" });
let forgotten: Promise<unknown> | undefined;
await expectAbiError(
executor.transaction((tx) => {
forgotten = tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
expect(texts(script).at(-1)).toBe("DISCARD ALL");
});
});
describe("the ABI's own sources", () => {
it("import no ORM, query builder, or ORM type", async () => {
for (const file of [
"../src/modules/database-abi.ts",
"../src/modules/database-abi-values.ts",
]) {
const source = await readFile(new URL(file, import.meta.url), "utf8");
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
expect(
specifiers.every((specifier) =>
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
),
).toBe(true);
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
}
});
});