docs: propose ADR 0050, Module Database ABI v1
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Core and modules exchange PostgreSQL SQL text, bound parameters, result
rows with field metadata, and Core-owned transaction scopes. Never ORM
objects. A module may use Drizzle, Kysely, raw SQL, or another
module-side builder. Core's own ORM becomes an implementation detail.
ADR 0050 decides:
- an explicit correctness/compatibility boundary, not a security
sandbox: no claim for SQL parsing, execution-time prefix checks,
SET ROLE, or PostgreSQL roles;
- opt-in through manifest version 8 `database.abi: 1`, a hard cutover
like every manifest version; a version 8 module without `database`
keeps the legacy ModuleDatabase facade during the deprecation window;
- the executor: query({ text, values, rowMode }) returning
{ command, rowCount, rows, fields }, one executor per module;
- a small parameter set (no Date, no implicit JSON, no identifiers);
- a Core-owned decoding table that loses no precision and does not
depend on time zones (int8, numeric, temporal, and JSON as text;
bytea as Uint8Array);
- a sanitized ModuleDatabaseError { code, sqlState? }, recognised
structurally;
- callback-scoped transactions and Core-named savepoints, revoked when
their callback settles;
- module-side adapters (Drizzle via pg-proxy, raw SQL, later Kysely);
- plain schema metadata for ABI v1 modules instead of Drizzle objects;
- migrations unchanged; packageFormat stays 1; ABI v1 packages omit
transitional.databaseRuntime.
It partly supersedes ADR 0016 and amends ADR 0007 for ABI v1 modules,
and extends ADR 0049. Status Proposed. Accepted ADRs are unchanged. No
runtime, SDK, manifest, packaging, migration, or Signage changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9ddb5e2796
commit
759872834d
@@ -0,0 +1,501 @@
|
|||||||
|
# 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
|
||||||
|
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
|
||||||
|
Core's Drizzle instance, and its rule that every module receives the same `services` object.
|
||||||
|
One pool, no pool or connection string for modules, no database access during `register`, the
|
||||||
|
shutdown order, handler error sanitization, and the trust boundary remain accepted and unchanged.
|
||||||
|
Modules that do not declare ABI v1 keep ADR 0016's facade until it is removed (see
|
||||||
|
[Compatibility and deprecation](#compatibility-and-deprecation)).
|
||||||
|
- Amends: [0007](0007-module-owned-drizzle-schemas.md) — for modules that declare ABI v1, the
|
||||||
|
schema definition Core inspects is plain Ekklesia-owned metadata instead of Drizzle table objects.
|
||||||
|
Table namespacing, identifier rules, uniqueness, same-module foreign keys, atomic aggregation, and
|
||||||
|
sanitized discovery errors remain accepted and unchanged.
|
||||||
|
- Extends: [0049](0049-module-package-v1.md) — this is the Module Database ABI that ADR 0049 deferred
|
||||||
|
and anticipated. ABI v1 packages carry no `transitional.databaseRuntime`. `packageFormat` stays 1.
|
||||||
|
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||||
|
[ADR 0009](0009-transactional-module-migrations.md),
|
||||||
|
[ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md),
|
||||||
|
[ADR 0048](0048-generic-image-and-installed-module-storage.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0016 gave module code database access through `ModuleDatabase`, which is
|
||||||
|
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
|
||||||
|
"delete" | "execute" | "transaction">`. Core implements it as a frozen object whose eight methods are
|
||||||
|
bound to Core's own `drizzle(pool)` instance.
|
||||||
|
|
||||||
|
ADR 0048 then took modules out of the platform image, and ADR 0049 made each module release a
|
||||||
|
self-contained package that bundles its own copy of Drizzle. The two copies now meet at run time,
|
||||||
|
and an investigation of the current code (Drizzle 0.45.2) found the coupling runs deeper than a
|
||||||
|
type:
|
||||||
|
|
||||||
|
- **Module objects cross into Core's ORM.** A module builds queries from table, column, and `SQL`
|
||||||
|
objects made by its own Drizzle copy. Core's Drizzle instance then compiles and decodes them.
|
||||||
|
This only works while the two copies agree on internal symbols and entity kinds, which is why ADR
|
||||||
|
0049 records the module's Drizzle version under `transitional.databaseRuntime` and refuses a
|
||||||
|
mismatch.
|
||||||
|
- **Core's ORM objects cross into the module.** `transaction` hands module code Core's live
|
||||||
|
`NodePgTransaction`. At run time that object is a full Drizzle database: its session, dialect,
|
||||||
|
relational query builder, and `rollback()` are all reachable, whatever the `Pick` type says. A
|
||||||
|
reference kept after the callback settles still points at a connection that has returned to the
|
||||||
|
pool.
|
||||||
|
- **Result decoding is split between two ORMs.** Core's Drizzle session overrides `pg`'s parsers
|
||||||
|
for each query, so temporal values arrive as raw text. Then the module's column objects convert
|
||||||
|
them. Neither half is an Ekklesia contract. `pg`'s default parsers are also process-global and
|
||||||
|
mutable, and some lose information: `jsonb` numbers beyond double precision, and `numeric[]`
|
||||||
|
elements, are parsed to JavaScript numbers.
|
||||||
|
- **Errors are ORM and driver shapes.** A module that needs to recognize a PostgreSQL condition
|
||||||
|
reads it from whatever object Drizzle or `pg` throws. Drizzle 0.45 wraps driver errors in its own
|
||||||
|
error type, whose message carries the SQL text and the parameters.
|
||||||
|
- **Schema validation needs Drizzle objects.** Module validation, Doctor, and the package verifier
|
||||||
|
import a module's schema entrypoint and inspect it with Drizzle's `is(…, PgTable)`,
|
||||||
|
`getTableConfig`, and `PgColumn`. That is the same cross-copy dependency, at validation time. Core
|
||||||
|
extracts only plain facts from it: table names and foreign-key target tables.
|
||||||
|
|
||||||
|
Two findings show the dependency is unnecessary:
|
||||||
|
|
||||||
|
- **Migration execution is already independent of the ORM runtime.** Migration discovery reads SQL
|
||||||
|
files and JSON metadata, and the runner executes SQL text. Neither imports Drizzle. Drizzle Kit
|
||||||
|
snapshots are validated metadata; they are not needed to apply a migration.
|
||||||
|
- **The module side can compile to SQL alone.** Drizzle and Kysely both compile a query to
|
||||||
|
PostgreSQL text with `$n` placeholders and an ordered parameter list. Drizzle 0.45.2's own
|
||||||
|
`pg-proxy` driver, given only a callback that receives SQL text, parameters, and an array-or-object
|
||||||
|
row mode, ran `select`, `update`, and `insert … returning`, and decoded `timestamptz` and `int8`
|
||||||
|
text with the module's own column definitions. That driver has no transactions, which a
|
||||||
|
module-side adapter has to supply.
|
||||||
|
|
||||||
|
Signage 0.1.2, the first production module, uses `select`, `insert`, `update`, `delete`,
|
||||||
|
`transaction`, one `returning`, and SQL expressions in `set` clauses. It uses no `execute`, joins,
|
||||||
|
row locks, isolation options, or result row counts. None of its usage needs a Core ORM object. It
|
||||||
|
recognizes a uniqueness conflict by reading SQLSTATE `23505` from the thrown error. A stable,
|
||||||
|
Ekklesia-owned place for that code is desirable regardless of which ORM either side uses. (This
|
||||||
|
paragraph is motivation, not part of the decision.)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The goal: the ORM is an implementation detail on both sides
|
||||||
|
|
||||||
|
Ekklesia defines a stable, Ekklesia-owned **Module Database ABI** between Core and modules.
|
||||||
|
|
||||||
|
- **The boundary is PostgreSQL itself.** Exactly these concepts cross it:
|
||||||
|
- SQL text;
|
||||||
|
- ordered, bound parameter values;
|
||||||
|
- result rows and their metadata;
|
||||||
|
- transaction scopes that Core controls.
|
||||||
|
- **Core never receives or depends on a module's ORM or query-builder object.** That includes:
|
||||||
|
- Drizzle tables, columns, `SQL` objects, query builders, and transactions;
|
||||||
|
- Kysely objects;
|
||||||
|
- any other third-party ORM or runtime object.
|
||||||
|
- **Modules never receive Core's ORM objects, driver objects, or connections.**
|
||||||
|
- **Each side chooses its own tools.** A module may use Drizzle, Kysely, raw SQL, or any module-side
|
||||||
|
query builder that produces PostgreSQL text and parameters. Core may keep Drizzle, or replace it,
|
||||||
|
without any module noticing.
|
||||||
|
|
||||||
|
This ADR defines **Module Database ABI version 1**. The TypeScript names below are illustrative; the
|
||||||
|
concepts and the semantics are the contract.
|
||||||
|
|
||||||
|
### A correctness boundary, not a security boundary
|
||||||
|
|
||||||
|
**The Module Database ABI is a correctness and compatibility boundary. It is not a security
|
||||||
|
sandbox.** Installed modules remain trusted backend code running inside the API process with its
|
||||||
|
database role (ADR 0006, ADR 0016, ADR 0048).
|
||||||
|
|
||||||
|
Nothing in this ADR claims, and nothing built from it may claim, that the following can stop
|
||||||
|
malicious module code from reaching data that is not its own:
|
||||||
|
|
||||||
|
- the ABI itself;
|
||||||
|
- parsing or inspecting SQL;
|
||||||
|
- checking table prefixes when statements execute;
|
||||||
|
- `SET ROLE`;
|
||||||
|
- PostgreSQL roles.
|
||||||
|
|
||||||
|
A module sends arbitrary SQL by design, including SQL that changes its role back. It also runs
|
||||||
|
in-process, where it can reach the network and the environment without the ABI.
|
||||||
|
|
||||||
|
Table ownership remains what ADR 0007 made it: a static correctness check of declared tables and
|
||||||
|
foreign keys, run during validation. The ABI keeps that check honest by removing the ORM objects it
|
||||||
|
used to depend on. It adds no runtime enforcement.
|
||||||
|
|
||||||
|
### Declaring the ABI: manifest version 8
|
||||||
|
|
||||||
|
A module opts into the ABI explicitly, in its static manifest. The manifest is strict and changes by
|
||||||
|
hard cutover (ADR 0006, ADR 0014, ADR 0021, ADR 0029), so the declaration arrives with
|
||||||
|
**manifest version 8**. Conceptually:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manifestVersion": 8,
|
||||||
|
"database": { "abi": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`database.abi: 1`** means the module receives a Module Database ABI v1 executor. It never
|
||||||
|
receives the legacy facade.
|
||||||
|
- **No `database` field** means the module receives ADR 0016's legacy `ModuleDatabase` facade
|
||||||
|
during the deprecation window.
|
||||||
|
- **Any other ABI value** fails validation. A platform that does not implement a declared ABI refuses
|
||||||
|
the module rather than guessing.
|
||||||
|
- **Version 8 is a hard cutover, like every manifest version before it.** Core parses exactly one
|
||||||
|
manifest version; it never parses version 7 and version 8 side by side.
|
||||||
|
- An existing source module moves to version 8, and repacks, without changing any database code.
|
||||||
|
- A package already built with a version 7 manifest must be rebuilt.
|
||||||
|
- Module Package v1 has only just landed, and no published ecosystem depends on version 7
|
||||||
|
packages, so this costs a rebuild and nothing more.
|
||||||
|
|
||||||
|
In this ADR, a **legacy module** means a module with a version 8 manifest and no `database`
|
||||||
|
declaration, which therefore uses the legacy facade. It never means a module with a version 7
|
||||||
|
manifest.
|
||||||
|
|
||||||
|
Because the declaration is static, Core, the packager, and the verifier know a module's database
|
||||||
|
contract without loading its code. Because Core enforces it, an ABI v1 module cannot reach the
|
||||||
|
legacy facade, so nothing at run time can depend on Drizzle agreement.
|
||||||
|
|
||||||
|
The rest of manifest version 8 (field placement, validation messages, the template, and the
|
||||||
|
cutover itself) is an implementation detail of the change that introduces it.
|
||||||
|
|
||||||
|
### The executor
|
||||||
|
|
||||||
|
An ABI v1 module's database service is an **executor**. Conceptually:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
query({ text, values, rowMode }) → { command, rowCount, rows, fields }
|
||||||
|
transaction(callback, options) → the callback's result
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. Statement text that
|
||||||
|
holds several statements is not part of the ABI, and Core may reject it.
|
||||||
|
- **`values`** is the ordered list of bound parameters (see
|
||||||
|
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
|
||||||
|
- **`rowMode`** is `"object"` or `"array"`.
|
||||||
|
- `"object"` rows are keyed by column name.
|
||||||
|
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
|
||||||
|
as joins do, needs this mode.
|
||||||
|
- Core rejects an object-mode result that has duplicate column names, rather than silently
|
||||||
|
dropping one.
|
||||||
|
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
|
||||||
|
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
|
||||||
|
none.
|
||||||
|
- **`rows`** are the result rows, decoded as in [Results](#results). `RETURNING` rows are ordinary
|
||||||
|
rows.
|
||||||
|
- **`fields`** are the ordered column metadata, present even when there are no rows: at least each
|
||||||
|
column's name and its PostgreSQL type OID.
|
||||||
|
|
||||||
|
The executor contains no ORM type, AST, query object, or schema object, and accepts none.
|
||||||
|
|
||||||
|
**One executor per module.** Each ABI v1 module receives its own executor, bound to its module id.
|
||||||
|
The module's `start` and `stop` hooks and its route handlers all receive that same object, which
|
||||||
|
keeps ADR 0016's guarantee that lifecycle code and handlers share one database service. Different
|
||||||
|
modules get different executors, so Core can attribute every statement to the module that issued it.
|
||||||
|
|
||||||
|
**Observability belongs to Core.** Core may record per-module timings, command tags, row counts, and
|
||||||
|
error codes. It never writes SQL text or parameter values to logs, diagnostics, Doctor output, or
|
||||||
|
HTTP responses.
|
||||||
|
|
||||||
|
**Session state is not part of the ABI.** Top-level `query` calls are separate autocommit statements
|
||||||
|
that may run on different pooled connections. A module must not rely on session-level state, and
|
||||||
|
must not change it. That includes:
|
||||||
|
|
||||||
|
- session `SET`;
|
||||||
|
- temporary tables;
|
||||||
|
- named prepared statements;
|
||||||
|
- `LISTEN`;
|
||||||
|
- session advisory locks.
|
||||||
|
|
||||||
|
Settings that must hold for one unit of work use `SET LOCAL` inside a transaction. Core may reset or
|
||||||
|
discard a connection that a module changed. `COPY`, cursors, streaming results, and named prepared
|
||||||
|
statements are not part of v1.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
The ABI deliberately accepts a small set of PostgreSQL wire-safe values:
|
||||||
|
|
||||||
|
| Value | Sent as |
|
||||||
|
| --- | --- |
|
||||||
|
| `null` | SQL `NULL` |
|
||||||
|
| `boolean` | `true` / `false` |
|
||||||
|
| `number` | its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as PostgreSQL's `NaN`, `Infinity`, and `-Infinity` |
|
||||||
|
| `bigint` | its decimal text |
|
||||||
|
| `string` | the text, unchanged |
|
||||||
|
| `Uint8Array` | `bytea` |
|
||||||
|
| arrays of the above | a PostgreSQL array, nested for more dimensions |
|
||||||
|
|
||||||
|
Everything else is refused with a sanitized error before anything reaches PostgreSQL, rather than
|
||||||
|
being coerced. In particular:
|
||||||
|
|
||||||
|
- **`Date` is not an ABI parameter.** A JavaScript `Date` is an instant. Whether it should become a
|
||||||
|
`timestamp` or a `timestamptz`, and in which time zone, depends on a column type the ABI cannot
|
||||||
|
see. The module-side adapter converts it to explicit text first; for example, Drizzle's timestamp
|
||||||
|
columns already send ISO-8601 text.
|
||||||
|
- **Plain objects are not implicit JSON.** A module or adapter encodes JSON explicitly, as a string,
|
||||||
|
and the SQL casts it where needed.
|
||||||
|
- **Identifiers are never parameters.** Table and column names are part of the module's compiled
|
||||||
|
SQL text. The ABI provides no identifier interpolation.
|
||||||
|
|
||||||
|
### Results
|
||||||
|
|
||||||
|
Core owns one documented decoding table for ABI v1. It does not depend on `pg`'s global parser
|
||||||
|
registry, which module code could change, or on any ORM. Values that a generic conversion could
|
||||||
|
change, or change silently by time zone, arrive in a stable raw form:
|
||||||
|
|
||||||
|
| PostgreSQL type | Arrives as |
|
||||||
|
| --- | --- |
|
||||||
|
| any `NULL` | `null` |
|
||||||
|
| `bool` | `boolean` |
|
||||||
|
| `int2`, `int4`, `oid` | `number` |
|
||||||
|
| `int8` | `string`, its exact decimal text |
|
||||||
|
| `float4`, `float8` | `number`, including `NaN` and `±Infinity` |
|
||||||
|
| `numeric` / `decimal` | `string`, its exact text (`NaN` included) |
|
||||||
|
| `text`, `varchar`, `bpchar`, `name`, `uuid` | `string` |
|
||||||
|
| `date`, `timestamp`, `timestamptz`, `time`, `timetz`, `interval` | `string`: PostgreSQL's ISO text output |
|
||||||
|
| `json`, `jsonb` | `string`: the JSON text, not parsed |
|
||||||
|
| `bytea` | `Uint8Array` |
|
||||||
|
| arrays of any of the above | JavaScript arrays (nested for more dimensions), each element decoded by the same rule, with `null` elements as `null` |
|
||||||
|
| any other type | `string`: PostgreSQL's text output |
|
||||||
|
|
||||||
|
- **Temporal text is fixed for every module.** Core fixes the session's `DateStyle` (ISO),
|
||||||
|
`IntervalStyle` (`postgres`), and `TimeZone` (UTC) on every connection that serves the ABI. The
|
||||||
|
same instant therefore always produces the same text, whatever the server's or the host's local
|
||||||
|
zone.
|
||||||
|
- **`bytea` has one representation.** A value is a `Uint8Array`. A module must not rely on it being
|
||||||
|
any particular subclass.
|
||||||
|
- **Higher-level conversion belongs to the module side.** Converting text to `Date`, `numeric` to a
|
||||||
|
decimal type, `int8` to `bigint` or `number`, and JSON text to values is the adapter's job, or the
|
||||||
|
module's. The Drizzle column types Signage uses already accept exactly these raw forms.
|
||||||
|
|
||||||
|
Changing how any type arrives, including decoding a type that arrives as text today, requires a new
|
||||||
|
ABI version.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
Every failure the executor reports is a Core-owned, sanitized **`ModuleDatabaseError`**.
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ModuleDatabaseError { code, sqlState? }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`code`** is a small, stable, Ekklesia-defined set. It covers at least:
|
||||||
|
- the statement failed in PostgreSQL;
|
||||||
|
- a parameter or option was refused;
|
||||||
|
- the scope is closed;
|
||||||
|
- the transaction could not be completed.
|
||||||
|
|
||||||
|
The exact names are an implementation detail.
|
||||||
|
- **`sqlState`** is PostgreSQL's five-character SQLSTATE, present when PostgreSQL reported one.
|
||||||
|
Modules legitimately branch on stable conditions such as `23505` (unique violation), `23503`
|
||||||
|
(foreign-key violation), and `40001` (serialization failure).
|
||||||
|
- **It never carries:**
|
||||||
|
- SQL text or parameter values;
|
||||||
|
- the driver's message, detail, hint, constraint, table, or column names;
|
||||||
|
- a stack, a cause, connection details, or any Core, `pg`, or Drizzle object.
|
||||||
|
- **Modules recognize it structurally** (its name and its `code`), never with `instanceof`. A module
|
||||||
|
bundles its own copy of the SDK (ADR 0049), so class identity across the boundary is not a
|
||||||
|
contract.
|
||||||
|
|
||||||
|
This replaces modules depending on `DrizzleQueryError`, `pg`'s `DatabaseError`, or any other driver
|
||||||
|
or ORM error shape. An uncaught `ModuleDatabaseError` in a route handler is sanitized at the
|
||||||
|
handler boundary exactly as before (ADR 0015, ADR 0016).
|
||||||
|
|
||||||
|
### Transactions
|
||||||
|
|
||||||
|
Transactions are **callback-scoped**, and Core owns the connection throughout:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
transaction(async (tx) => { … }, { isolation, readOnly })
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
|
||||||
|
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
|
||||||
|
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
|
||||||
|
- **Commit and rollback.** Core commits when the callback's promise resolves, and rolls back when it
|
||||||
|
rejects. The transaction call then rejects with the callback's error. There is no `rollback()`
|
||||||
|
method: to roll back, the module throws.
|
||||||
|
- **Unfinished queries.** Before committing or rolling back, Core waits for every query the scope
|
||||||
|
has already started. If one of them fails after the callback resolved, Core rolls back and reports
|
||||||
|
the failure.
|
||||||
|
- **Revocation.** When the callback settles, `tx` is revoked. Any later call on it fails with the
|
||||||
|
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
|
||||||
|
callback can never run a statement on a connection that has gone back to the pool.
|
||||||
|
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
|
||||||
|
boolean. An unknown option or value is refused, never ignored. `deferrable` is reserved for a
|
||||||
|
later version.
|
||||||
|
- **No automatic retries.** Core never retries a transaction. A callback may have side effects
|
||||||
|
outside the database, so retrying on `40001` is the module's decision.
|
||||||
|
- **Failed statements.** PostgreSQL semantics apply unchanged. After a failed statement the
|
||||||
|
transaction is aborted, and later statements fail until the scope ends. A module that wants to
|
||||||
|
continue after a failure uses a savepoint.
|
||||||
|
|
||||||
|
**Savepoints** are callback-scoped too:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
tx.savepoint(async (sp) => { … })
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
|
||||||
|
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
|
||||||
|
to it, releases it, and rejects with the callback's error.
|
||||||
|
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
|
||||||
|
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
|
||||||
|
overlap.
|
||||||
|
- **Revocation.** `sp` is revoked when its callback settles, exactly like `tx`.
|
||||||
|
|
||||||
|
Nested transactions are savepoints. No nested-transaction API exposes a driver or ORM object.
|
||||||
|
|
||||||
|
**Timeouts and cancellation stay with Core.** The existing per-statement bounds of the pool
|
||||||
|
(`statement_timeout` and `query_timeout`) apply to every ABI statement. Core may also bound how long
|
||||||
|
a transaction stays open. Modules cannot change Core's timeout policy through the ABI. `AbortSignal`
|
||||||
|
and module-initiated cancellation are not part of v1.
|
||||||
|
|
||||||
|
### Module-side adapters
|
||||||
|
|
||||||
|
The ABI is ORM-neutral. The Module SDK may ship adapters **on the module side**, bundled into each
|
||||||
|
module like the rest of the SDK (ADR 0049):
|
||||||
|
|
||||||
|
- **A Drizzle adapter.** It turns the executor into a Drizzle database the module's own tables work
|
||||||
|
with. It may use Drizzle's `pg-proxy` driver, mapping row-array queries to `rowMode: "array"` and
|
||||||
|
the rest to `"object"`. It supplies `transaction` by opening an ABI transaction and binding a fresh
|
||||||
|
Drizzle instance to `tx`.
|
||||||
|
- **A raw-SQL helper.** A tagged template that turns interpolations into placeholders and values.
|
||||||
|
- **Later, a Kysely adapter.**
|
||||||
|
|
||||||
|
Adapter types and query-builder types are not part of the ABI. Core never learns, and never needs
|
||||||
|
to learn, that a module used Drizzle. A module may bundle any Drizzle version its adapter supports.
|
||||||
|
|
||||||
|
### Schema metadata
|
||||||
|
|
||||||
|
For an ABI v1 module, Core validates table ownership from **plain, Ekklesia-owned metadata**, not
|
||||||
|
from ORM objects. Conceptually:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tables:
|
||||||
|
- name: care_team_members
|
||||||
|
foreignKeys:
|
||||||
|
- table: care_team_teams
|
||||||
|
```
|
||||||
|
|
||||||
|
- **What it is.** Plain data: strings, arrays, and records, with no functions or class instances.
|
||||||
|
Core validates its structure strictly.
|
||||||
|
- **What Core checks.** It is enough for the checks ADR 0007 already makes:
|
||||||
|
- the module namespace and identifier rules;
|
||||||
|
- uniqueness;
|
||||||
|
- foreign keys that stay within the module and target its declared tables;
|
||||||
|
- atomic, deterministic aggregation with sanitized issues.
|
||||||
|
- **What Core never does.** For ABI v1 modules, Core does not call Drizzle's `is(…, PgTable)`,
|
||||||
|
`getTableConfig`, or `PgColumn` checks on module-provided objects, and does not import an ORM to
|
||||||
|
understand the metadata.
|
||||||
|
- **How a module produces it.** A Drizzle helper in the Module SDK may derive it from the module's
|
||||||
|
own Drizzle tables, on the module side. A Kysely or raw-SQL module may write it by hand or produce
|
||||||
|
it another way.
|
||||||
|
- **What it promises.** The metadata is a declaration checked for consistency. It is exactly as
|
||||||
|
strong a correctness check as today's, and no stronger: the database enforces columns when the
|
||||||
|
migration runs.
|
||||||
|
|
||||||
|
Legacy modules keep the existing Drizzle-object path while legacy support exists. Where the metadata
|
||||||
|
lives (in the schema entrypoint's default export or in a static file) is decided by the change that
|
||||||
|
implements it. Either way, it must be readable without Core importing an ORM.
|
||||||
|
|
||||||
|
### Migrations are unchanged
|
||||||
|
|
||||||
|
Migration discovery, validation, and execution stay as ADR 0008, ADR 0009, and ADR 0048 define
|
||||||
|
them. The runner already executes SQL without an ORM runtime. Migrations stay forward-only and
|
||||||
|
explicit, and history is retained for absent modules.
|
||||||
|
|
||||||
|
The Drizzle Kit migration file format (journal, snapshots, and `_ekklesia.json`) remains the module
|
||||||
|
migration contract. Whether that build-tool format should become Ekklesia-owned is a separate
|
||||||
|
question, and this ADR does not change it.
|
||||||
|
|
||||||
|
### Module Package v1
|
||||||
|
|
||||||
|
ADR 0049's `packageFormat` stays **1**. For a module that declares ABI v1:
|
||||||
|
|
||||||
|
- **Drizzle is allowed in the bundle.** It is a module-side implementation detail, for example of
|
||||||
|
the adapter.
|
||||||
|
- **The packager omits `transitional.databaseRuntime`.** Core requires no Drizzle runtime agreement.
|
||||||
|
- **The verifier checks the ABI declaration and the plain schema metadata instead.** It refuses an
|
||||||
|
ABI v1 package that carries `transitional.databaseRuntime`. Such a package was made by a packager
|
||||||
|
that did not know the module's database contract.
|
||||||
|
|
||||||
|
A legacy module's package keeps today's `transitional.databaseRuntime` recording and validation for
|
||||||
|
as long as the legacy facade exists.
|
||||||
|
|
||||||
|
This needs no new package format. ADR 0049 already lets a format 1 package carry no `transitional`
|
||||||
|
facts. A platform from before this change cannot misread an ABI v1 package either: it refuses the
|
||||||
|
version 8 manifest.
|
||||||
|
|
||||||
|
### Compatibility and deprecation
|
||||||
|
|
||||||
|
The deprecation window applies to the **legacy database facade**, not to manifest version 7.
|
||||||
|
|
||||||
|
1. **Phase 1.** Core serves both contracts: the legacy facade to version 8 modules without a
|
||||||
|
`database` declaration, and an ABI v1 executor to modules that declare `abi: 1`.
|
||||||
|
2. **Phase 2.** The Module SDK and the module template use ABI v1. First-party modules move over
|
||||||
|
independently, each in its own change.
|
||||||
|
3. **Phase 3.** After at least one documented deprecation window, ending in a breaking Module SDK
|
||||||
|
release, the legacy facade may be removed. Its removal date, and how the manifest then expresses
|
||||||
|
the database contract, belong to that later decision.
|
||||||
|
|
||||||
|
The legacy facade is transitional. It is not part of the permanent module contract, and it keeps
|
||||||
|
its current behaviour until it is removed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module's ORM, and its version, no longer has to match Core's. Core can upgrade or replace its
|
||||||
|
own Drizzle without rebuilding any ABI v1 module.
|
||||||
|
- `transitional.databaseRuntime`, recorded in ADR 0049 as an implementation constraint, disappears
|
||||||
|
for ABI v1 packages, as ADR 0049 anticipated.
|
||||||
|
- Modules get one stable error shape with SQLSTATE, and one documented result encoding that loses
|
||||||
|
no precision and does not depend on time zones.
|
||||||
|
- A transaction object can no longer outlive its callback or reach a pooled connection afterwards.
|
||||||
|
- Every module moves to manifest version 8. Already-built version 7 packages must be rebuilt.
|
||||||
|
Database code changes only when a module opts into ABI v1.
|
||||||
|
- An ABI v1 module bundles an adapter. Its Drizzle code mostly stays as it is: the query builders
|
||||||
|
are its own.
|
||||||
|
- Core takes on a documented decoding table, transaction scoping, savepoint management, and error
|
||||||
|
mapping that Drizzle did implicitly before. Each needs live PostgreSQL test evidence.
|
||||||
|
- Core must serve two database contracts during the deprecation window.
|
||||||
|
- None of this is a sandbox. A trusted module can still do anything the API's database role can.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **An ORM AST or query-object ABI.** Rejected. Anything richer than SQL text and parameters is one
|
||||||
|
ORM's internal representation, so it would tie the ABI to that ORM and its release cadence. The
|
||||||
|
only part worth keeping, the row mode and field metadata, is already in the executor.
|
||||||
|
- **An Ekklesia-owned query DSL.** Rejected. It would cover less of PostgreSQL than Drizzle or
|
||||||
|
Kysely, cost more to maintain than either, and still force every module to rewrite its queries.
|
||||||
|
- **A permanent, versioned Drizzle facade.** Rejected as the permanent contract. It would make Drizzle
|
||||||
|
version agreement a permanent install-time constraint. Core's transaction object would keep
|
||||||
|
reaching module code. Core could never change its ORM. It remains only as the transitional legacy
|
||||||
|
facade.
|
||||||
|
- **Exposing the pool, a client, or a connection string.** Rejected, as in ADR 0016. Connection
|
||||||
|
lifecycle, timeouts, and transaction cleanup belong to the platform.
|
||||||
|
- **SQL parsing or execution-time prefix checks as a security boundary.** Rejected. SQL can name
|
||||||
|
tables in too many ways, and a trusted module can send any SQL, so a parser would be bypassable
|
||||||
|
and a false promise. ADR 0016 rejected the same idea.
|
||||||
|
- **`SET ROLE` or per-module PostgreSQL roles as a security boundary.** Rejected. Any SQL the module
|
||||||
|
sends can reset the role, and in-process code can reach the database without the ABI. Roles in a
|
||||||
|
module's CI, as a test aid, would be a correctness tool, not a boundary.
|
||||||
|
- **`Date` as a direct parameter.** Rejected. Its meaning depends on the target column's type, which
|
||||||
|
the ABI cannot see. Adapters convert it explicitly.
|
||||||
|
- **`pg`'s default parsing as the result contract.** Rejected. It is process-global and mutable, it
|
||||||
|
loses precision for `jsonb` numbers and `numeric[]` elements, and it is not an Ekklesia contract.
|
||||||
|
- **Parsing manifest versions 7 and 8 side by side.** Rejected. Manifest versions are hard cutovers,
|
||||||
|
and the deprecation window is about the database facade, not the manifest.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The implementation: Core's executor, decoding table, transactions, savepoints, errors, and their
|
||||||
|
tests.
|
||||||
|
- Manifest version 8's exact mechanics, and the template and fixture cutover.
|
||||||
|
- The Module SDK's Drizzle adapter and raw-SQL helper.
|
||||||
|
- A Kysely adapter.
|
||||||
|
- The legacy facade's removal date and the release that removes it.
|
||||||
|
- Redesigning the migration file format.
|
||||||
|
- SQL cancellation and `AbortSignal`.
|
||||||
|
- Stronger process or database isolation for modules.
|
||||||
|
- Moving Signage to ABI v1.
|
||||||
@@ -58,3 +58,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||||
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||||
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||||
|
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Proposed
|
||||||
|
|||||||
@@ -686,4 +686,5 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
|||||||
and loads the backend once in a short-lived process with an empty environment. Only then does it
|
and loads the backend once in a short-lived process with an empty environment. Only then does it
|
||||||
extract anything.
|
extract anything.
|
||||||
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
|
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
|
||||||
database ABI.
|
database ABI. [ADR 0050](decisions/0050-module-database-abi-v1.md), still proposed, defines the
|
||||||
|
Module Database ABI that removes it for modules that declare that ABI.
|
||||||
|
|||||||
Reference in New Issue
Block a user