From 859675c3c7703ffb4dcae23be595da28ec236f77 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 7 Oct 2026 16:44:28 -0500 Subject: [PATCH] feat: add module database ABI v1 adapters --- docs/architecture/repository-boundaries.md | 13 +- docs/module-development/README.md | 75 ++++ packages/module-database/package.json | 47 ++ packages/module-database/src/drizzle.ts | 85 ++++ packages/module-database/src/index.ts | 50 +++ .../tests/architecture.test.ts | 46 ++ .../module-database/tests/drizzle.test.ts | 415 ++++++++++++++++++ .../module-database/tests/raw-sql.test.ts | 95 ++++ packages/module-database/tsconfig.build.json | 14 + packages/module-database/tsconfig.json | 8 + packages/module-pack/package.json | 1 + .../module-pack/tests/pack-module.test.ts | 32 ++ .../tests/support/fixture-module.ts | 13 +- pnpm-lock.yaml | 25 ++ scripts/check-boundaries.ts | 14 +- tests/boundaries.test.ts | 6 + .../packages/module-database/package.json | 22 + .../packages/module-database/src/index.ts | 3 + 18 files changed, 957 insertions(+), 7 deletions(-) create mode 100644 packages/module-database/package.json create mode 100644 packages/module-database/src/drizzle.ts create mode 100644 packages/module-database/src/index.ts create mode 100644 packages/module-database/tests/architecture.test.ts create mode 100644 packages/module-database/tests/drizzle.test.ts create mode 100644 packages/module-database/tests/raw-sql.test.ts create mode 100644 packages/module-database/tsconfig.build.json create mode 100644 packages/module-database/tsconfig.json create mode 100644 tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/package.json create mode 100644 tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/src/index.ts diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index a8c459d..723d01b 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -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. diff --git a/docs/module-development/README.md b/docs/module-development/README.md index b40d1c4..841bacf 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 --sha256-file .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, diff --git a/packages/module-database/package.json b/packages/module-database/package.json new file mode 100644 index 0000000..c65c1f1 --- /dev/null +++ b/packages/module-database/package.json @@ -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 + } + } +} diff --git a/packages/module-database/src/drizzle.ts b/packages/module-database/src/drizzle.ts new file mode 100644 index 0000000..d562a30 --- /dev/null +++ b/packages/module-database/src/drizzle.ts @@ -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 = Promise | T; +type DrizzleSurface> = Pick< + PgRemoteDatabase, + "delete" | "insert" | "select" | "update" +>; + +export type ModuleDrizzleTransaction< + TSchema extends Record = Record, +> = DrizzleSurface & { + transaction( + callback: (transaction: ModuleDrizzleTransaction) => MaybePromise, + ): Promise; +}; + +export type ModuleDrizzleDatabase = Record> = + DrizzleSurface & { + transaction( + callback: (transaction: ModuleDrizzleTransaction) => MaybePromise, + ): Promise; + }; + +type TransactionRunner> = ( + callback: (transaction: ModuleDrizzleTransaction) => MaybePromise, +) => Promise; + +function remoteDatabase>( + executor: ModuleDatabaseQueryable, + config: DrizzleConfig, + runTransaction: TransactionRunner, +): ModuleDrizzleDatabase { + const database = drizzle(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; +} + +function transactionDatabase>( + transaction: ModuleDatabaseTransaction, + config: DrizzleConfig, +): ModuleDrizzleTransaction { + 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 = Record>( + executor: ModuleDatabaseExecutor, + config: DrizzleConfig = {}, +): ModuleDrizzleDatabase { + return remoteDatabase(executor, config, (callback) => + executor.transaction((transaction) => callback(transactionDatabase(transaction, config))), + ); +} diff --git a/packages/module-database/src/index.ts b/packages/module-database/src/index.ts new file mode 100644 index 0000000..e644f05 --- /dev/null +++ b/packages/module-database/src/index.ts @@ -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>>; + +/** + * 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(); + let current = value; + while (current instanceof Error && !visited.has(current)) { + if (isModuleDatabaseError(current)) { + return current; + } + visited.add(current); + current = current.cause; + } + return undefined; +} diff --git a/packages/module-database/tests/architecture.test.ts b/packages/module-database/tests/architecture.test.ts new file mode 100644 index 0000000..b817d8d --- /dev/null +++ b/packages/module-database/tests/architecture.test.ts @@ -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 { + 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 + >; + 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"); + }); +}); diff --git a/packages/module-database/tests/drizzle.test.ts b/packages/module-database/tests/drizzle.test.ts new file mode 100644 index 0000000..a35ef6d --- /dev/null +++ b/packages/module-database/tests/drizzle.test.ts @@ -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; + +interface RecordedCall { + readonly query: ModuleDatabaseQuery; + readonly scope: string; +} + +const noFields: readonly ModuleDatabaseField[] = []; + +function result( + rows: readonly ModuleDatabaseArrayRow[], + command = "SELECT", +): ModuleDatabaseQueryResult { + 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>; + query( + query: ModuleDatabaseQuery & { readonly rowMode?: "object" }, + ): Promise>; + query( + query: ModuleDatabaseQuery, + ): Promise>; + async query( + query: ModuleDatabaseQuery, + ): Promise> { + 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( + callback: (savepoint: ModuleDatabaseTransaction) => Promise | T, + ): Promise { + 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>; + query( + query: ModuleDatabaseQuery & { readonly rowMode?: "object" }, + ): Promise>; + query( + query: ModuleDatabaseQuery, + ): Promise>; + async query( + query: ModuleDatabaseQuery, + ): Promise> { + return new FakeTransaction(this.state, "executor").query(query); + } + + async transaction( + callback: (transaction: ModuleDatabaseTransaction) => Promise | T, + ): Promise { + 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>(); + 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"); + }); +}); diff --git a/packages/module-database/tests/raw-sql.test.ts b/packages/module-database/tests/raw-sql.test.ts new file mode 100644 index 0000000..8097045 --- /dev/null +++ b/packages/module-database/tests/raw-sql.test.ts @@ -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(); + }); +}); diff --git a/packages/module-database/tsconfig.build.json b/packages/module-database/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/packages/module-database/tsconfig.build.json @@ -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"] +} diff --git a/packages/module-database/tsconfig.json b/packages/module-database/tsconfig.json new file mode 100644 index 0000000..0d93162 --- /dev/null +++ b/packages/module-database/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/module-pack/package.json b/packages/module-pack/package.json index bc20c10..78b50de 100644 --- a/packages/module-pack/package.json +++ b/packages/module-pack/package.json @@ -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", diff --git a/packages/module-pack/tests/pack-module.test.ts b/packages/module-pack/tests/pack-module.test.ts index 956b206..a713313 100644 --- a/packages/module-pack/tests/pack-module.test.ts +++ b/packages/module-pack/tests/pack-module.test.ts @@ -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( { diff --git a/packages/module-pack/tests/support/fixture-module.ts b/packages/module-pack/tests/support/fixture-module.ts index 75a4465..ff45c05 100644 --- a/packages/module-pack/tests/support/fixture-module.ts +++ b/packages/module-pack/tests/support/fixture-module.ts @@ -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) => Record; @@ -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"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2c8e85..238fbe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,28 @@ importers: specifier: 4.1.10 version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(happy-dom@20.0.11)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + 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 diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index dd698b7..b5c94a4 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -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>([ + ["@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 { 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", diff --git a/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/package.json b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/package.json new file mode 100644 index 0000000..91b7e51 --- /dev/null +++ b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/package.json @@ -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" + } +} diff --git a/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/src/index.ts b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/src/index.ts new file mode 100644 index 0000000..84928f4 --- /dev/null +++ b/tests/fixtures/boundaries/legal-consumer-runtime-peer/packages/module-database/src/index.ts @@ -0,0 +1,3 @@ +import { drizzle } from "drizzle-orm/pg-proxy"; + +export const moduleDrizzle = drizzle; -- 2.54.0