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
@@ -4,6 +4,7 @@ export * from "./identity.js";
|
||||
export * from "./module-configuration.js";
|
||||
export * from "./module-contribution.js";
|
||||
export * from "./module-database.js";
|
||||
export * from "./module-database-abi.js";
|
||||
export * from "./module-http.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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 settles with work outstanding is never committed or released.
|
||||
*/
|
||||
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
|
||||
/** Core names the savepoint. A resolved callback releases it; a rejected one rolls back to it. */
|
||||
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. Core never retries it. */
|
||||
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. */
|
||||
"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 settled while work it started was still outstanding. The
|
||||
* scope was rolled back, never committed or released. */
|
||||
"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"]>();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user