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]>
177 lines
7.0 KiB
TypeScript
177 lines
7.0 KiB
TypeScript
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"]>();
|
|
}
|
|
});
|
|
});
|