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
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@ekklesia/module-database",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./drizzle": {
"types": "./dist/drizzle.d.ts",
"import": "./dist/drizzle.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-database",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"drizzle-orm": "0.45.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-sdk"
},
"peerDependencies": {
"drizzle-orm": ">=0.45.2 <0.46.0"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
}
+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;
}
@@ -0,0 +1,46 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const workspaceRoot = path.resolve(packageRoot, "..", "..");
async function text(relative: string): Promise<string> {
return readFile(path.resolve(workspaceRoot, relative), "utf8");
}
describe("the module-side database architecture", () => {
it("keeps the Drizzle adapter free of Core, pg, and Core-owned Drizzle types", async () => {
const source = await text("packages/module-database/src/drizzle.ts");
expect(source).not.toMatch(/@ekklesia\/core|from ["']pg["']|node-postgres/u);
expect(source).not.toMatch(/NodePgDatabase|NodePgTransaction/u);
expect(source).toContain("@ekklesia/contracts");
expect(source).toContain("drizzle-orm/pg-proxy");
});
it("keeps ABI v1 contracts free of ORM imports", async () => {
const source = await text("packages/contracts/src/module-database-abi.ts");
expect(source).not.toMatch(/(?:import|export).*drizzle-orm/u);
expect(source).not.toMatch(/(?:import|export).*kysely/u);
expect(source).not.toMatch(/(?:import|export).*\bpg\b/u);
});
it("makes Drizzle an optional ranged peer and keeps raw SQL independent from it", async () => {
const manifest = JSON.parse(await text("packages/module-database/package.json")) as Record<
string,
Record<string, string>
>;
expect(manifest.dependencies).not.toHaveProperty("drizzle-orm");
expect(manifest.peerDependencies?.["drizzle-orm"]).toBe(">=0.45.2 <0.46.0");
expect(manifest.peerDependenciesMeta?.["drizzle-orm"]).toEqual({ optional: true });
const rawSource = await text("packages/module-database/src/index.ts");
expect(rawSource).not.toContain("drizzle-orm");
});
it("does not introduce an adapter dependency into Core", async () => {
const manifest = await text("core/package.json");
expect(manifest).not.toContain("@ekklesia/module-database");
});
});
@@ -0,0 +1,415 @@
import {
type ModuleDatabaseArrayRow,
ModuleDatabaseError,
type ModuleDatabaseExecutor,
type ModuleDatabaseField,
type ModuleDatabaseObjectRow,
type ModuleDatabaseQuery,
type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { and, eq, sql as drizzleSql } from "drizzle-orm";
import {
bigint,
customType,
date,
integer,
json,
jsonb,
numeric,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { describe, expect, expectTypeOf, it } from "vitest";
import { moduleDrizzle, type ModuleDrizzleDatabase } from "../src/drizzle.js";
import { findEkklesiaDatabaseError } from "../src/index.js";
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
dataType: () => "bytea",
});
const records = pgTable("calendar_records", {
amount: numeric("amount"),
bigId: bigint("big_id", { mode: "bigint" }).notNull(),
bytes: bytea("bytes"),
count: integer("count").notNull(),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).notNull(),
day: date("day", { mode: "date" }).notNull(),
id: integer("id").primaryKey(),
localAt: timestamp("local_at", { mode: "date" }).notNull(),
metadata: jsonb("metadata"),
nullable: text("nullable"),
payload: json("payload"),
scores: bigint("scores", { mode: "bigint" }).array(),
tags: text("tags").array(),
title: text("title").notNull(),
});
const categories = pgTable("calendar_categories", {
id: integer("id").primaryKey(),
title: text("title").notNull(),
});
type Outcome = Error | ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>;
interface RecordedCall {
readonly query: ModuleDatabaseQuery;
readonly scope: string;
}
const noFields: readonly ModuleDatabaseField[] = [];
function result(
rows: readonly ModuleDatabaseArrayRow[],
command = "SELECT",
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow> {
return { command, fields: noFields, rowCount: rows.length, rows };
}
class FakeState {
readonly calls: RecordedCall[] = [];
readonly events: string[] = [];
readonly outcomes: Outcome[] = [];
}
class FakeTransaction implements ModuleDatabaseTransaction {
readonly abiVersion = 1;
constructor(
private readonly state: FakeState,
private readonly scope: string,
) {}
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>>;
async query(
query: ModuleDatabaseQuery,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>> {
this.state.calls.push({ query, scope: this.scope });
this.state.events.push(`${this.scope}:query`);
const outcome = this.state.outcomes.shift() ?? result([]);
if (outcome instanceof Error) {
throw outcome;
}
return outcome;
}
async savepoint<T>(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
const scope = `${this.scope}.savepoint`;
this.state.events.push(`${scope}:begin`);
try {
const value = await callback(new FakeTransaction(this.state, scope));
this.state.events.push(`${scope}:release`);
return value;
} catch (error) {
this.state.events.push(`${scope}:rollback`);
throw error;
}
}
}
class FakeExecutor implements ModuleDatabaseExecutor {
readonly abiVersion = 1;
readonly state = new FakeState();
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>>;
async query(
query: ModuleDatabaseQuery,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>> {
return new FakeTransaction(this.state, "executor").query(query);
}
async transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
this.state.events.push("transaction:begin");
try {
const value = await callback(new FakeTransaction(this.state, "transaction"));
this.state.events.push("transaction:commit");
return value;
} catch (error) {
this.state.events.push("transaction:rollback");
throw error;
}
}
}
function setup(...outcomes: Outcome[]) {
const executor = new FakeExecutor();
executor.state.outcomes.push(...outcomes);
return { db: moduleDrizzle(executor), executor };
}
describe("moduleDrizzle query compilation and ABI mapping", () => {
it("compiles a select and decodes its positional result on the module side", async () => {
const { db, executor } = setup(result([[7, "Launch"]]));
await expect(
db.select({ id: records.id, title: records.title }).from(records).where(eq(records.id, 7)),
).resolves.toEqual([{ id: 7, title: "Launch" }]);
expect(executor.state.calls).toEqual([
{
query: {
text: 'select "id", "title" from "calendar_records" where "calendar_records"."id" = $1',
values: [7],
rowMode: "array",
},
scope: "executor",
},
]);
});
it("compiles insert returning through the array-row ABI", async () => {
const { db, executor } = setup(result([[8, "New"]], "INSERT"));
await expect(
db
.insert(records)
.values({
bigId: 8n,
count: 1,
createdAt: new Date("2026-01-02T03:04:05.000Z"),
day: new Date("2026-01-02T00:00:00.000Z"),
id: 8,
localAt: new Date("2026-01-02T03:04:05.000Z"),
title: "New",
})
.returning({ id: records.id, title: records.title }),
).resolves.toEqual([{ id: 8, title: "New" }]);
expect(executor.state.calls[0]?.query).toMatchObject({
rowMode: "array",
values: [
8n,
1,
"2026-01-02T03:04:05.000Z",
"2026-01-02T00:00:00.000Z",
8,
"2026-01-02T03:04:05.000Z",
"New",
],
});
expect(executor.state.calls[0]?.query.text).toContain('insert into "calendar_records"');
expect(executor.state.calls[0]?.query.text).toContain('returning "id", "title"');
});
it("compiles an update returning with a SQL expression", async () => {
const { db, executor } = setup(result([[7, 4]], "UPDATE"));
await expect(
db
.update(records)
.set({ count: drizzleSql`${records.count} + 1` })
.where(and(eq(records.id, 7), eq(records.title, "Launch")))
.returning({ count: records.count, id: records.id }),
).resolves.toEqual([{ count: 7, id: 4 }]);
expect(executor.state.calls[0]?.query).toEqual({
text: 'update "calendar_records" set "count" = "calendar_records"."count" + 1 where ("calendar_records"."id" = $1 and "calendar_records"."title" = $2) returning "count", "id"',
values: [7, "Launch"],
rowMode: "array",
});
});
it("compiles delete returning", async () => {
const { db, executor } = setup(result([[7, "Old"]], "DELETE"));
await expect(
db
.delete(records)
.where(eq(records.id, 7))
.returning({ id: records.id, title: records.title }),
).resolves.toEqual([{ id: 7, title: "Old" }]);
expect(executor.state.calls[0]?.query).toEqual({
text: 'delete from "calendar_records" where "calendar_records"."id" = $1 returning "id", "title"',
values: [7],
rowMode: "array",
});
});
it("uses positions rather than duplicate database column names for joins", async () => {
const { db, executor } = setup(result([[9, "Category", 3, "Record"]]));
await expect(
db
.select({
categoryId: categories.id,
categoryTitle: categories.title,
recordId: records.id,
recordTitle: records.title,
})
.from(records)
.innerJoin(categories, eq(records.id, categories.id)),
).resolves.toEqual([
{ categoryId: 9, categoryTitle: "Category", recordId: 3, recordTitle: "Record" },
]);
expect(executor.state.calls[0]?.query.rowMode).toBe("array");
expect(executor.state.calls[0]?.query.text).toContain(
'select "calendar_categories"."id", "calendar_categories"."title", "calendar_records"."id", "calendar_records"."title"',
);
});
});
describe("moduleDrizzle result decoding", () => {
it("lets Drizzle decode every stable low-level ABI representation", async () => {
const bytes = new Uint8Array([0, 127, 255]);
const { db, executor } = setup(
result([
[
"1234567890.0123456789",
"12345678901234567890",
bytes,
"2026-01-02 03:04:05+00",
"2026-01-02",
"2026-01-02 03:04:05",
'{"kind":"jsonb"}',
null,
'{"kind":"json"}',
["3", "9007199254740993"],
["one", "two"],
],
]),
);
const rows = await db
.select({
amount: records.amount,
bigId: records.bigId,
bytes: records.bytes,
createdAt: records.createdAt,
day: records.day,
localAt: records.localAt,
metadata: records.metadata,
nullable: records.nullable,
payload: records.payload,
scores: records.scores,
tags: records.tags,
})
.from(records);
expect(rows).toEqual([
{
amount: "1234567890.0123456789",
bigId: 12_345_678_901_234_567_890n,
bytes,
createdAt: new Date("2026-01-02T03:04:05.000Z"),
day: new Date("2026-01-02T00:00:00.000Z"),
localAt: new Date("2026-01-02T03:04:05.000Z"),
metadata: { kind: "jsonb" },
nullable: null,
payload: { kind: "json" },
scores: [3n, 9_007_199_254_740_993n],
tags: ["one", "two"],
},
]);
expect(rows[0]?.bytes).toBe(bytes);
expect(executor.state.calls[0]?.query.rowMode).toBe("array");
});
});
describe("moduleDrizzle transactions", () => {
it("maps top-level and nested transactions to ABI callbacks and savepoints", async () => {
const { db, executor } = setup(result([[1]]), result([[2]]), result([[3]]));
const value = await db.transaction(async (transaction) => {
await transaction.select({ id: records.id }).from(records);
const nested = await transaction.transaction(async (savepoint) => {
await savepoint.select({ id: records.id }).from(records);
return "nested";
});
await transaction.select({ id: records.id }).from(records);
return `${nested}-result`;
});
expect(value).toBe("nested-result");
expect(executor.state.events).toEqual([
"transaction:begin",
"transaction:query",
"transaction.savepoint:begin",
"transaction.savepoint:query",
"transaction.savepoint:release",
"transaction:query",
"transaction:commit",
]);
expect(executor.state.calls.map((call) => call.scope)).toEqual([
"transaction",
"transaction.savepoint",
"transaction",
]);
});
it("propagates callback errors unchanged and leaves rollback to the ABI", async () => {
const { db, executor } = setup();
const failure = new Error("module callback failed");
await expect(
db.transaction(() => {
throw failure;
}),
).rejects.toBe(failure);
expect(executor.state.events).toEqual(["transaction:begin", "transaction:rollback"]);
});
it("rolls a rejected nested callback back to its ABI savepoint", async () => {
const { db, executor } = setup();
const failure = new Error("nested callback failed");
await expect(
db.transaction((transaction) => transaction.transaction(() => Promise.reject(failure))),
).rejects.toBe(failure);
expect(executor.state.events).toEqual([
"transaction:begin",
"transaction.savepoint:begin",
"transaction.savepoint:rollback",
"transaction:rollback",
]);
});
});
describe("moduleDrizzle error and type boundaries", () => {
it("recovers the stable ABI code and SQLSTATE from Drizzle's wrapper", async () => {
const databaseError = new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", {
sqlState: "23505",
});
const { db } = setup(databaseError);
let thrown: unknown;
try {
await db.select({ id: records.id }).from(records);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect(findEkklesiaDatabaseError(thrown)).toBe(databaseError);
expect(findEkklesiaDatabaseError(thrown)).toMatchObject({
code: "MODULE_DATABASE_QUERY_FAILED",
sqlState: "23505",
});
expect(databaseError).not.toHaveProperty("query");
expect(databaseError).not.toHaveProperty("params");
expect(databaseError).not.toHaveProperty("detail");
expect(databaseError).not.toHaveProperty("hint");
});
it("retains the module's schema inference without Core or pg database types", () => {
const executor = new FakeExecutor();
const schema = { records };
const db = moduleDrizzle(executor, { schema });
expectTypeOf(db).toMatchTypeOf<ModuleDrizzleDatabase<typeof schema>>();
expectTypeOf(db.select().from(records)).resolves.toEqualTypeOf<
(typeof records.$inferSelect)[]
>();
expect(Object.keys(db).sort()).toEqual(["delete", "insert", "select", "transaction", "update"]);
expect(db).not.toHaveProperty("_");
expect(db).not.toHaveProperty("$client");
expect(db).not.toHaveProperty("execute");
expect(db).not.toHaveProperty("query");
});
});
@@ -0,0 +1,95 @@
import {
ModuleDatabaseError,
type ModuleDatabaseExecutor,
type ModuleDatabaseParameter,
} from "@ekklesia/contracts";
import { describe, expect, it } from "vitest";
import { findEkklesiaDatabaseError, sql } from "../src/index.js";
describe("the raw Module Database ABI v1 SQL tag", () => {
it("keeps static SQL unchanged when there are no interpolations", () => {
expect(sql`SELECT id FROM calendar_events ORDER BY id`).toEqual({
text: "SELECT id FROM calendar_events ORDER BY id",
values: [],
});
});
it("turns every interpolation into an ordered PostgreSQL parameter", () => {
const id = "event-1";
const active = true;
expect(sql`SELECT * FROM calendar_events WHERE id = ${id} AND active = ${active}`).toEqual({
text: "SELECT * FROM calendar_events WHERE id = $1 AND active = $2",
values: [id, active],
});
});
it("gives repeated values separate positional parameters", () => {
const value = "same";
expect(sql`SELECT ${value}, ${value}, ${value}`).toEqual({
text: "SELECT $1, $2, $3",
values: [value, value, value],
});
});
it("keeps SQL-injection-sensitive strings bound instead of joining them into SQL", () => {
const attack = "x'; DROP TABLE calendar_events; --";
const query = sql`SELECT * FROM calendar_events WHERE title = ${attack}`;
expect(query.text).toBe("SELECT * FROM calendar_events WHERE title = $1");
expect(query.text).not.toContain(attack);
expect(query.values).toEqual([attack]);
});
it("preserves bigint, byte arrays, and PostgreSQL arrays as ABI values", () => {
const bytes = new Uint8Array([0, 127, 255]);
const identifiers = [1n, 2n, null] as const;
const query = sql`SELECT ${9_007_199_254_740_993n}, ${bytes}, ${identifiers}`;
expect(query.values).toEqual([9_007_199_254_740_993n, bytes, identifiers]);
expect(query.values[1]).toBe(bytes);
expect(query.values[2]).toBe(identifiers);
});
it("does not stringify unsupported values before the ABI executor rejects them", async () => {
const invalid = new Date("2026-01-02T03:04:05.000Z");
const query = sql`SELECT ${invalid as unknown as ModuleDatabaseParameter}`;
expect(query.values[0]).toBe(invalid);
const executor = {
abiVersion: 1,
async query(request: { readonly values?: readonly ModuleDatabaseParameter[] }) {
if (request.values?.some((value) => value instanceof Date)) {
throw new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
throw new Error("Expected the fake ABI validator to reject the value.");
},
async transaction() {
throw new Error("not used");
},
} as unknown as ModuleDatabaseExecutor;
await expect(executor.query(query)).rejects.toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
name: "ModuleDatabaseError",
});
});
});
describe("findEkklesiaDatabaseError", () => {
it("finds a stable ABI error through wrappers without parsing messages", () => {
const databaseError = new ModuleDatabaseError("MODULE_DATABASE_QUERY_FAILED", {
sqlState: "23505",
});
const wrapped = new Error("wrapper", { cause: new Error("second", { cause: databaseError }) });
expect(findEkklesiaDatabaseError(wrapped)).toBe(databaseError);
expect(findEkklesiaDatabaseError(wrapped)).toMatchObject({
code: "MODULE_DATABASE_QUERY_FAILED",
sqlState: "23505",
});
});
it("terminates safely for cause cycles and unrelated errors", () => {
const cyclic = new Error("cyclic");
cyclic.cause = cyclic;
expect(findEkklesiaDatabaseError(cyclic)).toBeUndefined();
expect(findEkklesiaDatabaseError("not an error")).toBeUndefined();
});
});
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+1
View File
@@ -33,6 +33,7 @@
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/core": "workspace:*",
"@ekklesia/module-database": "workspace:*",
"@ekklesia/module-sdk": "workspace:*",
"@ekklesia/module-template": "workspace:*",
"@types/node": "24.13.3",
@@ -181,6 +181,38 @@ describe("packModule: a self-contained backend", () => {
}
});
it("bundles the module-side Drizzle adapter with the module's ORM runtime", async () => {
const { archive } = await pack({
serverSource: `import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
export const database = (executor) => moduleDrizzle(executor);
export default { id: "example", version: "0.1.0", register() {} };
`,
withModuleDatabase: true,
});
const files = fileEntries(archive);
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
expect(metadata.incorporates).toMatchObject({
"@ekklesia/module-database": expect.any(String),
});
expect(metadata.transitional).toEqual({ databaseRuntime: { "drizzle-orm": "0.45.2" } });
for (const [file, data] of files) {
if (!file.endsWith(".js")) {
continue;
}
const code = data.toString("utf8");
const specifiers = [
...code.matchAll(/(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*"([^"]+)"/gu),
...code.matchAll(/(?:^|\n)\s*import\s*"([^"]+)"/gu),
...code.matchAll(/\bimport\(\s*"([^"]+)"\s*\)/gu),
].map((match) => match[1] ?? "");
for (const specifier of specifiers) {
expect(specifier.startsWith(".") || isBuiltin(specifier), `${file}: ${specifier}`).toBe(
true,
);
}
}
});
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
await packRejection(
{
@@ -25,6 +25,7 @@ export interface FixtureModuleOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly withModuleDatabase?: boolean;
readonly withSchema?: boolean;
readonly fakePackages?: readonly FakePackage[];
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
@@ -105,7 +106,11 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
private: true,
type: "module",
scripts: { build: "tsc" },
dependencies: { "@ekklesia/module-sdk": "workspace:*", "drizzle-orm": "0.45.2" },
dependencies: {
"@ekklesia/module-sdk": "workspace:*",
...(options.withModuleDatabase ? { "@ekklesia/module-database": "workspace:*" } : {}),
"drizzle-orm": "0.45.2",
},
devDependencies: { typescript: "7.0.2" },
exports: { ".": "./dist/server/index.js" },
ekklesia: { layer: "module" },
@@ -166,6 +171,12 @@ export async function fixtureModule(options: FixtureModuleOptions = {}): Promise
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
path.join(modules, "@ekklesia", "module-sdk"),
);
if (options.withModuleDatabase) {
await symlink(
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-database")),
path.join(modules, "@ekklesia", "module-database"),
);
}
await symlink(
await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")),
path.join(modules, "drizzle-orm"),