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().toEqualTypeOf< "abiVersion" | "query" | "transaction" >(); expectTypeOf().toEqualTypeOf< "abiVersion" | "query" | "savepoint" >(); expectTypeOf().toEqualTypeOf<"abiVersion" | "query">(); expectTypeOf().toEqualTypeOf<1>(); expect(MODULE_DATABASE_ABI_VERSION).toBe(1); }); it("types array-mode rows positionally and object-mode rows by name", () => { expectTypeOf>["rows"][number]>().toEqualTypeOf< readonly ModuleDatabaseValue[] >(); expectTypeOf>["rows"][number]>().toEqualTypeOf< Readonly> >(); }); it("keeps parameter and result values to PostgreSQL wire-safe primitives", () => { expectTypeOf().not.toMatchTypeOf(); expectTypeOf>().not.toMatchTypeOf(); expectTypeOf().not.toMatchTypeOf(); expectTypeOf<[bigint, Uint8Array, [string | null]]>().toMatchTypeOf(); expectTypeOf().not.toMatchTypeOf(); expectTypeOf().not.toMatchTypeOf(); }); 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(); } }); });