feat: add module database ABI v1 adapters #70

Merged
Zayin_Admin merged 1 commits from feat/module-database-abi-adapters into main 2026-10-07 22:00:07 +00:00
18 changed files with 957 additions and 7 deletions
+8 -5
View File
@@ -136,11 +136,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
declared by the consumer.
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
omits development dependencies. The one narrow exception is
`@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and the
Module Package v1 bundler incorporates it into their self-contained backend; Core never loads the
adapter package. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
(`runtime-imports-are-production-dependencies`).
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
- Workspace package names are unique and shared packages declare a recognized architectural role.
+75
View File
@@ -145,6 +145,12 @@ copies interoperate only when the copies are interchangeable, so the current imp
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
This is a constraint of today's implementation, not a permanent contract.
`@ekklesia/module-database` now contains the module-side ABI v1 adapters that remove that coupling,
but deployed modules cannot receive an ABI v1 executor yet. Manifest v8 and per-module runtime
service delivery are separate later work. Until those land, the current transitional package rule
and database service above remain authoritative; neither Module Package v1 nor the template changed
with the adapter package.
#### Verifying a package
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
@@ -1485,6 +1491,75 @@ destructive behavior. Review those properties explicitly. Core is the only migra
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
falls back to non-transactional execution.
## Module Database ABI v1 adapters (not yet delivered at runtime)
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
cross it. `@ekklesia/module-database` is module-side code for consuming that boundary once a later
manifest/runtime slice delivers a `ModuleDatabaseExecutor`; it is **not usable by deployed modules
yet**.
A Drizzle module imports the optional adapter subpath and declares its own exact compatible
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
compatibility; it is not a Core or platform compatibility range:
```ts
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
import * as schema from "./schema/tables.js";
// `executor` will be supplied by later ABI service delivery work.
const db = moduleDrizzle(executor, { schema });
const events = await db.select().from(schema.events);
```
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
supported arrays. This also makes joins with duplicate PostgreSQL column names unambiguous. Core
never imports this adapter and has no dependency on the module's Drizzle version. The adapter surface
is deliberately limited to `select`, `insert`, `update`, `delete`, and `transaction`; returning,
joins, and SQL expressions come from those normal Drizzle builders.
`db.transaction(callback)` maps to the ABI transaction callback. The callback receives another
Drizzle-facing database backed by the scoped ABI executor; a nested `.transaction()` maps to the
ABI's `savepoint` callback. Callback values and errors propagate, Core remains authoritative for
commit/rollback and escaped-scope behavior, and the adapter adds no retries or transaction-control
SQL.
Drizzle wraps execution failures in `DrizzleQueryError`. Recover the stable sanitized ABI error by
shape instead of using a PostgreSQL error instance, parsing a message, or reading `.cause.code`:
```ts
import { findEkklesiaDatabaseError } from "@ekklesia/module-database";
try {
await db.insert(schema.events).values(event);
} catch (error) {
const databaseError = findEkklesiaDatabaseError(error);
if (databaseError?.sqlState === "23505") {
// Handle a uniqueness conflict.
}
}
```
Modules that do not want Drizzle use the package's ORM-neutral tag. Every interpolation is one bound
value; v1 deliberately provides no dynamic-identifier or trusted-fragment escape hatch:
```ts
import { sql } from "@ekklesia/module-database";
const statement = sql`
SELECT id, title
FROM calendar_events
WHERE id = ${eventId}
`;
const result = await executor.query({ ...statement, rowMode: "object" });
```
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
rejects them; strings containing quotes or SQL syntax always remain parameters.
## Database access
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
+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"),
+25
View File
@@ -196,6 +196,28 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-database:
dependencies:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@types/node':
specifier: 24.13.3
version: 24.13.3
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-pack:
dependencies:
'@ekklesia/contracts':
@@ -217,6 +239,9 @@ importers:
'@ekklesia/core':
specifier: workspace:*
version: link:../../core
'@ekklesia/module-database':
specifier: workspace:*
version: link:../module-database
'@ekklesia/module-sdk':
specifier: workspace:*
version: link:../module-sdk
+13 -1
View File
@@ -147,6 +147,16 @@ const DEPENDENCY_FIELDS = [
"optionalDependencies",
"peerDependencies",
] as const;
/**
* Runtime peers whose identity is deliberately supplied by the final consumer. The sole entry is
* the module-side Drizzle adapter: a module chooses and bundles its own compatible Drizzle runtime,
* while Core must never select one for it. Keep this exception package-and-dependency specific;
* ordinary workspace packages still need production dependencies for runtime imports.
*/
const CONSUMER_SELECTED_RUNTIME_PEERS = new Map<string, ReadonlySet<string>>([
["@ekklesia/module-database", new Set(["drizzle-orm"])],
]);
const SHARED_PACKAGE_ROLES = new Set([
"api-client",
"auth-client",
@@ -1006,7 +1016,9 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
dependencyName === runtimePackage.name ||
NODE_BUILTIN_MODULES.has(dependencyName) ||
manifest.dependencies?.[dependencyName] !== undefined ||
manifest.optionalDependencies?.[dependencyName] !== undefined
manifest.optionalDependencies?.[dependencyName] !== undefined ||
(manifest.peerDependencies?.[dependencyName] !== undefined &&
CONSUMER_SELECTED_RUNTIME_PEERS.get(runtimePackage.name)?.has(dependencyName) === true)
) {
continue;
}
+6
View File
@@ -119,6 +119,12 @@ describe("repository dependency boundaries", () => {
expect(result.violations).toEqual([]);
});
it("accepts only the module database adapter's consumer-selected Drizzle runtime peer", async () => {
const result = await checkBoundaries(path.join(fixtures, "legal-consumer-runtime-peer"));
expect(result.violations).toEqual([]);
});
it.each([
[
"runtime-peer-only-dependency",
@@ -0,0 +1,22 @@
{
"name": "@ekklesia/module-database",
"version": "0.0.0",
"type": "module",
"peerDependencies": {
"drizzle-orm": ">=0.45.2 <0.46.0"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
},
"devDependencies": {
"drizzle-orm": "0.45.2"
},
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "module-sdk"
}
}
@@ -0,0 +1,3 @@
import { drizzle } from "drizzle-orm/pg-proxy";
export const moduleDrizzle = drizzle;