feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d43031997b
commit
6db50516b7
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type ModuleDatabaseField,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseValue,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
|
||||
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
|
||||
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
|
||||
*
|
||||
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
|
||||
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
|
||||
* executor for every ABI operation (`./database-abi.ts`).
|
||||
*/
|
||||
|
||||
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
|
||||
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
|
||||
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
|
||||
const maximumArrayDimensions = 6;
|
||||
|
||||
function parameterInvalid(): ModuleDatabaseError {
|
||||
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||
}
|
||||
|
||||
function isScalar(value: unknown): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "bigint" ||
|
||||
typeof value === "string" ||
|
||||
value instanceof Uint8Array
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: boolean | number | bigint | string): string {
|
||||
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
|
||||
// accepts them.
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function byteaText(value: Uint8Array): string {
|
||||
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
|
||||
}
|
||||
|
||||
function quoteArrayElement(text: string): string {
|
||||
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
|
||||
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
|
||||
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
|
||||
* type.
|
||||
*/
|
||||
function arrayLiteral(values: readonly unknown[], depth: number): string {
|
||||
if (depth > maximumArrayDimensions) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const elements: string[] = [];
|
||||
let nestedLength: number | undefined;
|
||||
let nested: boolean | undefined;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const element = values[index];
|
||||
const isNested = Array.isArray(element);
|
||||
if (nested === undefined) {
|
||||
nested = isNested;
|
||||
} else if (nested !== isNested) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
if (isNested) {
|
||||
if (nestedLength === undefined) {
|
||||
nestedLength = element.length;
|
||||
} else if (nestedLength !== element.length) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
elements.push(arrayLiteral(element, depth + 1));
|
||||
} else if (element === null) {
|
||||
elements.push("NULL");
|
||||
} else if (element instanceof Uint8Array) {
|
||||
elements.push(quoteArrayElement(byteaText(element)));
|
||||
} else if (isScalar(element)) {
|
||||
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return `{${elements.join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
|
||||
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
|
||||
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
|
||||
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
|
||||
* anything reaches PostgreSQL. The error never names the value.
|
||||
*/
|
||||
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const encoded: (string | Buffer | null)[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const value: unknown = values[index];
|
||||
if (value === null) {
|
||||
encoded.push(null);
|
||||
} else if (value instanceof Uint8Array) {
|
||||
// A copy: the driver serializes when the statement is sent, which may be after the caller
|
||||
// has changed its own bytes.
|
||||
encoded.push(Buffer.from(value));
|
||||
} else if (Array.isArray(value)) {
|
||||
encoded.push(arrayLiteral(value, 1));
|
||||
} else if (isScalar(value)) {
|
||||
encoded.push(scalarText(value as boolean | number | bigint | string));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
|
||||
|
||||
const asText: ScalarDecoder = (text) => text;
|
||||
const asBoolean: ScalarDecoder = (text) => text === "t";
|
||||
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
|
||||
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
|
||||
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
|
||||
const asFloat: ScalarDecoder = (text) => Number(text);
|
||||
|
||||
function hexDigit(code: number): number {
|
||||
if (code >= 48 && code <= 57) {
|
||||
return code - 48;
|
||||
}
|
||||
if (code >= 97 && code <= 102) {
|
||||
return code - 87;
|
||||
}
|
||||
if (code >= 65 && code <= 70) {
|
||||
return code - 55;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
|
||||
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
|
||||
* Always a plain `Uint8Array`, never a `Buffer`.
|
||||
*/
|
||||
const asBytes: ScalarDecoder = (text) => {
|
||||
if (text.startsWith("\\x")) {
|
||||
const length = (text.length - 2) / 2;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const high = hexDigit(text.charCodeAt(2 + index * 2));
|
||||
const low = hexDigit(text.charCodeAt(3 + index * 2));
|
||||
bytes[index] = high * 16 + low;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code === 92 && text.charCodeAt(index + 1) === 92) {
|
||||
bytes.push(92);
|
||||
index += 1;
|
||||
} else if (code === 92) {
|
||||
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
|
||||
index += 3;
|
||||
} else {
|
||||
bytes.push(code);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
|
||||
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
|
||||
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
|
||||
* arrives as its exact text. */
|
||||
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
|
||||
[16, asBoolean], // bool
|
||||
[17, asBytes], // bytea
|
||||
[21, asInteger], // int2
|
||||
[23, asInteger], // int4
|
||||
[26, asInteger], // oid
|
||||
[700, asFloat], // float4
|
||||
[701, asFloat], // float8
|
||||
]);
|
||||
|
||||
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
|
||||
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
|
||||
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
|
||||
[1000, 16], // bool[]
|
||||
[1001, 17], // bytea[]
|
||||
[1002, 18], // "char"[]
|
||||
[1003, 19], // name[]
|
||||
[1005, 21], // int2[]
|
||||
[1007, 23], // int4[]
|
||||
[1009, 25], // text[]
|
||||
[1014, 1042], // bpchar[]
|
||||
[1015, 1043], // varchar[]
|
||||
[1016, 20], // int8[]
|
||||
[1021, 700], // float4[]
|
||||
[1022, 701], // float8[]
|
||||
[1028, 26], // oid[]
|
||||
[1115, 1114], // timestamp[]
|
||||
[1182, 1082], // date[]
|
||||
[1183, 1083], // time[]
|
||||
[1185, 1184], // timestamptz[]
|
||||
[1187, 1186], // interval[]
|
||||
[1231, 1700], // numeric[]
|
||||
[1270, 1266], // timetz[]
|
||||
[2951, 2950], // uuid[]
|
||||
[199, 114], // json[]
|
||||
[3807, 3802], // jsonb[]
|
||||
]);
|
||||
|
||||
class ArrayTextError extends Error {}
|
||||
|
||||
/**
|
||||
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
|
||||
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
|
||||
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
|
||||
* decodes uses `,` as its delimiter.
|
||||
*/
|
||||
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
|
||||
let position = 0;
|
||||
if (text.startsWith("[")) {
|
||||
const equals = text.indexOf("=");
|
||||
if (equals === -1) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position = equals + 1;
|
||||
}
|
||||
|
||||
function parseArray(): ModuleDatabaseValue[] {
|
||||
if (text[position] !== "{") {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position += 1;
|
||||
const items: ModuleDatabaseValue[] = [];
|
||||
if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
}
|
||||
for (;;) {
|
||||
const character = text[position];
|
||||
if (character === "{") {
|
||||
items.push(parseArray());
|
||||
} else if (character === '"') {
|
||||
position += 1;
|
||||
let element = "";
|
||||
for (;;) {
|
||||
const next = text[position];
|
||||
if (next === undefined) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
if (next === "\\") {
|
||||
element += text[position + 1] ?? "";
|
||||
position += 2;
|
||||
} else if (next === '"') {
|
||||
position += 1;
|
||||
break;
|
||||
} else {
|
||||
element += next;
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
items.push(decode(element));
|
||||
} else {
|
||||
const start = position;
|
||||
while (position < text.length && text[position] !== "," && text[position] !== "}") {
|
||||
position += 1;
|
||||
}
|
||||
const element = text.slice(start, position);
|
||||
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
|
||||
}
|
||||
if (text[position] === ",") {
|
||||
position += 1;
|
||||
} else if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
} else {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseArray();
|
||||
if (position !== text.length) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function scalarDecoder(typeOid: number): ScalarDecoder {
|
||||
return scalarDecoders.get(typeOid) ?? asText;
|
||||
}
|
||||
|
||||
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
|
||||
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
|
||||
const elementType = arrayElementTypes.get(typeOid);
|
||||
if (elementType === undefined) {
|
||||
return scalarDecoder(typeOid)(text);
|
||||
}
|
||||
try {
|
||||
return parsePostgresArrayText(text, scalarDecoder(elementType));
|
||||
} catch {
|
||||
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
|
||||
// arrives faithfully, as text, rather than as a guess.
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
|
||||
* every type, so the driver's global registry, which module code could change, is never consulted
|
||||
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
|
||||
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
|
||||
* parameter type from the statement.
|
||||
*/
|
||||
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
|
||||
Object.freeze(
|
||||
Object.assign([] as never[], {
|
||||
getTypeParser: () => (text: string) => text,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Decodes one positional row of raw driver text. */
|
||||
export function decodeModuleDatabaseRow(
|
||||
fields: readonly ModuleDatabaseField[],
|
||||
raw: readonly unknown[],
|
||||
): ModuleDatabaseValue[] {
|
||||
return fields.map((field, index) => {
|
||||
const value = raw[index];
|
||||
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import {
|
||||
MODULE_DATABASE_ABI_VERSION,
|
||||
type ModuleDatabaseArrayRow,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseErrorCode,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleDatabaseField,
|
||||
type ModuleDatabaseObjectRow,
|
||||
type ModuleDatabaseQueryable,
|
||||
type ModuleDatabaseQueryResult,
|
||||
type ModuleDatabaseTransaction,
|
||||
type ModuleId,
|
||||
moduleIdSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
|
||||
import {
|
||||
decodeModuleDatabaseRow,
|
||||
encodeModuleDatabaseParameters,
|
||||
rawTextTypes,
|
||||
} from "./database-abi-values.js";
|
||||
|
||||
/**
|
||||
* Core's Module Database ABI v1 executor (ADR 0050).
|
||||
*
|
||||
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
|
||||
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
|
||||
* crosses in either direction. The executor is built over the one pool the API already owns
|
||||
* (ADR 0016) and never closes it.
|
||||
*
|
||||
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
|
||||
* still send any SQL its database role permits. Nothing here inspects table names.
|
||||
*
|
||||
* ### Connections and session state
|
||||
*
|
||||
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
|
||||
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
|
||||
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
|
||||
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
|
||||
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
|
||||
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
|
||||
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
|
||||
*
|
||||
* ### One statement per `query`
|
||||
*
|
||||
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
|
||||
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
|
||||
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
|
||||
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
|
||||
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
||||
* it empty.
|
||||
*
|
||||
* ### Transaction control belongs to Core
|
||||
*
|
||||
* A statement whose command tag is transaction control (`BEGIN`, `START`, `COMMIT`, `ROLLBACK`,
|
||||
* `SAVEPOINT`, `RELEASE`, `PREPARE`), or that leaves the connection's transaction status other than
|
||||
* expected, is refused. PostgreSQL has already run it by then, so inside a transaction it also marks
|
||||
* the transaction failed: Core rolls back and never commits it. The command tag and the protocol's
|
||||
* transaction status come from PostgreSQL, not from parsing the SQL.
|
||||
*/
|
||||
|
||||
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
|
||||
const pinSessionStatement =
|
||||
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
|
||||
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||
const resetSessionStatement = "DISCARD ALL";
|
||||
|
||||
const transactionControlCommands: ReadonlySet<string> = new Set([
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"PREPARE",
|
||||
"RELEASE",
|
||||
"ROLLBACK",
|
||||
"SAVEPOINT",
|
||||
"START",
|
||||
]);
|
||||
|
||||
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
|
||||
["read committed", "READ COMMITTED"],
|
||||
["repeatable read", "REPEATABLE READ"],
|
||||
["serializable", "SERIALIZABLE"],
|
||||
]);
|
||||
|
||||
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
|
||||
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
|
||||
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
|
||||
|
||||
interface PreparedQuery {
|
||||
readonly config: QueryArrayConfig;
|
||||
readonly rowMode: "array" | "object";
|
||||
}
|
||||
|
||||
interface RawResult {
|
||||
readonly command: string | null;
|
||||
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
|
||||
readonly rowCount: number | null;
|
||||
readonly rows: readonly unknown[][];
|
||||
}
|
||||
|
||||
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
|
||||
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
|
||||
// object names, and every non-PostgreSQL error, are dropped here.
|
||||
if (cause instanceof DatabaseError && typeof cause.code === "string") {
|
||||
return new ModuleDatabaseError(code, { sqlState: cause.code });
|
||||
}
|
||||
return new ModuleDatabaseError(code);
|
||||
}
|
||||
|
||||
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
|
||||
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
|
||||
function connectionSurvives(error: unknown): boolean {
|
||||
return error instanceof DatabaseError;
|
||||
}
|
||||
|
||||
function prepareQuery(request: unknown): PreparedQuery {
|
||||
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(request)) {
|
||||
if (key !== "text" && key !== "values" && key !== "rowMode") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
}
|
||||
const { rowMode, text, values } = request as Record<string, unknown>;
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const config = {
|
||||
queryMode: "extended",
|
||||
rowMode: "array",
|
||||
text,
|
||||
types: rawTextTypes,
|
||||
values: encodeModuleDatabaseParameters(values),
|
||||
};
|
||||
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
|
||||
}
|
||||
|
||||
function beginStatement(options: unknown): string {
|
||||
if (options === undefined) {
|
||||
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
|
||||
}
|
||||
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(options)) {
|
||||
if (key !== "isolation" && key !== "readOnly") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
}
|
||||
const { isolation, readOnly } = options as Record<string, unknown>;
|
||||
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
|
||||
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
raw: RawResult,
|
||||
rowMode: "array" | "object",
|
||||
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
|
||||
if (typeof raw.command !== "string" || raw.command === "") {
|
||||
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const fields: readonly ModuleDatabaseField[] = Object.freeze(
|
||||
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
|
||||
);
|
||||
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
|
||||
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
|
||||
if (rowMode === "array") {
|
||||
rows = decoded;
|
||||
} else {
|
||||
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
|
||||
// Object rows cannot hold two columns with one name; refusing beats dropping one.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
|
||||
rows = decoded.map((row) =>
|
||||
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
command: raw.command,
|
||||
fields,
|
||||
rowCount: raw.rowCount ?? null,
|
||||
rows: Object.freeze(rows),
|
||||
});
|
||||
}
|
||||
|
||||
/** A borrowed client's `error` events are already reported through the statement they interrupt.
|
||||
* The pool only listens to idle clients, so without this a connection the server terminates while
|
||||
* borrowed would surface as an unhandled `error` event. */
|
||||
function ignoreClientError(): void {}
|
||||
|
||||
function destroy(client: AbiClient): void {
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release(true);
|
||||
}
|
||||
|
||||
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
|
||||
let client: AbiClient;
|
||||
try {
|
||||
client = (await pool.connect()) as AbiClient;
|
||||
} catch (error) {
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
client.on("error", ignoreClientError);
|
||||
if (typeof client.getTransactionStatus !== "function") {
|
||||
destroy(client);
|
||||
throw new ModuleDatabaseError(failure);
|
||||
}
|
||||
try {
|
||||
await client.query(pinSessionStatement);
|
||||
} catch (error) {
|
||||
destroy(client);
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
|
||||
* otherwise. Never throws. */
|
||||
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
|
||||
if (!healthy || client.getTransactionStatus() !== "I") {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.query(resetSessionStatement);
|
||||
} catch {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release();
|
||||
}
|
||||
|
||||
/** One open scope: a transaction or a savepoint inside it. */
|
||||
interface Scope {
|
||||
activeChild: Scope | undefined;
|
||||
readonly children: Set<Scope>;
|
||||
closed: boolean;
|
||||
/** Operations started through this scope's executor that have not settled yet. */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface TransactionState {
|
||||
readonly client: AbiClient;
|
||||
healthy: boolean;
|
||||
/** Set when the transaction can no longer be committed: transaction control sent through
|
||||
* `query`, or a savepoint that could not be undone. */
|
||||
failure: ModuleDatabaseError | undefined;
|
||||
finished: boolean;
|
||||
/** Settled-never-rejecting shadows of every statement sent on the connection. */
|
||||
readonly inflight: Set<Promise<void>>;
|
||||
savepoints: number;
|
||||
}
|
||||
|
||||
function newScope(parent?: Scope): Scope {
|
||||
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
|
||||
parent?.children.add(scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
function closeScope(scope: Scope): void {
|
||||
scope.closed = true;
|
||||
for (const child of scope.children) {
|
||||
closeScope(child);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActive(scope: Scope): void {
|
||||
if (scope.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if (scope.activeChild !== undefined) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
}
|
||||
}
|
||||
|
||||
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
|
||||
const shadow = promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
state.inflight.add(shadow);
|
||||
void shadow.then(() => state.inflight.delete(shadow));
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Waits until every statement already sent has settled, including any sent while waiting. */
|
||||
async function drain(state: TransactionState): Promise<void> {
|
||||
while (state.inflight.size > 0) {
|
||||
await Promise.all([...state.inflight]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
|
||||
* the transaction has finished. */
|
||||
async function control(state: TransactionState, statement: string): Promise<void> {
|
||||
if (state.finished) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
try {
|
||||
await track(state, state.client.query(statement));
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(state: TransactionState): Promise<void> {
|
||||
try {
|
||||
await state.client.query("ROLLBACK");
|
||||
} catch {
|
||||
state.healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
|
||||
|
||||
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
|
||||
try {
|
||||
return { value: await run() };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
const moduleByExecutor = new WeakMap<object, ModuleId>();
|
||||
|
||||
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
|
||||
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
|
||||
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
|
||||
return moduleByExecutor.get(executor);
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseExecutorOptions {
|
||||
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
|
||||
* ownership at run time (ADR 0050). */
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
|
||||
* nothing touches the database until the module calls it. The returned object, and every
|
||||
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
|
||||
*/
|
||||
export function createModuleDatabaseExecutor(
|
||||
pool: Pool,
|
||||
options: ModuleDatabaseExecutorOptions,
|
||||
): ModuleDatabaseExecutor {
|
||||
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
|
||||
if (!parsedModuleId.success) {
|
||||
throw new TypeError("A module database executor needs a valid module ID.");
|
||||
}
|
||||
const moduleId = parsedModuleId.data;
|
||||
|
||||
async function query(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
const prepared = prepareQuery(request);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
|
||||
let healthy = true;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await client.query(prepared.config)) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
client.getTransactionStatus() !== "I"
|
||||
) {
|
||||
// The connection is destroyed below if the statement left a transaction open.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
await relinquish(client, healthy);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
|
||||
async function scopeQuery(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
assertActive(scope);
|
||||
const prepared = prepareQuery(request);
|
||||
scope.pending += 1;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
state.client.getTransactionStatus() === "I"
|
||||
) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function savepoint<T>(
|
||||
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
assertActive(scope);
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
scope.pending += 1;
|
||||
const child = newScope(scope);
|
||||
scope.activeChild = child;
|
||||
state.savepoints += 1;
|
||||
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
|
||||
const name = `ekklesia_savepoint_${state.savepoints}`;
|
||||
try {
|
||||
await control(state, `SAVEPOINT ${name}`);
|
||||
if (child.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, child)));
|
||||
const outstanding = child.pending > 0;
|
||||
closeScope(child);
|
||||
await drain(state);
|
||||
if (scope.closed) {
|
||||
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if (outstanding || "error" in outcome) {
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
if (!outstanding) {
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
throw error;
|
||||
}
|
||||
if (outstanding) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
throw (outcome as { readonly error: unknown }).error;
|
||||
}
|
||||
try {
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch (releaseError) {
|
||||
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
|
||||
// transaction stays usable, and report that this savepoint did not complete.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
throw releaseError;
|
||||
}
|
||||
return (outcome as { readonly value: T }).value;
|
||||
} finally {
|
||||
closeScope(child);
|
||||
if (scope.activeChild === child) {
|
||||
scope.activeChild = undefined;
|
||||
}
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: scopeQuery as ModuleDatabaseQueryable["query"],
|
||||
savepoint,
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
|
||||
async function transaction<T>(
|
||||
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
options?: unknown,
|
||||
): Promise<T> {
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const begin = beginStatement(options);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
const state: TransactionState = {
|
||||
client,
|
||||
failure: undefined,
|
||||
finished: false,
|
||||
healthy: true,
|
||||
inflight: new Set(),
|
||||
savepoints: 0,
|
||||
};
|
||||
try {
|
||||
try {
|
||||
await client.query(begin);
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
const scope = newScope();
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
|
||||
const outstanding = scope.pending > 0;
|
||||
closeScope(scope);
|
||||
await drain(state);
|
||||
state.finished = true;
|
||||
|
||||
if (outstanding) {
|
||||
await rollback(state);
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
if ("error" in outcome) {
|
||||
await rollback(state);
|
||||
throw outcome.error;
|
||||
}
|
||||
if (state.failure !== undefined) {
|
||||
await rollback(state);
|
||||
throw state.failure;
|
||||
}
|
||||
let committed: { readonly command: string | null };
|
||||
try {
|
||||
committed = await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
if (committed.command !== "COMMIT") {
|
||||
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
|
||||
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
return outcome.value;
|
||||
} finally {
|
||||
state.finished = true;
|
||||
await relinquish(client, state.healthy);
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: query as ModuleDatabaseQueryable["query"],
|
||||
transaction: transaction as ModuleDatabaseExecutor["transaction"],
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./configuration.js";
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./database.js";
|
||||
export * from "./database-abi.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,833 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user