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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user