feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050) #69

Merged
Zayin_Admin merged 2 commits from feat/module-database-abi-executor into main 2026-10-07 21:25:16 +00:00
13 changed files with 3747 additions and 15 deletions
+349
View File
@@ -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;
});
}
+596
View File
@@ -0,0 +1,596 @@
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 the callbacks
*
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
* the transaction API apply to conforming use.
*/
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
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");
}
}
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
* transaction-control violation in particular, a later statement would otherwise run outside any
* transaction, as its own autocommitted statement. */
function assertUsable(state: TransactionState): void {
if (state.failure !== undefined) {
throw new ModuleDatabaseError(state.failure.code);
}
}
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then(
() => undefined,
() => 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"
) {
// A transaction-control violation. PostgreSQL has already run it; the connection is
// destroyed below rather than returned in a state Core did not choose.
healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
await relinquish(client, healthy);
}
}
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
async function scopeQuery(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope);
assertUsable(state);
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"
) {
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
// that; it can only never commit this transaction itself, and destroy the connection.
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
scope.pending -= 1;
}
}
async function savepoint<T>(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
assertActive(scope);
assertUsable(state);
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 ("error" in outcome) {
// A rejected callback's own error stays the error, even if sibling work it started was
// still settling: that work has been drained, and is undone here with everything else.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw outcome.error;
}
if (outstanding) {
// A callback that resolved with work outstanding forgot an await. Roll back to the
// savepoint and never release it.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
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 ("error" in outcome) {
// The callback's own error stays primary, even when sibling work it started (say, the
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
// back with the rest; outstanding work never leads to a commit.
await rollback(state);
throw outcome.error;
}
if (outstanding) {
// The callback resolved with work it never awaited: a forgotten `await`.
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (state.failure !== undefined) {
await rollback(state);
throw state.failure;
}
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;
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./configuration.js";
export * from "./configuration-schema.js"; export * from "./configuration-schema.js";
export * from "./configuration-store.js"; export * from "./configuration-store.js";
export * from "./database.js"; export * from "./database.js";
export * from "./database-abi.js";
export * from "./discovery.js"; export * from "./discovery.js";
export * from "./lifecycle.js"; export * from "./lifecycle.js";
export * from "./lifecycle-schema.js"; export * from "./lifecycle-schema.js";
File diff suppressed because it is too large Load Diff
+867
View File
@@ -0,0 +1,867 @@
import { readFile } from "node:fs/promises";
import {
isModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
import {
createModuleDatabaseExecutor,
moduleDatabaseExecutorModuleId,
} from "../src/modules/database-abi.js";
import {
decodeModuleDatabaseValue,
encodeModuleDatabaseParameters,
MODULE_DATABASE_MAX_PARAMETERS,
parsePostgresArrayText,
} from "../src/modules/database-abi-values.js";
/**
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
*
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
* transactions, savepoints, session settings) is proven against a real database in
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
*/
async function rejection(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error("Expected the operation to reject.");
}
async function expectAbiError(
promise: Promise<unknown>,
code: ModuleDatabaseErrorCode,
sqlState?: string,
): Promise<void> {
const error = await rejection(promise);
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code });
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
}
function syncThrows(run: () => unknown): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the call to throw.");
}
// --- Parameters --------------------------------------------------------------------------------
describe("encodeModuleDatabaseParameters", () => {
it("encodes every scalar category as text, or bytes, and nothing else", () => {
const bytes = new Uint8Array([0, 1, 255]);
const encoded = encodeModuleDatabaseParameters([
null,
true,
false,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
9_007_199_254_740_993n,
'it\'s \\ "quoted"',
bytes,
]);
expect(encoded.slice(0, 9)).toEqual([
null,
"true",
"false",
"1.5",
"NaN",
"Infinity",
"-Infinity",
"9007199254740993",
'it\'s \\ "quoted"',
]);
expect(Buffer.isBuffer(encoded[9])).toBe(true);
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
});
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
const bytes = new Uint8Array([1, 2, 3]);
const view = bytes.subarray(1);
const [encoded] = encodeModuleDatabaseParameters([view]);
bytes[1] = 9;
expect([...(encoded as Buffer)]).toEqual([2, 3]);
});
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
expect(
encodeModuleDatabaseParameters([
[1, null, 3],
['a"b', "c\\d", "", "NULL", "{,}"],
[
[1, 2],
[3, 4],
],
[new Uint8Array([0, 255])],
[true, 2n],
[],
]),
).toEqual([
'{"1",NULL,"3"}',
'{"a\\"b","c\\\\d","","NULL","{,}"}',
'{{"1","2"},{"3","4"}}',
'{"\\\\x00ff"}',
'{"true","2"}',
"{}",
]);
});
it("treats omitted values as no parameters", () => {
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
});
class Point {
readonly x = 1;
}
const refused: readonly [string, unknown][] = [
["a Date", new Date(0)],
["a plain object", { a: 1 }],
["a null-prototype object", Object.create(null)],
["undefined", undefined],
["a function", () => 1],
["a symbol", Symbol("s")],
["a class instance", new Point()],
["a Map", new Map()],
["another typed array", new Int16Array([1])],
["an ArrayBuffer", new ArrayBuffer(1)],
["a boxed string", new String("x")],
["an array holding a Date", [new Date(0)]],
["an array holding undefined", [1, undefined]],
["an array holding an object", [{ a: 1 }]],
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
["a sparse array", [1, , 3]],
["scalars mixed with sub-arrays", [1, [2]]],
["a null where a sub-array belongs", [[1], null]],
["ragged sub-arrays", [[1], [1, 2]]],
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
];
for (const [label, value] of refused) {
it(`refuses ${label}, without naming the value`, () => {
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
});
}
it("accepts exactly PostgreSQL's six array dimensions", () => {
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
});
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
for (const values of [
"x",
{ 0: "x", length: 1 },
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
]) {
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
}
expect(
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
});
it("refuses a self-referencing array instead of recursing forever", () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
});
});
});
// --- Results -----------------------------------------------------------------------------------
describe("decodeModuleDatabaseValue", () => {
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
"0.1000000000000000000001",
);
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
'{"a": 12345678901234567890}',
);
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
"2026-10-07 12:00:00+00",
);
// An unknown OID (an enum, a domain, an extension type) is text.
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
});
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
});
it("decodes built-in arrays element by element", () => {
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
"1",
null,
"9223372036854775807",
]);
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
[1, 2],
[3, 4],
]);
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
// An array of a type the table does not know arrives as text, like the type itself.
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
});
});
describe("parsePostgresArrayText", () => {
const text = (value: string) => value;
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
'a"b',
"c,d",
null,
"NULL",
"",
"{}",
null,
]);
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
});
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
});
it("refuses text that is not an array literal", () => {
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
}
});
});
// --- The executor, over a scripted client -------------------------------------------------------
interface ScriptedStatement {
readonly text: string;
readonly values?: readonly unknown[];
}
interface Script {
readonly pool: Pool;
readonly statements: ScriptedStatement[];
readonly released: (boolean | undefined)[];
/** Resolve a statement only when `release` is called, to model in-flight work. */
holdNext(): () => void;
failNext(error: unknown): void;
/** Fail the next statement whose text is exactly `text`. */
failWhen(text: string, error: unknown): void;
connectCount(): number;
}
function commandOf(text: string): string {
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
return word === "END" ? "COMMIT" : word;
}
/**
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
*/
function scriptedPool(): Script {
const statements: ScriptedStatement[] = [];
const released: (boolean | undefined)[] = [];
let status = "I";
let connects = 0;
let hold: Promise<void> | undefined;
let failure: unknown;
const failures = new Map<string, unknown>();
const client = {
getTransactionStatus: () => status,
on: () => client,
removeListener: () => client,
async query(config: string | { text: string; values?: unknown[] }) {
const text = typeof config === "string" ? config : config.text;
statements.push(
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
);
const held = hold;
hold = undefined;
if (held !== undefined) {
await held;
}
if (failure !== undefined || failures.has(text)) {
const error = failure ?? failures.get(text);
failure = undefined;
failures.delete(text);
if (status === "T") {
status = "E";
}
throw error;
}
const command = commandOf(text);
if (command === "BEGIN" || command === "START") {
status = "T";
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
const aborted = status === "E";
status = "I";
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
}
return {
command,
fields: [{ dataTypeID: 23, name: "n" }],
rowCount: 1,
rows: [["1"]],
};
},
release: (destroy?: boolean) => {
released.push(destroy);
},
};
return {
connectCount: () => connects,
failNext(error) {
failure = error;
},
failWhen(text, error) {
failures.set(text, error);
},
holdNext() {
let release!: () => void;
hold = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
pool: {
connect: vi.fn(async () => {
connects += 1;
return client;
}),
} as unknown as Pool,
released,
statements,
};
}
function pgError(code: string): DatabaseError {
const error = new DatabaseError(
'duplicate key value violates unique constraint "example_secret_key"',
100,
"error",
);
error.code = code;
error.detail = "Key (secret)=(hunter2) already exists.";
error.hint = "a hint";
error.table = "example_records";
error.constraint = "example_secret_key";
return error;
}
const pin =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
function texts(script: Script): string[] {
return script.statements.map((statement) => statement.text);
}
describe("createModuleDatabaseExecutor", () => {
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
expect(executor.abiVersion).toBe(1);
expect(Object.isFrozen(executor)).toBe(true);
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
expect(forbidden in executor).toBe(false);
}
expect(script.connectCount()).toBe(0);
});
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
expect(() =>
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
).toThrow(TypeError);
});
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
expect(result).toEqual({
command: "SELECT",
fields: [{ name: "n", typeOid: 23 }],
rowCount: 1,
rows: [{ n: 1 }],
});
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
expect(script.released).toEqual([undefined]);
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.rows)).toBe(true);
expect(Object.isFrozen(result.fields)).toBe(true);
});
it("refuses a malformed request before borrowing a connection", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const query = executor.query as (request: unknown) => Promise<unknown>;
for (const request of [
undefined,
null,
"SELECT 1",
[],
{ text: "" },
{ text: 1 },
{ text: "SELECT 1", rowMode: "rows" },
{ text: "SELECT 1", name: "prepared" },
{ text: "SELECT 1", types: [] },
]) {
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
}
await expectAbiError(
query({ text: "SELECT $1", values: [new Date()] }),
"MODULE_DATABASE_PARAMETER_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen("SELECT 1", pgError("23505"));
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
for (const secret of [
"hunter2",
"example_secret_key",
"example_records",
"duplicate",
"hint",
]) {
expect(serialized).not.toContain(secret);
}
expect((error as Error).cause).toBeUndefined();
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
expect(script.released).toEqual([undefined]);
});
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen(
"SELECT 1",
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
);
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
expect(JSON.stringify(error)).not.toContain("postgres://");
expect(script.released).toEqual([true]);
});
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
expect(script.released).toEqual([true]);
expect(texts(script)).not.toContain("DISCARD ALL");
});
});
describe("transaction options", () => {
const invalid: readonly unknown[] = [
null,
"serializable",
[],
{ isolation: "read uncommitted" },
{ isolation: "SERIALIZABLE" },
{ isolation: "constructor" },
{ readOnly: "yes" },
{ readOnly: 1 },
{ deferrable: true },
{ isolation: "serializable", retries: 3 },
];
for (const options of invalid) {
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const callback = vi.fn();
await expectAbiError(
executor.transaction(callback, options as never),
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
);
expect(callback).not.toHaveBeenCalled();
expect(script.connectCount()).toBe(0);
});
}
it("refuses a callback that is not a function", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(
executor.transaction("SELECT 1" as never),
"MODULE_DATABASE_REQUEST_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("begins every transaction with an explicit isolation level and access mode", async () => {
const cases: readonly [unknown, string][] = [
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
[
{ isolation: "serializable", readOnly: false },
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
],
];
for (const [options, begin] of cases) {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async () => undefined, options as never);
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
}
});
});
describe("transaction scopes", () => {
it("commits a resolved callback and returns its value", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const value = await executor.transaction(async (tx) => {
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
expect(Object.isFrozen(tx)).toBe(true);
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
return "done";
});
expect(value).toBe("done");
expect(texts(script).slice(1)).toEqual([
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
"INSERT INTO example_records VALUES (1)",
"COMMIT",
"DISCARD ALL",
]);
});
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
const error = await rejection(
executor.transaction(async (tx) => {
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
}),
);
expect(error).toBe(own);
expect(texts(script)).toContain("ROLLBACK");
expect(texts(script)).not.toContain("COMMIT");
});
it("revokes the executor when the callback settles; later use sends nothing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let escaped: ModuleDatabaseTransaction | undefined;
await executor.transaction((tx) => {
escaped = tx;
});
const sent = script.statements.length;
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await expectAbiError(
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_CLOSED",
);
expect(script.statements).toHaveLength(sent);
});
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction((tx) => {
releaseHeld = script.holdNext();
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// Core waits for the started statement to settle before rolling back the connection.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
await expect(forgotten).resolves.toBeDefined();
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction(async (tx) => {
releaseHeld = script.holdNext();
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// The sibling statement is drained before Core rolls back.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
expect(await rejection(outcome)).toBe(own);
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const error = await rejection(
executor.transaction(async (tx) => {
await expectAbiError(
tx.query({ text: "SAVEPOINT mine" }),
"MODULE_DATABASE_REQUEST_INVALID",
);
return "carried on";
}),
);
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script)).toContain("ROLLBACK");
// The connection is destroyed, never reset and reused.
expect(texts(script)).not.toContain("DISCARD ALL");
expect(script.released).toEqual([true]);
});
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const outcome = executor.transaction(async (tx) => {
script.failNext(pgError("22012"));
await expectAbiError(
tx.query({ text: "SELECT 1/0" }),
"MODULE_DATABASE_QUERY_FAILED",
"22012",
);
});
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
});
});
describe("savepoint scopes", () => {
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
"SAVEPOINT ekklesia_savepoint_2",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
"RELEASE SAVEPOINT ekklesia_savepoint_2",
]);
});
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await expectAbiError(
tx.savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_INACTIVE",
);
await sp.savepoint(async (inner) => {
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await inner.query({ text: "SELECT 2" });
});
await sp.query({ text: "SELECT 3" });
});
await tx.query({ text: "SELECT 4" });
});
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
expect(texts(script)).toContain("COMMIT");
});
it("revokes an escaped savepoint executor", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
let escaped: ModuleDatabaseTransaction | undefined;
await tx.savepoint((sp) => {
escaped = sp;
});
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await tx.query({ text: "SELECT 2" });
});
expect(texts(script)).toContain("COMMIT");
});
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await expectAbiError(
tx.savepoint((sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
]);
});
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
const error = await rejection(
tx.savepoint(async (sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
throw own;
}),
);
expect(error).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
]);
expect(texts(script)).toContain("COMMIT");
});
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
await expectAbiError(
executor.transaction((tx) => {
forgotten = tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
expect(texts(script).at(-1)).toBe("DISCARD ALL");
});
});
describe("the ABI's own sources", () => {
it("import no ORM, query builder, or ORM type", async () => {
for (const file of [
"../src/modules/database-abi.ts",
"../src/modules/database-abi-values.ts",
]) {
const source = await readFile(new URL(file, import.meta.url), "utf8");
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
expect(
specifiers.every((specifier) =>
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
),
).toBe(true);
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
}
});
});
@@ -1,6 +1,6 @@
# 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects # 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects
- Status: Proposed - Status: Accepted
- Date: 2026-10-07 - Date: 2026-10-07
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1, - Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
@@ -181,8 +181,13 @@ transaction(callback, options) → the callback's result
- `"object"` rows are keyed by column name. - `"object"` rows are keyed by column name.
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice, - `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
as joins do, needs this mode. as joins do, needs this mode.
- Core rejects an object-mode result that has duplicate column names, rather than silently - Duplicate column names are invalid in object mode. Core rejects such a result rather than
dropping one. silently dropping a column.
- With the current PostgreSQL and driver execution path, the result's shape is only known once
the statement has run. So a mutating statement can already have had its effects when the
result-shape error is reported.
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
transaction, where the error rolls the mutation back with everything else.
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`. - **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports - **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
none. none.
@@ -331,15 +336,37 @@ transaction(async (tx) => { … }, { isolation, readOnly })
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back, transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
the module throws. the module throws.
- **All work is awaited inside the callback.** Module code must await every database operation it - **All work is awaited inside the callback.** Module code must await every database operation it
starts through `tx` before its callback returns. starts through `tx` before its callback returns. What happens when work is still outstanding
- If the callback settles while an operation launched through that scope is still outstanding, the depends on how the callback settled:
module has used the scope incorrectly. - **Resolved with work outstanding.** The module forgot an `await` and has used the scope
- The transaction is then never committed: Core rolls it back, and the transaction call rejects incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
with the scope-usage `ModuleDatabaseError`. rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains. quietly changes what the transaction contains.
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
scope-usage error just because sibling work was still settling. This is what happens when one
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
- Either way, outstanding work never leads to a commit.
- Core may let operations that already started finish, or settle, before it rolls back and releases - Core may let operations that already started finish, or settle, before it rolls back and releases
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
never turns into a commit. never turns into a commit.
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
lifecycle only through the `transaction` and `savepoint` callbacks.
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
executes it before Core can see its result.
- Core detects a violation where the driver and protocol make that possible: from the statement's
command tag, and from the connection's transaction status. It then fails safely:
- the call rejects;
- the transaction's scopes refuse every later statement without sending it, because it would
otherwise run outside any transaction;
- Core never commits the enclosing transaction itself, and that transaction rejects too;
- the connection is destroyed rather than reused.
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
`COMMIT` sent through a transaction's `query` has already committed the work before it.
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has - **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
finished. Any later call on it fails with the finished. Any later call on it fails with the
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
@@ -362,9 +389,12 @@ tx.savepoint(async (sp) => { … })
- **Naming.** Core generates every savepoint identifier. Modules never provide one. - **Naming.** Core generates every savepoint identifier. Modules never provide one.
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back - **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
to it, releases it, and rejects with the callback's error. to it, releases it, and rejects with the callback's error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback - **Outstanding work.** The awaiting rule for transactions applies unchanged.
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled - A savepoint callback that resolves with operations still outstanding is a scope-usage error.
back, never released, and the call rejects with that error. The savepoint is rolled back, never released, and the call rejects with that error.
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
rejection.
- Either way, outstanding work never leads to a release.
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is - **One active scope.** Only the innermost open scope may run statements. While a savepoint is
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
overlap. overlap.
+1 -1
View File
@@ -58,4 +58,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed - [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted - [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted - [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Proposed - [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
+4 -2
View File
@@ -686,5 +686,7 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
and loads the backend once in a short-lived process with an empty environment. Only then does it and loads the backend once in a short-lived process with an empty environment. Only then does it
extract anything. extract anything.
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a - **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md), still proposed, defines the database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md) defines the Module Database ABI
Module Database ABI that removes it for modules that declare that ABI. that removes it for modules that declare that ABI. Core's ABI v1 executor
(`createModuleDatabaseExecutor`) exists and is proven against PostgreSQL. No module can declare the
ABI or receive the executor yet: that needs manifest version 8.
+30
View File
@@ -509,6 +509,36 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
answer "yes" to every human question, so any accidental fall-through into a human path would flip answer "yes" to every human question, so any accidental fall-through into a human path would flip
those assertions. those assertions.
A further suite (`core/tests/module-database-abi.integration.ts`, same harness) proves Core's Module
Database ABI v1 executor against real PostgreSQL
([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)). Nothing it asserts is mocked,
because every claim is a property of PostgreSQL or of the protocol:
- one statement per `query`, enforced by PostgreSQL's extended protocol rather than by parsing, so
semicolons inside literals, identifiers, and comments are never mistaken for a second statement;
- every allowed parameter category bound exactly, and every refused one refused before PostgreSQL
sees it;
- the decoding table for each type the ADR names;
- PostgreSQL's SQLSTATE preserved while its message, detail, constraint, and values are hidden;
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
committed;
- callback-scoped savepoints;
- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a
failed `Promise.all`) keeps its own error;
- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core
cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything
after, never commits, and destroys the connection;
- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction;
- revoked executors reaching nothing.
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
date style, `sql_standard` intervals, `escape` bytea output, and reduced float digits. So every
decoding assertion also proves the executor pins its own session settings. The pool-safety cases use
a one-connection pool, which proves the next borrower gets a connection with exactly the database's
defaults after every failure class: no transaction, setting, temporary table, listener, or advisory
lock carries over. The same is true after a connection the server terminated. Parameter validation,
decoding from text, option validation, error sanitization, and scope bookkeeping are also unit-tested
without a database in `core/tests/module-database-abi.test.ts`.
Outbound email is proven against a **real SMTP server** rather than a mock Outbound email is proven against a **real SMTP server** rather than a mock
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). (`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
`smtp-server` — the reference implementation from the same project as the client — runs in-process, `smtp-server` — the reference implementation from the same project as the client — runs in-process,
+1
View File
@@ -4,6 +4,7 @@ export * from "./identity.js";
export * from "./module-configuration.js"; export * from "./module-configuration.js";
export * from "./module-contribution.js"; export * from "./module-contribution.js";
export * from "./module-database.js"; export * from "./module-database.js";
export * from "./module-database-abi.js";
export * from "./module-http.js"; export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
@@ -0,0 +1,231 @@
/**
* Module Database ABI v1 (ADR 0050): the stable boundary between Core and a module's database code.
*
* Only PostgreSQL concepts cross it: SQL text, ordered bound parameter values, decoded result rows
* with their column metadata, and transaction scopes that Core controls. **Nothing in this file
* names an ORM, a query builder, or a driver.** A module may build its SQL with Drizzle, Kysely, raw
* text, or anything else, and Core never learns which; Core may change its own ORM without any
* module noticing.
*
* This is a correctness and compatibility boundary, **not a security sandbox**. Installed modules
* remain trusted backend code that runs in the API process with its database role (ADR 0048), and
* nothing here, including table prefixes, `SET ROLE`, or PostgreSQL roles, can stop a module from
* reaching data that is not its own.
*/
/** The ABI version these types describe. A module declares it; every executor reports it. */
export const MODULE_DATABASE_ABI_VERSION = 1;
export type ModuleDatabaseAbiVersion = typeof MODULE_DATABASE_ABI_VERSION;
/**
* A scalar parameter value. Deliberately small: each has exactly one PostgreSQL text encoding.
*
* - `number` is sent as its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as
* PostgreSQL's own spellings.
* - `bigint` is sent as its decimal text.
* - `Uint8Array` is sent as `bytea`.
*
* A JavaScript `Date` is **not** a parameter: whether it means a `timestamp` or a `timestamptz`, and
* in which zone, depends on a column the ABI cannot see. Plain objects are not implicit JSON; encode
* JSON explicitly as a string. Identifiers are never parameters.
*/
export type ModuleDatabaseScalar = null | boolean | number | bigint | string | Uint8Array;
/** A parameter value: a scalar, or a PostgreSQL array of scalars, nested for more dimensions. */
export type ModuleDatabaseParameter = ModuleDatabaseScalar | readonly ModuleDatabaseParameter[];
/**
* A decoded result value. Values that a generic conversion could change arrive as PostgreSQL's
* exact text: `int8`, `numeric`, every date and time type, `json`, and `jsonb`. `bytea` arrives as a
* `Uint8Array`, and arrays of the built-in types decode element by element.
*/
export type ModuleDatabaseValue =
| null
| boolean
| number
| string
| Uint8Array
| readonly ModuleDatabaseValue[];
/** `"object"` rows are keyed by column name; `"array"` rows are positional, in `fields` order. */
export type ModuleDatabaseRowMode = "object" | "array";
export type ModuleDatabaseObjectRow = Readonly<Record<string, ModuleDatabaseValue>>;
export type ModuleDatabaseArrayRow = readonly ModuleDatabaseValue[];
/** One query: exactly one PostgreSQL statement with `$1 … $n` placeholders. */
export interface ModuleDatabaseQuery {
readonly text: string;
readonly values?: readonly ModuleDatabaseParameter[];
/** Defaults to `"object"`. */
readonly rowMode?: ModuleDatabaseRowMode;
}
/** The ordered column metadata of a result, present even when it has no rows. */
export interface ModuleDatabaseField {
readonly name: string;
/** The column's PostgreSQL type OID. */
readonly typeOid: number;
}
export interface ModuleDatabaseQueryResult<Row> {
/** PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`. */
readonly command: string;
/** The number of rows PostgreSQL reports as processed, or `null` when it reports none. */
readonly rowCount: number | null;
readonly rows: readonly Row[];
readonly fields: readonly ModuleDatabaseField[];
}
/** What every executor can do: run one statement. */
export interface ModuleDatabaseQueryable {
readonly abiVersion: ModuleDatabaseAbiVersion;
query(
query: ModuleDatabaseQuery & { readonly rowMode: "array" },
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow>>;
query(
query: ModuleDatabaseQuery & { readonly rowMode?: "object" },
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow>>;
query(
query: ModuleDatabaseQuery,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>>;
}
export type ModuleDatabaseIsolationLevel = "read committed" | "repeatable read" | "serializable";
/** Transaction options. Any other key or value is refused, never ignored. Omitted, a transaction is
* `read committed` and read-write. */
export interface ModuleDatabaseTransactionOptions {
readonly isolation?: ModuleDatabaseIsolationLevel;
readonly readOnly?: boolean;
}
/**
* The executor inside a transaction or a savepoint. It is valid only while its callback runs: once
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL.
*
* Only the innermost open scope may run statements: while a savepoint is open, its parent refuses
* statements and new savepoints. A callback must await every operation it starts before it returns.
* One that resolves with work outstanding forgot an `await`: it fails with
* `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error.
* Either way, outstanding work never leads to a commit or a release.
*
* Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is
* forbidden through `query`; the callbacks control the transaction.
*/
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
/** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected
* one rolls back to it and keeps its own error. */
savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
}
/**
* A module's database service under ABI v1. Top-level `query` calls are separate autocommit
* statements on whichever pooled connection is free: no session state carries from one to the
* next, and none survives the operation that made it.
*/
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and
* rolls back otherwise; a rejected callback's own error is the transaction's error. Core never
* retries it. These guarantees apply to conforming use: no transaction-control SQL through
* `query`. */
transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: ModuleDatabaseTransactionOptions,
): Promise<T>;
}
/**
* Every failure category an ABI v1 executor reports. A code is a stable category for programmatic
* handling and keeps its meaning once published; the specific reason is in the static message.
* PostgreSQL's own condition, when there is one, is `sqlState`, a separate field.
*/
export const MODULE_DATABASE_ERROR_CODES = [
/** The call is not valid ABI use: a malformed query request or callback, a statement text that
* holds no statement, an object-mode result with duplicate column names, or a transaction-control
* statement sent through `query`. A programming error.
*
* The last two are only detectable once PostgreSQL has run the statement, so its effects may
* already have happened: a mutation with duplicate result columns has been applied (inside a
* transaction, it rolls back with it), and transaction control has taken effect, which Core cannot
* undo. */
"MODULE_DATABASE_REQUEST_INVALID",
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches
* PostgreSQL. */
"MODULE_DATABASE_PARAMETER_INVALID",
/** The statement did not complete. `sqlState` is present when PostgreSQL reported the condition;
* absent when the database could not be reached or the connection failed. */
"MODULE_DATABASE_QUERY_FAILED",
/** A transaction option or value is not one the ABI defines. Refused before the transaction
* starts. */
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
/** The transaction could not be begun or completed, or a savepoint could not be completed.
* `sqlState` is present when PostgreSQL reported the condition. A transaction left aborted by a
* failed statement ends here too: PostgreSQL rolls it back, and nothing is committed. */
"MODULE_DATABASE_TRANSACTION_FAILED",
/** The executor's scope has finished, so it is revoked. */
"MODULE_DATABASE_SCOPE_CLOSED",
/** The executor's scope is open but not active, because a savepoint inside it is open. */
"MODULE_DATABASE_SCOPE_INACTIVE",
/** A transaction or savepoint callback resolved while work it started was still outstanding: a
* forgotten `await`. The scope was rolled back, never committed or released. (A callback that
* rejects keeps its own error instead.) */
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
] as const;
export type ModuleDatabaseErrorCode = (typeof MODULE_DATABASE_ERROR_CODES)[number];
const databaseErrorMessages: Readonly<Record<ModuleDatabaseErrorCode, string>> = Object.freeze({
MODULE_DATABASE_PARAMETER_INVALID: "A database parameter value is not supported.",
MODULE_DATABASE_QUERY_FAILED: "The database statement did not complete.",
MODULE_DATABASE_REQUEST_INVALID: "The database request is not valid.",
MODULE_DATABASE_SCOPE_CLOSED: "The database scope has finished and can no longer be used.",
MODULE_DATABASE_SCOPE_INACTIVE: "The database scope cannot be used while a savepoint is open.",
MODULE_DATABASE_SCOPE_WORK_OUTSTANDING:
"The database scope finished with work still outstanding, so it was rolled back.",
MODULE_DATABASE_TRANSACTION_FAILED: "The database transaction could not be completed.",
MODULE_DATABASE_TRANSACTION_OPTION_INVALID: "A database transaction option is not supported.",
});
const sqlStatePattern = /^[0-9A-Z]{5}$/u;
/**
* The one error an ABI v1 executor reports, sanitized by construction: a stable `code`, PostgreSQL's
* `sqlState` when the database supplied one, and a static message. It never carries SQL text,
* parameter values, the driver's message, detail, hint, or object names, a cause, a stack beyond
* its own name and message, or any driver or ORM object.
*
* Recognize it with `isModuleDatabaseError`, never `instanceof`: a module bundles its own copy of
* this package (ADR 0049), so class identity across the boundary is not a contract.
*/
export class ModuleDatabaseError extends Error {
readonly code: ModuleDatabaseErrorCode;
/** Declared, not initialized, so the property exists only when PostgreSQL supplied a SQLSTATE. */
declare readonly sqlState?: string;
constructor(code: ModuleDatabaseErrorCode, options: { readonly sqlState?: string } = {}) {
super(databaseErrorMessages[code]);
this.name = "ModuleDatabaseError";
this.code = code;
if (options.sqlState !== undefined && sqlStatePattern.test(options.sqlState)) {
this.sqlState = options.sqlState;
}
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** Whether `value` is a Module Database ABI error, judged by its shape rather than its class. */
export function isModuleDatabaseError(
value: unknown,
): value is Pick<ModuleDatabaseError, "code" | "message" | "name" | "sqlState"> {
if (!(value instanceof Error) || value.name !== "ModuleDatabaseError") {
return false;
}
const { code, sqlState } = value as { code?: unknown; sqlState?: unknown };
return (
typeof code === "string" &&
(MODULE_DATABASE_ERROR_CODES as readonly string[]).includes(code) &&
(sqlState === undefined || (typeof sqlState === "string" && sqlStatePattern.test(sqlState)))
);
}
@@ -0,0 +1,176 @@
import { readFile } from "node:fs/promises";
import { describe, expect, expectTypeOf, it } from "vitest";
import {
isModuleDatabaseError,
MODULE_DATABASE_ABI_VERSION,
MODULE_DATABASE_ERROR_CODES,
type ModuleDatabaseError,
ModuleDatabaseError as ModuleDatabaseErrorClass,
type ModuleDatabaseExecutor,
type ModuleDatabaseParameter,
type ModuleDatabaseQueryable,
type ModuleDatabaseTransaction,
type ModuleDatabaseValue,
} from "../src/index.js";
/**
* Never invoked: proves at typecheck time that the ABI's executors carry exactly the ABI's members,
* and that no ORM, driver, pool, or client escape hatch is reachable through their types. Re-adding
* any of them would make an `@ts-expect-error` below unused, failing `pnpm typecheck`.
*/
function typeOnlyExclusions(
executor: ModuleDatabaseExecutor,
transaction: ModuleDatabaseTransaction,
): void {
// @ts-expect-error No raw pg pool or client.
void executor.pool;
// @ts-expect-error No raw pg client.
void executor.$client;
// @ts-expect-error No ORM internals.
void executor._;
// @ts-expect-error No query builder.
void executor.select;
// @ts-expect-error No relational query object.
void executor.execute;
// @ts-expect-error A transaction executor has no rollback(); a module throws instead.
void transaction.rollback;
// @ts-expect-error Nested transactions are savepoints, not transaction().
void transaction.transaction;
// @ts-expect-error Savepoints are not named by modules.
void transaction.savepoint("name", async () => undefined);
// @ts-expect-error A Date is not an ABI parameter.
void executor.query({ text: "SELECT $1", values: [new Date()] });
// @ts-expect-error A plain object is not implicit JSON.
void executor.query({ text: "SELECT $1", values: [{ a: 1 }] });
// @ts-expect-error undefined is not a parameter.
void executor.query({ text: "SELECT $1", values: [undefined] });
}
void typeOnlyExclusions;
describe("Module Database ABI v1 types", () => {
it("names exactly the ABI's members on every executor", () => {
expectTypeOf<keyof ModuleDatabaseExecutor>().toEqualTypeOf<
"abiVersion" | "query" | "transaction"
>();
expectTypeOf<keyof ModuleDatabaseTransaction>().toEqualTypeOf<
"abiVersion" | "query" | "savepoint"
>();
expectTypeOf<keyof ModuleDatabaseQueryable>().toEqualTypeOf<"abiVersion" | "query">();
expectTypeOf<ModuleDatabaseExecutor["abiVersion"]>().toEqualTypeOf<1>();
expect(MODULE_DATABASE_ABI_VERSION).toBe(1);
});
it("types array-mode rows positionally and object-mode rows by name", () => {
expectTypeOf<Awaited<ReturnType<typeof arrayQuery>>["rows"][number]>().toEqualTypeOf<
readonly ModuleDatabaseValue[]
>();
expectTypeOf<Awaited<ReturnType<typeof objectQuery>>["rows"][number]>().toEqualTypeOf<
Readonly<Record<string, ModuleDatabaseValue>>
>();
});
it("keeps parameter and result values to PostgreSQL wire-safe primitives", () => {
expectTypeOf<Date>().not.toMatchTypeOf<ModuleDatabaseParameter>();
expectTypeOf<Record<string, number>>().not.toMatchTypeOf<ModuleDatabaseParameter>();
expectTypeOf<undefined>().not.toMatchTypeOf<ModuleDatabaseParameter>();
expectTypeOf<[bigint, Uint8Array, [string | null]]>().toMatchTypeOf<ModuleDatabaseParameter>();
expectTypeOf<bigint>().not.toMatchTypeOf<ModuleDatabaseValue>();
expectTypeOf<Date>().not.toMatchTypeOf<ModuleDatabaseValue>();
});
it("imports nothing at all, so no ORM or driver type can reach the ABI", async () => {
const source = await readFile(
new URL("../src/module-database-abi.ts", import.meta.url),
"utf8",
);
expect(source).not.toMatch(/^\s*import\b/mu);
expect(source).not.toMatch(/\bfrom\s+["']/u);
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/u);
});
});
function arrayQuery(executor: ModuleDatabaseExecutor) {
return executor.query({ rowMode: "array", text: "SELECT 1" });
}
function objectQuery(executor: ModuleDatabaseExecutor) {
return executor.query({ text: "SELECT 1" });
}
describe("ModuleDatabaseError", () => {
it("separates the Ekklesia category from PostgreSQL's own SQLSTATE", () => {
const failed = new ModuleDatabaseErrorClass("MODULE_DATABASE_QUERY_FAILED", {
sqlState: "23505",
});
expect(failed.code).toBe("MODULE_DATABASE_QUERY_FAILED");
expect(failed.sqlState).toBe("23505");
const closed = new ModuleDatabaseErrorClass("MODULE_DATABASE_SCOPE_CLOSED");
expect(closed.code).toBe("MODULE_DATABASE_SCOPE_CLOSED");
expect(closed.sqlState).toBeUndefined();
expect("sqlState" in closed).toBe(false);
});
it("never records anything but a five-character SQLSTATE", () => {
for (const candidate of ["EPIPE1", "2350", "23505 ", "duplicate key", "econn"]) {
const error = new ModuleDatabaseErrorClass("MODULE_DATABASE_QUERY_FAILED", {
sqlState: candidate,
});
expect(error.sqlState).toBeUndefined();
}
});
it("is sanitized by construction: a static message, its own stack line, no cause, frozen", () => {
for (const code of MODULE_DATABASE_ERROR_CODES) {
const error = new ModuleDatabaseErrorClass(code, { sqlState: "40001" });
expect(error.name).toBe("ModuleDatabaseError");
expect(error.stack).toBe(`ModuleDatabaseError: ${error.message}`);
expect(error.cause).toBeUndefined();
expect(Object.keys(error).sort()).toEqual(["code", "name", "sqlState"]);
expect(Object.isFrozen(error)).toBe(true);
expect(error.message).not.toMatch(/select|insert|\$1|password|postgres:\/\//iu);
}
});
it("has one distinct message per code", () => {
const messages = MODULE_DATABASE_ERROR_CODES.map(
(code) => new ModuleDatabaseErrorClass(code).message,
);
expect(new Set(messages).size).toBe(MODULE_DATABASE_ERROR_CODES.length);
});
it("is recognized by shape, so a module's own bundled copy of this package agrees", () => {
const fromAnotherCopy = Object.assign(new Error("The database request is not valid."), {
code: "MODULE_DATABASE_REQUEST_INVALID",
name: "ModuleDatabaseError",
});
expect(isModuleDatabaseError(fromAnotherCopy)).toBe(true);
expect(
isModuleDatabaseError(new ModuleDatabaseErrorClass("MODULE_DATABASE_SCOPE_CLOSED")),
).toBe(true);
expect(
isModuleDatabaseError({ code: "MODULE_DATABASE_SCOPE_CLOSED", name: "ModuleDatabaseError" }),
).toBe(false);
expect(
isModuleDatabaseError(
Object.assign(new Error("x"), { code: "SOMETHING_ELSE", name: "ModuleDatabaseError" }),
),
).toBe(false);
expect(
isModuleDatabaseError(
Object.assign(new Error("x"), {
code: "MODULE_DATABASE_QUERY_FAILED",
name: "ModuleDatabaseError",
sqlState: "not a sqlstate",
}),
),
).toBe(false);
expect(isModuleDatabaseError(Object.assign(new Error("x"), { code: "23505" }))).toBe(false);
const narrowed: unknown = fromAnotherCopy;
if (isModuleDatabaseError(narrowed)) {
expectTypeOf(narrowed.code).toEqualTypeOf<ModuleDatabaseError["code"]>();
}
});
});
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
"core/tests/module-configuration-store.integration.ts", "core/tests/module-configuration-store.integration.ts",
"core/tests/module-storage.integration.ts", "core/tests/module-storage.integration.ts",
"core/tests/module-delivery.integration.ts", "core/tests/module-delivery.integration.ts",
"core/tests/module-database-abi.integration.ts",
"apps/api/tests/api-http.integration.ts", "apps/api/tests/api-http.integration.ts",
"apps/api/tests/initial-owner.integration.ts", "apps/api/tests/initial-owner.integration.ts",
"apps/api/tests/device-pairing.integration.ts", "apps/api/tests/device-pairing.integration.ts",