docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s

Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
This commit is contained in:
ekklesia
2026-07-25 21:50:03 -05:00
parent d1b9efd4d2
commit c38b572778
15 changed files with 584 additions and 91 deletions
+75 -27
View File
@@ -215,11 +215,15 @@ abuse-control design.
permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`,
`apps/api/src/platform-permissions.ts`.
2. Construct the one shared PostgreSQL pool.
3. Construct the Core identity runtime, authorization store, and evaluator.
4. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step.
5. Create the Hono application, mounting the started module runtime's published routes.
6. Bind the HTTP server.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Start the module runtime from that exact same discovered inventory
(`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step — passing it the
services object every module's `start`/`stop` hook receives.
6. Create the Hono application, mounting the started module runtime's published routes and passing
the identical services object to every module HTTP handler.
7. Bind the HTTP server.
Discovery and permission composition happen before any pool, Better Auth instance, module
entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid
@@ -257,9 +261,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
(same permission check, response body stripped); a module cannot separately author `HEAD`.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
body all behave normally, and the full query string remains available) plus a frozen context of
exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters
(an empty frozen object for a route with none) — never a Hono `Context`, a database connection,
the authorization store, storage, jobs, configuration, or secrets.
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
empty frozen object for a route with none), and `services` — the same frozen
`ModuleRuntimeServices` object the module's own `start`/`stop` hooks received (see
[Module database access](#module-database-access)) — never a Hono `Context`, the authorization
store, storage, jobs, configuration, or secrets.
- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response`
becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the
caught value, a stack, the route's path or permission id, or the module id. A `Response` a
@@ -276,6 +282,46 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
registry: installing, removing, or editing a module's routes takes effect only on the next API
restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`.
## Module database access
`context.services.database` gives a module's `start`/`stop` lifecycle hooks and every authorized
HTTP handler a typed Drizzle query surface over its own tables — never during `register`, which
stays declarative and database-free. See
[Module development](module-development/README.md#database-access) for the module-authoring
contract; this section covers only how `apps/api` composes and wires it.
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
session/dialect state, no schema-bound relational `query` builder, and no connection string,
pool, or raw `pg` client — not merely hidden by the TypeScript type, but physically absent from
the constructed object at runtime (`core/src/modules/database.ts`'s `createModuleDatabaseView`
builds a frozen object with each operation explicitly bound, rather than handing out Drizzle's
own instance).
- **One pool, one facade, one services object.** `apps/api/src/database.ts`'s `createApiDatabase`
wraps the one shared PostgreSQL pool in three independent typed Drizzle views — identity,
authorization, and now `module` (a `ModuleDatabase`). `startApi` builds one frozen
`ModuleRuntimeServices` object (currently just `{ database }`) from that pool and passes the
*same* object to every module's `start` and `stop` hook and to every module HTTP handler for the
lifetime of one running API — never a fresh object per hook or per request, and never a second
pool or facade.
- **No database access without authorization.** A handler's context — and therefore
`services.database` — is unreachable until `requirePermission` (and, for a state-changing
method, `requireTrustedOrigin`) has already succeeded; a denied request, a rejected-Origin
request, and an `OPTIONS` preflight never invoke a module handler at all, so they never touch a
module's database.
- **Errors.** A handler-thrown database error is sanitized through the exact same
`MODULE_HANDLER_FAILED` boundary described above — there is no separate, database-specific public
error contract. A module that deliberately catches its own database failure may still return any
valid `Response` of its own choosing.
- **Migrations stay explicit.** Constructing the facade performs no query and no migration.
Module schema and migration changes still follow the existing generation, validation, policy, and
deployment flow (see [Module development](module-development/README.md#database-access)); nothing
in module discovery, registration, start, or an HTTP request infers a schema or creates a table
automatically. See [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the
complete design and its rejected alternatives.
## Database connection limits
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
@@ -287,10 +333,10 @@ a query is already running on one. The operation timeout additionally sets `pg`'
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
constructed, sanitized the same way an invalid connection string already is. The identity,
authorization, and module domains all read from this one pool through independent typed Drizzle
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
one pool, not two.
## Graceful shutdown
@@ -300,19 +346,22 @@ shared promise the first time it is called: every caller, concurrent or sequenti
exact same operation, and that operation runs exactly once no matter how many times `shutdown()`
is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight
requests finish; then stops the module runtime (so no module resource stops while a route handler
might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization
both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a
startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a
genuine result a caller should observe — but pool cleanup always still runs first, so a stop
failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is
extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
might still be in flight) — a module's `stop` hook may still use `services.database` here, since
the pool has not yet closed; then closes the one shared PostgreSQL pool (identity, authorization,
and the module database facade all read from it — see
[Authorization](authorization.md#shared-database-composition)). Unlike a startup-failure cleanup
path (below), a module-stop failure here is not swallowed — it is a genuine result a caller should
observe — but pool cleanup always still runs first, so a stop failure never leaves the pool open,
and no module code can run against it afterward. The executable entry point's `SIGINT`/`SIGTERM`
wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the same function production
composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a
signal arrived" without sending a real process signal or invoking the real `process.exit`.
A startup failure after the database pool was already created always closes that pool before the
failure propagates: identity/authorization construction failing (no module runtime was started
yet); the server failing to bind its listen address; or an app-construction failure, both of which
also stop the module runtime first, since it may have already started successfully. A module
failure propagates: module-services/identity/authorization construction failing (no module runtime
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
same pool); the server failing to bind its listen address; or an app-construction failure, both of
which also stop the module runtime first, since it may have already started successfully. A module
load/register/start failure uses Core's own existing rollback (already-started modules stop, in
reverse order, before the failure propagates) and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
@@ -354,10 +403,9 @@ Cookie: better-auth.session_token=<redacted, now revoked>
{"kind":"anonymous"}
```
## Next phase
## What's next
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.
See the repository root [README](../../README.md#current-status) and
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
for what has landed across every phase and what remains before Surface work begins. This document
tracks `apps/api`'s own HTTP, module-runtime, and database-composition contracts as they evolve.
+16 -12
View File
@@ -396,15 +396,19 @@ Every response sets `Cache-Control: no-store`.
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
and on any startup failure after the pool was already constructed (mirroring the existing
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with three
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
facade module code queries through `context.services.database`; see
[API](api.md#module-database-access) and
[Module development](../module-development/README.md#database-access)) — rather than three
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
once during `startApi()`, builds the identity runtime from `.identity`, the
`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen
`ModuleRuntimeServices` from `.module` off that same handle, and closes the handle exactly once —
on graceful shutdown, and on any startup failure after the pool was already constructed (mirroring
the existing identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
The existing per-domain factories (`createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
@@ -413,9 +417,9 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
the authorization schema and has no reason to share a pool with anything else.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
there are or whether the failure happened during startup.
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
module database usage), and exactly one `pool.end()` call regardless of how many concurrent
`shutdown()` callers there are or whether the failure happened during startup.
## Tests
+5 -4
View File
@@ -169,9 +169,10 @@ rejected the same sanitized way an invalid connection string already is.
## What is not operational yet
No email verification, password recovery, invitation flow, email delivery, social or OAuth
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
provider, passkey, magic link, two-factor authentication, or roles/groups/teams/tenants model
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
Surface, which still depends on an authorization model that does not exist yet.
public registration is intentional and grants no elevation. Core's authorization foundation
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on.
+17
View File
@@ -97,6 +97,23 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin.
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves `context.services.database` against a real,
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
repository's live `modules/` directory, and invisible to normal discovery/Doctor/`modules:validate`
unless a test explicitly points discovery at it): the module's own table does not exist until its
migration is explicitly applied, and constructing the pool or facade never creates it; `start`,
`stop`, and an authorized HTTP handler all receive the identical `ModuleRuntimeServices` object,
proven by reference equality; a real authorized mutation persists and a later read observes it,
gated by the same permission/trusted-Origin enforcement every other module route uses; a real
committed transaction persists and a real rollback (a deliberate unique-constraint violation)
leaves no partial mutation; a module deliberately catching a real PostgreSQL failure returns its
own response untouched, while an uncaught one sanitizes to the existing
`MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; and a `stop` hook's write
completes and is independently verifiable, through the harness's own administrative connection —
never the API's own pool — before that pool closes. See
[ADR 0016](../architecture/decisions/0016-module-database-access.md).
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection: