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 = 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 = 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; }); }