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): Promise { try { await promise; } catch (error) { return error; } throw new Error("Expected the operation to reject."); } async function expectAbiError( promise: Promise, code: ModuleDatabaseErrorCode, sqlState?: string, ): Promise { 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 | undefined; let failure: unknown; const failures = new Map(); 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((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; 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 | 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 | 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, "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); } }); });