docs: document module database access
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:
+75
-27
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user