feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)
CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.
Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
{ command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
frozen; isModuleDatabaseError recognises it by shape.
Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
protocol so PostgreSQL itself refuses a second statement (42601);
no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
registry: int8, numeric, temporal, and JSON as exact text; bytea as a
plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
and extra_float_digits, then resets with DISCARD ALL before returning
the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
committed or released;
- transaction-control statements sent through query are refused by
command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.
Tests:
- 83 integration tests on a database with hostile defaults: queries,
the one-statement rule, parameters, decoding, errors, transactions,
savepoints, forgotten awaits, pool safety after every failure class,
and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
bookkeeping;
- type-level and source checks that no ORM or driver object crosses
the boundary.
ADR 0050 is Accepted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d43031997b
commit
6db50516b7
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type ModuleDatabaseField,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseValue,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
|
||||
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
|
||||
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
|
||||
*
|
||||
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
|
||||
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
|
||||
* executor for every ABI operation (`./database-abi.ts`).
|
||||
*/
|
||||
|
||||
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
|
||||
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
|
||||
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
|
||||
const maximumArrayDimensions = 6;
|
||||
|
||||
function parameterInvalid(): ModuleDatabaseError {
|
||||
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||
}
|
||||
|
||||
function isScalar(value: unknown): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "bigint" ||
|
||||
typeof value === "string" ||
|
||||
value instanceof Uint8Array
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: boolean | number | bigint | string): string {
|
||||
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
|
||||
// accepts them.
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function byteaText(value: Uint8Array): string {
|
||||
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
|
||||
}
|
||||
|
||||
function quoteArrayElement(text: string): string {
|
||||
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
|
||||
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
|
||||
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
|
||||
* type.
|
||||
*/
|
||||
function arrayLiteral(values: readonly unknown[], depth: number): string {
|
||||
if (depth > maximumArrayDimensions) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const elements: string[] = [];
|
||||
let nestedLength: number | undefined;
|
||||
let nested: boolean | undefined;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const element = values[index];
|
||||
const isNested = Array.isArray(element);
|
||||
if (nested === undefined) {
|
||||
nested = isNested;
|
||||
} else if (nested !== isNested) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
if (isNested) {
|
||||
if (nestedLength === undefined) {
|
||||
nestedLength = element.length;
|
||||
} else if (nestedLength !== element.length) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
elements.push(arrayLiteral(element, depth + 1));
|
||||
} else if (element === null) {
|
||||
elements.push("NULL");
|
||||
} else if (element instanceof Uint8Array) {
|
||||
elements.push(quoteArrayElement(byteaText(element)));
|
||||
} else if (isScalar(element)) {
|
||||
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return `{${elements.join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
|
||||
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
|
||||
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
|
||||
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
|
||||
* anything reaches PostgreSQL. The error never names the value.
|
||||
*/
|
||||
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const encoded: (string | Buffer | null)[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const value: unknown = values[index];
|
||||
if (value === null) {
|
||||
encoded.push(null);
|
||||
} else if (value instanceof Uint8Array) {
|
||||
// A copy: the driver serializes when the statement is sent, which may be after the caller
|
||||
// has changed its own bytes.
|
||||
encoded.push(Buffer.from(value));
|
||||
} else if (Array.isArray(value)) {
|
||||
encoded.push(arrayLiteral(value, 1));
|
||||
} else if (isScalar(value)) {
|
||||
encoded.push(scalarText(value as boolean | number | bigint | string));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
|
||||
|
||||
const asText: ScalarDecoder = (text) => text;
|
||||
const asBoolean: ScalarDecoder = (text) => text === "t";
|
||||
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
|
||||
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
|
||||
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
|
||||
const asFloat: ScalarDecoder = (text) => Number(text);
|
||||
|
||||
function hexDigit(code: number): number {
|
||||
if (code >= 48 && code <= 57) {
|
||||
return code - 48;
|
||||
}
|
||||
if (code >= 97 && code <= 102) {
|
||||
return code - 87;
|
||||
}
|
||||
if (code >= 65 && code <= 70) {
|
||||
return code - 55;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
|
||||
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
|
||||
* Always a plain `Uint8Array`, never a `Buffer`.
|
||||
*/
|
||||
const asBytes: ScalarDecoder = (text) => {
|
||||
if (text.startsWith("\\x")) {
|
||||
const length = (text.length - 2) / 2;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const high = hexDigit(text.charCodeAt(2 + index * 2));
|
||||
const low = hexDigit(text.charCodeAt(3 + index * 2));
|
||||
bytes[index] = high * 16 + low;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code === 92 && text.charCodeAt(index + 1) === 92) {
|
||||
bytes.push(92);
|
||||
index += 1;
|
||||
} else if (code === 92) {
|
||||
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
|
||||
index += 3;
|
||||
} else {
|
||||
bytes.push(code);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
|
||||
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
|
||||
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
|
||||
* arrives as its exact text. */
|
||||
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
|
||||
[16, asBoolean], // bool
|
||||
[17, asBytes], // bytea
|
||||
[21, asInteger], // int2
|
||||
[23, asInteger], // int4
|
||||
[26, asInteger], // oid
|
||||
[700, asFloat], // float4
|
||||
[701, asFloat], // float8
|
||||
]);
|
||||
|
||||
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
|
||||
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
|
||||
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
|
||||
[1000, 16], // bool[]
|
||||
[1001, 17], // bytea[]
|
||||
[1002, 18], // "char"[]
|
||||
[1003, 19], // name[]
|
||||
[1005, 21], // int2[]
|
||||
[1007, 23], // int4[]
|
||||
[1009, 25], // text[]
|
||||
[1014, 1042], // bpchar[]
|
||||
[1015, 1043], // varchar[]
|
||||
[1016, 20], // int8[]
|
||||
[1021, 700], // float4[]
|
||||
[1022, 701], // float8[]
|
||||
[1028, 26], // oid[]
|
||||
[1115, 1114], // timestamp[]
|
||||
[1182, 1082], // date[]
|
||||
[1183, 1083], // time[]
|
||||
[1185, 1184], // timestamptz[]
|
||||
[1187, 1186], // interval[]
|
||||
[1231, 1700], // numeric[]
|
||||
[1270, 1266], // timetz[]
|
||||
[2951, 2950], // uuid[]
|
||||
[199, 114], // json[]
|
||||
[3807, 3802], // jsonb[]
|
||||
]);
|
||||
|
||||
class ArrayTextError extends Error {}
|
||||
|
||||
/**
|
||||
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
|
||||
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
|
||||
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
|
||||
* decodes uses `,` as its delimiter.
|
||||
*/
|
||||
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
|
||||
let position = 0;
|
||||
if (text.startsWith("[")) {
|
||||
const equals = text.indexOf("=");
|
||||
if (equals === -1) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position = equals + 1;
|
||||
}
|
||||
|
||||
function parseArray(): ModuleDatabaseValue[] {
|
||||
if (text[position] !== "{") {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position += 1;
|
||||
const items: ModuleDatabaseValue[] = [];
|
||||
if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
}
|
||||
for (;;) {
|
||||
const character = text[position];
|
||||
if (character === "{") {
|
||||
items.push(parseArray());
|
||||
} else if (character === '"') {
|
||||
position += 1;
|
||||
let element = "";
|
||||
for (;;) {
|
||||
const next = text[position];
|
||||
if (next === undefined) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
if (next === "\\") {
|
||||
element += text[position + 1] ?? "";
|
||||
position += 2;
|
||||
} else if (next === '"') {
|
||||
position += 1;
|
||||
break;
|
||||
} else {
|
||||
element += next;
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
items.push(decode(element));
|
||||
} else {
|
||||
const start = position;
|
||||
while (position < text.length && text[position] !== "," && text[position] !== "}") {
|
||||
position += 1;
|
||||
}
|
||||
const element = text.slice(start, position);
|
||||
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
|
||||
}
|
||||
if (text[position] === ",") {
|
||||
position += 1;
|
||||
} else if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
} else {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseArray();
|
||||
if (position !== text.length) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function scalarDecoder(typeOid: number): ScalarDecoder {
|
||||
return scalarDecoders.get(typeOid) ?? asText;
|
||||
}
|
||||
|
||||
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
|
||||
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
|
||||
const elementType = arrayElementTypes.get(typeOid);
|
||||
if (elementType === undefined) {
|
||||
return scalarDecoder(typeOid)(text);
|
||||
}
|
||||
try {
|
||||
return parsePostgresArrayText(text, scalarDecoder(elementType));
|
||||
} catch {
|
||||
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
|
||||
// arrives faithfully, as text, rather than as a guess.
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
|
||||
* every type, so the driver's global registry, which module code could change, is never consulted
|
||||
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
|
||||
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
|
||||
* parameter type from the statement.
|
||||
*/
|
||||
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
|
||||
Object.freeze(
|
||||
Object.assign([] as never[], {
|
||||
getTypeParser: () => (text: string) => text,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Decodes one positional row of raw driver text. */
|
||||
export function decodeModuleDatabaseRow(
|
||||
fields: readonly ModuleDatabaseField[],
|
||||
raw: readonly unknown[],
|
||||
): ModuleDatabaseValue[] {
|
||||
return fields.map((field, index) => {
|
||||
const value = raw[index];
|
||||
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user