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
+80 -8
View File
@@ -157,9 +157,9 @@ export default defineModuleRuntime({
capabilities.provide("calendar.events", calendarEvents);
},
async start({ capabilities }) {
async start({ capabilities, services }) {
const people = capabilities.resolve("people.records");
// Acquire runtime resources here.
await services.database.insert(events).values({ id: "seed", title: "First event" });
},
async stop() {
@@ -170,8 +170,11 @@ export default defineModuleRuntime({
ID and version are intentional build-artifact identity markers and must match the manifest. Core
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
own capabilities and the capability dependencies declared in its manifest. The runtime context is
deliberately limited to the module ID and capability access.
own capabilities and the capability dependencies declared in its manifest. The `register` context
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) —
never `services`. `start` and `stop` share one context shape and both additionally receive
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API,
currently exposing only `database` — see [Database access](#database-access).
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
@@ -235,10 +238,12 @@ method and path (or two paths that are identical once every parameter is treated
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, and the immutable decoded path parameters — never a database
connection, authorization store, grant/platform-owner state, storage, jobs, configuration, secrets,
a framework-specific request context, or a raw Better Auth session. The full query string remains
available through `Request.url`; there is no separate query or body abstraction.
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request
context, or a raw Better Auth session. The full query string remains available through
`Request.url`; there is no separate query or body abstraction.
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
@@ -344,6 +349,73 @@ destructive behavior. Review those properties explicitly. Core is the only migra
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
falls back to non-transactional execution.
## Database access
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
supported way for module code to reach PostgreSQL:
```ts
import { defineModuleRuntime } from "@ekklesia/module-sdk";
import { events } from "./schema/tables.js";
export default defineModuleRuntime({
id: "calendar",
version: "0.1.0",
register({ http }) {
http.route({
method: "GET",
path: "/events",
permission: "calendar.events.read",
async handler(_request, context) {
const rows = await context.services.database
.select({ id: events.id, title: events.title })
.from(events);
return Response.json(rows);
},
});
},
});
```
Import your module's own table objects (`src/schema/tables.js` — the same declarations
[Drizzle schema definition](#drizzle-schema-definition) already covers) exactly as you would with
an independently constructed Drizzle instance; the only difference is that `services.database`,
not a client you built yourself, executes the query. Never:
- Construct a `Pool`, a `Client`, or a Drizzle instance in module code.
- Read `DATABASE_URL` or any other connection configuration.
- Access `$client`, a session, or a dialect object — `ModuleDatabase` exposes none of them, and not
merely at the type level: the constructed facade physically has no such property.
- Stash `services` (or `services.database`) in a module-global variable. The platform provides the
identical object directly to `start`, `stop`, and every authorized handler; there is nothing to
cache.
- Run or generate a migration from `register`, `start`, `stop`, or a route handler. Schema and
migration changes remain [explicit deployment work](#drizzle-migrations) — generate, review,
commit, and apply them the same way as any other module migration, *before* starting code that
depends on the new shape. Nothing in discovery, registration, or startup infers a schema or
creates a table automatically.
A handler's database error is sanitized through the same `500 {"code":"MODULE_HANDLER_FAILED"}`
boundary as any other handler failure (see [HTTP routes](#http-routes)) — there is no separate,
database-specific error contract. Catch a failure yourself if your route should return something
more specific; an uncaught one is sanitized automatically.
`ModuleDatabase` is an integration boundary for trusted, compiled backend module code, not a
sandbox: it exists so your module never has to construct or configure a PostgreSQL connection
itself, not to defend against malicious code, since a module already runs arbitrary code in this
process. It provides no tenant isolation and does not stop your own code from querying a table it
imports; `execute` in particular lets trusted code run arbitrary SQL, exactly as every other
operation already lets it construct arbitrary queries. Keep querying only your own module's tables
— the same convention [Drizzle schema definition](#drizzle-schema-definition) already establishes
for imports, just extended to runtime queries. See
[API](../development/api.md#module-database-access) for how `apps/api` composes and wires the
facade, and [ADR 0016](../architecture/decisions/0016-module-database-access.md) for the complete
design.
## Discovery and lifecycle
From the repository root, run: