|
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
# 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. **One `query` call
|
|
|
|
|
|
|
|
runs one statement.** This is a correctness and API rule, not a security boundary.
|
|
|
|
|
|
|
|
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
|
|
|
|
|
|
|
|
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
|
|
|
|
|
|
|
|
- It does not require, and must not grow, an Ekklesia SQL parser.
|
|
|
|
|
|
|
|
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
|
|
|
|
|
|
|
|
the conflict. It does not weaken this ADR silently.
|
|
|
|
|
|
|
|
- **`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? }
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The two fields mean different things:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
|
|
|
|
|
|
|
|
programmatic handling. Its categories cover at least:
|
|
|
|
|
|
|
|
- PostgreSQL rejected the statement;
|
|
|
|
|
|
|
|
- a parameter value was refused;
|
|
|
|
|
|
|
|
- a transaction option was refused;
|
|
|
|
|
|
|
|
- the scope was closed or used incorrectly;
|
|
|
|
|
|
|
|
- the transaction could not be completed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
|
|
|
|
|
|
|
|
meaning.
|
|
|
|
|
|
|
|
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
|
|
|
|
|
|
|
|
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
|
|
|
|
|
|
|
|
It is present only when the database supplied one, and Core never invents one.
|
|
|
|
|
|
|
|
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
|
|
|
|
|
|
|
|
invalid parameter type, or an invalid transaction option.
|
|
|
|
|
|
|
|
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
|
|
|
|
|
|
|
|
- **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 only when the callback's promise resolves and no operation
|
|
|
|
|
|
|
|
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
|
|
|
|
|
|
|
|
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
|
|
|
|
|
|
|
|
the module throws.
|
|
|
|
|
|
|
|
- **All work is awaited inside the callback.** Module code must await every database operation it
|
|
|
|
|
|
|
|
starts through `tx` before its callback returns.
|
|
|
|
|
|
|
|
- If the callback settles while an operation launched through that scope is still outstanding, the
|
|
|
|
|
|
|
|
module has used the scope incorrectly.
|
|
|
|
|
|
|
|
- The transaction is then never committed: Core rolls it back, and the transaction call rejects
|
|
|
|
|
|
|
|
with the scope-usage `ModuleDatabaseError`.
|
|
|
|
|
|
|
|
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains.
|
|
|
|
|
|
|
|
- Core may let operations that already started finish, or settle, before it rolls back and releases
|
|
|
|
|
|
|
|
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
|
|
|
|
|
|
|
|
never turns into a commit.
|
|
|
|
|
|
|
|
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
|
|
|
|
|
|
|
|
finished. 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.
|
|
|
|
|
|
|
|
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback
|
|
|
|
|
|
|
|
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled
|
|
|
|
|
|
|
|
back, never released, and the call rejects with that 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.
|