Files
Ekklesia/packages/contracts/src/module-database-abi.ts
T
ekklesiaandClaude Opus 5.5 317a4ce410
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:12:01 -05:00

232 lines
11 KiB
TypeScript

/**
* 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 resolves with work outstanding forgot an `await`: it fails with
* `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error.
* Either way, outstanding work never leads to a commit or a release.
*
* Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is
* forbidden through `query`; the callbacks control the transaction.
*/
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
/** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected
* one rolls back to it and keeps its own error. */
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; a rejected callback's own error is the transaction's error. Core never
* retries it. These guarantees apply to conforming use: no transaction-control SQL through
* `query`. */
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.
*
* The last two are only detectable once PostgreSQL has run the statement, so its effects may
* already have happened: a mutation with duplicate result columns has been applied (inside a
* transaction, it rolls back with it), and transaction control has taken effect, which Core cannot
* undo. */
"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 resolved while work it started was still outstanding: a
* forgotten `await`. The scope was rolled back, never committed or released. (A callback that
* rejects keeps its own error instead.) */
"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)))
);
}