CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.
Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
{ command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
frozen; isModuleDatabaseError recognises it by shape.
Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
protocol so PostgreSQL itself refuses a second statement (42601);
no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
registry: int8, numeric, temporal, and JSON as exact text; bytea as a
plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
and extra_float_digits, then resets with DISCARD ALL before returning
the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
committed or released;
- transaction-control statements sent through query are refused by
command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.
Tests:
- 83 integration tests on a database with hostile defaults: queries,
the one-statement rule, parameters, decoding, errors, transactions,
savepoints, forgotten awaits, pool safety after every failure class,
and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
bookkeeping;
- type-level and source checks that no ORM or driver object crosses
the boundary.
ADR 0050 is Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
834 lines
30 KiB
TypeScript
834 lines
30 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("reports outstanding work even when the callback itself rejected", async () => {
|
|
const script = scriptedPool();
|
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
|
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");
|
|
});
|
|
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
|
releaseHeld?.();
|
|
|
|
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
|
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");
|
|
});
|
|
|
|
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("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);
|
|
}
|
|
});
|
|
});
|