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
+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,