feat: add module database ABI v1 adapters
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s

This commit is contained in:
ekklesia
2026-10-07 16:44:28 -05:00
parent 4a3958c616
commit 859675c3c7
18 changed files with 957 additions and 7 deletions
+85
View File
@@ -0,0 +1,85 @@
import type {
ModuleDatabaseExecutor,
ModuleDatabaseParameter,
ModuleDatabaseQueryable,
ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
import type { DrizzleConfig } from "drizzle-orm/utils";
type MaybePromise<T> = Promise<T> | T;
type DrizzleSurface<TSchema extends Record<string, unknown>> = Pick<
PgRemoteDatabase<TSchema>,
"delete" | "insert" | "select" | "update"
>;
export type ModuleDrizzleTransaction<
TSchema extends Record<string, unknown> = Record<string, never>,
> = DrizzleSurface<TSchema> & {
transaction<T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
): Promise<T>;
};
export type ModuleDrizzleDatabase<TSchema extends Record<string, unknown> = Record<string, never>> =
DrizzleSurface<TSchema> & {
transaction<T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
): Promise<T>;
};
type TransactionRunner<TSchema extends Record<string, unknown>> = <T>(
callback: (transaction: ModuleDrizzleTransaction<TSchema>) => MaybePromise<T>,
) => Promise<T>;
function remoteDatabase<TSchema extends Record<string, unknown>>(
executor: ModuleDatabaseQueryable,
config: DrizzleConfig<TSchema>,
runTransaction: TransactionRunner<TSchema>,
): ModuleDrizzleDatabase<TSchema> {
const database = drizzle<TSchema>(async (text, parameters) => {
const result = await executor.query({
text,
// Drizzle performs column-specific parameter encoding before this callback. Values outside
// ABI v1 remain unchanged so the ABI executor, not the adapter, rejects them.
values: parameters as readonly ModuleDatabaseParameter[],
// Drizzle maps selected fields by position. Array rows also remain correct when a join returns
// duplicate PostgreSQL column names.
rowMode: "array",
});
return { rows: result.rows as unknown as unknown[][] };
}, config);
return Object.freeze({
delete: database.delete.bind(database),
insert: database.insert.bind(database),
select: database.select.bind(database),
transaction: runTransaction,
update: database.update.bind(database),
}) as ModuleDrizzleDatabase<TSchema>;
}
function transactionDatabase<TSchema extends Record<string, unknown>>(
transaction: ModuleDatabaseTransaction,
config: DrizzleConfig<TSchema>,
): ModuleDrizzleTransaction<TSchema> {
return remoteDatabase(transaction, config, (callback) =>
transaction.savepoint((savepoint) => callback(transactionDatabase(savepoint, config))),
);
}
/**
* Creates a module-owned Drizzle database backed exclusively by Module Database ABI v1 calls.
*
* Drizzle compiles and decodes on the module side. Core receives only SQL text, ordered parameter
* values, and `rowMode: "array"`. Top-level transactions use the ABI transaction callback; nested
* Drizzle transactions use ABI savepoints.
*/
export function moduleDrizzle<TSchema extends Record<string, unknown> = Record<string, never>>(
executor: ModuleDatabaseExecutor,
config: DrizzleConfig<TSchema> = {},
): ModuleDrizzleDatabase<TSchema> {
return remoteDatabase(executor, config, (callback) =>
executor.transaction((transaction) => callback(transactionDatabase(transaction, config))),
);
}
+50
View File
@@ -0,0 +1,50 @@
import {
isModuleDatabaseError,
type ModuleDatabaseError,
type ModuleDatabaseParameter,
type ModuleDatabaseQuery,
} from "@ekklesia/contracts";
/** SQL text and values ready to pass directly to a Module Database ABI v1 executor. */
export type ModuleSqlQuery = Readonly<Required<Pick<ModuleDatabaseQuery, "text" | "values">>>;
/**
* Builds one PostgreSQL statement without ever interpolating a value into its text.
*
* Every interpolation becomes the next positional parameter. Dynamic identifiers and trusted SQL
* fragments are deliberately unsupported in v1: keep identifiers in the static template text.
*/
export function sql(
strings: TemplateStringsArray,
...values: readonly ModuleDatabaseParameter[]
): ModuleSqlQuery {
let text = strings[0] ?? "";
for (let index = 0; index < values.length; index += 1) {
text += `$${index + 1}${strings[index + 1] ?? ""}`;
}
return Object.freeze({ text, values: Object.freeze([...values]) });
}
export type EkklesiaDatabaseError = Pick<
ModuleDatabaseError,
"code" | "message" | "name" | "sqlState"
>;
/**
* Finds a stable Ekklesia database error through wrappers such as DrizzleQueryError.
*
* Callers inspect the returned `code` and `sqlState`; they never need a PostgreSQL driver error,
* `.cause.code`, or message parsing.
*/
export function findEkklesiaDatabaseError(value: unknown): EkklesiaDatabaseError | undefined {
const visited = new Set<unknown>();
let current = value;
while (current instanceof Error && !visited.has(current)) {
if (isModuleDatabaseError(current)) {
return current;
}
visited.add(current);
current = current.cause;
}
return undefined;
}