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:
@@ -7,8 +7,8 @@ surfaces.
|
||||
## Current status
|
||||
|
||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
||||
Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
|
||||
repository includes
|
||||
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
|
||||
transport), and Phase 5C (module database access) have landed. The repository includes
|
||||
public module contracts, independently versioned capabilities, static module manifests,
|
||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||
@@ -80,6 +80,17 @@ See [API](docs/development/api.md#module-http-routes),
|
||||
[Module development](docs/module-development/README.md#http-routes), and
|
||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred).
|
||||
|
||||
Phase 5C gives a module's own lifecycle hooks and HTTP handlers typed access to their own
|
||||
PostgreSQL tables. `context.services.database` — a restricted, typed `ModuleDatabase` view over
|
||||
Drizzle's `select`/`selectDistinct`/`selectDistinctOn`/`insert`/`update`/`delete`/`execute`/
|
||||
`transaction`, never a raw `pg` client, pool, or connection string — is available to `start`,
|
||||
`stop`, and every authorized route handler, and reuses the one PostgreSQL pool `apps/api` already
|
||||
owns; registration remains declarative and database-free. Modules continue to own and import their
|
||||
own Drizzle schemas and generated migrations, applied explicitly and never automatically at
|
||||
startup. See [API](docs/development/api.md#module-database-access),
|
||||
[Module development](docs/module-development/README.md#database-access), and
|
||||
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
+7
-5
@@ -28,14 +28,16 @@ registry, which conflicts with install-by-cloning discovery.
|
||||
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
|
||||
> historical record of the manifest's prior shape.
|
||||
|
||||
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
|
||||
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md) and
|
||||
> [ADR 0016](0016-module-database-access.md).**
|
||||
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
|
||||
> contain only the module ID and a capability registrar or resolver" (this section, below) described
|
||||
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
|
||||
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
|
||||
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
|
||||
> limited to module ID and capability access as originally decided here. Every other statement in
|
||||
> this ADR remains accepted and unchanged.
|
||||
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar. ADR 0016
|
||||
> further extends `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes)
|
||||
> with a `services` field carrying the module database facade — `ModuleRegistrationContext` itself
|
||||
> gains no such field and remains exactly as database-free as originally decided here. Every other
|
||||
> statement in this ADR remains accepted and unchanged.
|
||||
|
||||
`ekklesia.module.json` remains the one canonical static manifest and moves to
|
||||
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
||||
|
||||
@@ -162,6 +162,16 @@ configured trusted origin — never a wildcard, and never anything module-influe
|
||||
|
||||
### A frozen, minimal handler context and sanitized failure boundary
|
||||
|
||||
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
|
||||
> "A frozen context of exactly `moduleId`... and an immutable, Hono-decoded parameter record" and "A
|
||||
> handler never receives a Hono `Context`, a database connection... or any other middleware-internal
|
||||
> state," below, described the handler context's complete shape accurately at the time. ADR 0016
|
||||
> adds a fourth field, `services`, carrying `ModuleRuntimeServices` (currently just a typed
|
||||
> `ModuleDatabase` facade) — never a raw database connection, `pg.Pool`, `PoolClient`, connection
|
||||
> string, or other infrastructure object. Every other statement in this section remains accepted and
|
||||
> unchanged: a handler still never receives a Hono `Context`, the authorization store or evaluator,
|
||||
> raw PostgreSQL access, configuration, or secrets.
|
||||
|
||||
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
|
||||
body, and a streaming request all behave normally, and the full query string remains available
|
||||
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
|
||||
@@ -276,6 +286,10 @@ runs, since nothing about this design sandboxes it.
|
||||
|
||||
## Deferred
|
||||
|
||||
> **Cross-reference — see [ADR 0016](0016-module-database-access.md).**
|
||||
> Module database or Drizzle access, listed immediately below as deferred, is now implemented.
|
||||
> Every other item in this list remains deferred and unimplemented.
|
||||
|
||||
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
|
||||
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
|
||||
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
# 0016: Module database access
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-25
|
||||
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
|
||||
[0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0007 and ADR 0008 already let a module own a Drizzle schema and a validated, checksummed
|
||||
migration; ADR 0009 already applies those migrations transactionally. ADR 0015 let a module mount
|
||||
permissioned HTTP routes and receive an authorized principal. None of these gave a module's
|
||||
*executable* code — a `start`/`stop` lifecycle hook or a route handler — any supported way to
|
||||
actually query or write the tables its own schema declares. A module wanting to persist or read
|
||||
product data had no platform-sanctioned path to PostgreSQL at all.
|
||||
|
||||
The platform already owns exactly one physical PostgreSQL pool per API runtime
|
||||
(`apps/api/src/database.ts`), wrapped in typed Drizzle views for Core's own identity and
|
||||
authorization schemas (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Handing a
|
||||
module that raw pool, a connection string, or an independently constructed Drizzle instance would
|
||||
either leak platform connection infrastructure into module code or require a second, competing
|
||||
pool — both rejected by the same reasoning ADR 0006 and ADR 0015 already applied to capability
|
||||
registration and HTTP routing. This ADR records the third typed view over that one pool, and the
|
||||
runtime-services boundary that delivers it to trusted module code without exposing the pool
|
||||
itself.
|
||||
|
||||
## Decision
|
||||
|
||||
### `ModuleDatabase`: a restricted, typed view over `NodePgDatabase`
|
||||
|
||||
`packages/contracts/src/module-database.ts` defines `ModuleDatabase` as
|
||||
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
|
||||
"delete" | "execute" | "transaction">` — derived from Drizzle's own `node-postgres` database type
|
||||
with `Pick`, not hand-declared, so every method keeps Drizzle's own table-aware argument and
|
||||
result inference exactly as-is. A module calling `database.select().from(table)` or
|
||||
`database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||
would; no `any` or `unknown` erasure exists anywhere in the public type. `ModuleDatabase` carries
|
||||
no schema generic: a module brings its own Drizzle table objects to each call, so there is no
|
||||
global relational-query object spanning every installed module's tables for this type to expose.
|
||||
|
||||
The type excludes everything else `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||
carries: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||
`query` (a schema-bound relational query builder), `$cache`, `$count`, `$with`, `with`, and
|
||||
`refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||
reachable through the type.
|
||||
|
||||
### The facade physically closes the same gaps at runtime, not only in the type system
|
||||
|
||||
`core/src/modules/database.ts`'s `createModuleDatabaseView(pool)` does not return the
|
||||
`drizzle(pool)` instance directly. Drizzle's own query methods are plain prototype methods that
|
||||
read `this.session`/`this.dialect` internally, not arrow-function instance properties, so handing
|
||||
that instance to a module directly would both break the moment a module destructured a method off
|
||||
it (`const { select } = database` loses `this`) and — more importantly — would leave every
|
||||
non-public property physically reachable on the object at runtime, reachable to a module written
|
||||
in plain JavaScript or one that bypasses the `ModuleDatabase` type with a cast, not merely hidden
|
||||
by TypeScript. `createModuleDatabaseView` instead builds a fresh, `Object.freeze`d object with
|
||||
each of the eight operations explicitly bound to the underlying Drizzle instance: only those eight
|
||||
exist on the object at all, each correctly bound, and nothing else does. Building this view
|
||||
performs no query and no migration — `drizzle(pool)` only wraps the given client, synchronously
|
||||
and without I/O, the same construction `createIdentityDrizzleView`/`createAuthorizationDrizzleView`
|
||||
already rely on. Core never constructs a `pg.Pool` itself; `apps/api/src/database.ts` remains the
|
||||
one place that does, and `createModuleDatabaseView` wraps the pool it is given.
|
||||
|
||||
### `ModuleRuntimeServices` is the one typed boundary carrying it into trusted module code
|
||||
|
||||
`ModuleRuntimeServices` (`packages/contracts/src/module-database.ts`) currently contains exactly
|
||||
one field: `database: ModuleDatabase`. It is frozen, and it is never handed to a module's
|
||||
`register` hook — `ModuleRegistrationContext` (ADR 0006, extended by ADR 0015 with `http`) gains no
|
||||
`services` field at all, so registration remains exactly as declarative and database-free as
|
||||
originally decided. `ModuleRuntimeContext` (the shared `start`/`stop` context) and
|
||||
`ModuleRouteHandlerContext` (ADR 0015's authorized-handler context, now exactly `moduleId`,
|
||||
`principal`, `params`, `services`) both gain a `services` field instead. For a given API runtime,
|
||||
`start`, `stop`, and every authorized HTTP handler receive the *same* `ModuleRuntimeServices`
|
||||
object — the same object identity, not a fresh one built per hook or per request — so a module
|
||||
never needs to stash a database reference in module-global state to share it between lifecycle
|
||||
code and its own route handlers; the platform simply provides it directly, every time, to every
|
||||
authorized call site.
|
||||
|
||||
### One pool, one facade, one services object, per API runtime
|
||||
|
||||
`apps/api/src/server.ts`'s `startApi` composes in a fixed order: discover the module inventory once
|
||||
and compose the permission registry from it; construct the one runtime `pg.Pool`
|
||||
(`createApiDatabase`, wrapping identity, authorization, and module views over it); construct the
|
||||
frozen `ModuleRuntimeServices` from that same pool's module view; construct Identity, the
|
||||
authorization store, and the evaluator; start the already-discovered module runtime, passing it
|
||||
that services object; create the Hono application and mount the started runtime's routes, passing
|
||||
the identical services object; bind the HTTP server. `startDiscoveredModuleRuntime` threads the
|
||||
same services object into `ModuleRuntimeContext` for every module's `start` and `stop`;
|
||||
`mountModuleHttpRoutes` threads the identical object into every authorized handler's
|
||||
`ModuleRouteHandlerContext`. No second pool, no second facade, and no second services object is
|
||||
ever constructed for one running API. A services/Identity/Authorization construction failure
|
||||
closes the one pool and starts no module; a module-runtime failure closes the pool without binding
|
||||
a server; an app-construction or bind failure stops the module runtime, then closes the pool —
|
||||
matching the cleanup-ordering discipline ADR 0015 already established, now additionally covering
|
||||
the services object's own dependencies.
|
||||
|
||||
### Shutdown remains drain → stop → close, now load-bearing for in-flight database use
|
||||
|
||||
Normal shutdown is unchanged from ADR 0015: drain and close the HTTP listener first, so no new or
|
||||
in-flight request can reach a module handler while the module runtime is stopping; stop the module
|
||||
runtime next — a module's `stop` hook may still use `services.database`, which is safe because the
|
||||
pool has not yet closed; only then close the shared pool, so no module code can run against it
|
||||
afterward. This ordering was already correct under ADR 0015; it is now additionally load-bearing,
|
||||
because a `stop` hook doing real database work depends on the pool still being open when it runs.
|
||||
Shutdown remains memoized — one shared promise every concurrent or later caller awaits — and a
|
||||
module-stop failure still does not prevent pool closure, though (unlike a startup-cleanup path) it
|
||||
is not swallowed: it remains a genuine result a caller observes.
|
||||
|
||||
### Module schemas and migrations are unchanged; the facade is not a migration tool
|
||||
|
||||
A module continues to own and import its own Drizzle table definitions (ADR 0007) and its own
|
||||
generated, checksummed migration (ADR 0008), applied transactionally and explicitly (ADR 0009).
|
||||
`ModuleDatabase` exposes no migration operation, and nothing in the module runtime infers a
|
||||
schema, creates a table automatically, or runs a migration as a side effect of module discovery,
|
||||
registration, start, or an HTTP request. A module's schema and migration changes still follow the
|
||||
existing generation, validation, policy, and deployment flow unchanged; they must be applied,
|
||||
exactly as before, before starting module code that depends on the new shape.
|
||||
|
||||
## Trust and security boundary
|
||||
|
||||
Modules remain trusted, compiled backend plugins — the same trust model ADR 0006 established for
|
||||
capability registration and ADR 0015 extended to HTTP handlers. `ModuleDatabase` is a deliberately
|
||||
restricted *integration* boundary, not a security sandbox: it exists to keep a module's code
|
||||
Drizzle-typed and decoupled from how the platform physically connects to PostgreSQL, not to defend
|
||||
against a malicious module, which already runs arbitrary code in this process and could reach
|
||||
PostgreSQL through other means regardless of what this facade does or does not expose. It provides
|
||||
no tenant isolation, no PostgreSQL-role-level separation between modules, and no runtime
|
||||
enforcement that one module's code only ever queries its own tables — table ownership is an
|
||||
architectural and migration-policy convention (ADR 0007's naming/prefix rules), not something this
|
||||
facade inspects or restricts at the SQL level; `execute` in particular lets a trusted module issue
|
||||
arbitrary SQL, exactly as `select`/`insert`/`update`/`delete`/`transaction` already let it construct
|
||||
arbitrary queries against any table object it imports. Authorization still fully protects the HTTP
|
||||
path into that trusted code: `requirePermission` and, for state-changing methods,
|
||||
`requireTrustedOrigin` (ADR 0013, ADR 0015) complete before a handler — and therefore before
|
||||
`services.database` is ever reachable — runs. A handler-thrown database error, caught or not,
|
||||
remains sanitized by the existing module-handler boundary (ADR 0015): an uncaught failure becomes
|
||||
exactly `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` and no SQL,
|
||||
PostgreSQL message or code, table/constraint name, stack, connection detail, or filesystem path;
|
||||
this ADR introduces no second, database-specific public error contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A module can persist and query its own product data with full Drizzle type inference, without
|
||||
ever owning a connection, a pool, or connection configuration.
|
||||
- The platform still owns exactly one physical pool and one shutdown lifecycle per API runtime;
|
||||
adding module database access did not require a second one.
|
||||
- Lifecycle hooks and HTTP handlers share one consistent services boundary
|
||||
(`ModuleRuntimeServices`), extensible the same deliberate way in the future.
|
||||
- Registration remains declarative and side-effect-free, preserving every existing discovery,
|
||||
Doctor, and `modules:validate` guarantee (none of them constructs the facade or executes module
|
||||
code).
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- Adding a new Drizzle operation to the module-facing surface requires deliberately extending
|
||||
`ModuleDatabase`'s `Pick`, not merely relying on whatever `NodePgDatabase` happens to expose.
|
||||
- A trusted module can still issue arbitrary SQL through `execute`; this facade does not, and does
|
||||
not claim to, prevent that.
|
||||
- Table ownership remains a naming/migration-policy convention, not a runtime-enforced sandbox —
|
||||
the same tradeoff ADR 0007 already accepted for schema ownership.
|
||||
- Static installation and API-restart requirements are unchanged: a module's database access, like
|
||||
its routes and permissions, only takes effect on the next restart.
|
||||
- A module's migration must be applied before starting code that depends on it; the platform does
|
||||
not infer or run it automatically, so an operator error here surfaces as a real database error
|
||||
from the module's own code, sanitized like any other handler failure.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Exposing `pg.Pool` or `$client` directly** was rejected: it would leak platform connection
|
||||
infrastructure into module code and make every module responsible for connection lifecycle the
|
||||
platform already owns correctly.
|
||||
- **Supplying `DATABASE_URL` to modules** was rejected for the same reason, plus the operational
|
||||
risk of a module opening its own competing connections outside the platform's one validated pool.
|
||||
- **One pool per module** was rejected: it would multiply connection overhead and configuration
|
||||
surface for no isolation benefit this facade does not already provide at the type level, and
|
||||
contradicts the one-pool-per-runtime model ADR 0015 already established.
|
||||
- **Letting a module construct its own Drizzle instance** was rejected: it would reintroduce
|
||||
exactly the raw-pool/connection-string exposure this ADR exists to avoid.
|
||||
- **Database access during `register`** was rejected: registration remains declarative — the same
|
||||
reasoning ADR 0006 already applied to capability provision — and a database-free registration
|
||||
hook can never have a partially-open connection to clean up if discovery aborts.
|
||||
- **Module-global service assignment** (a module stashing `services` in a top-level variable from
|
||||
`start` for its handlers to read later) was rejected as the *documented* pattern: the platform
|
||||
provides the identical object directly to every authorized call site, so no module needs to
|
||||
invent its own storage for it.
|
||||
- **Running migrations automatically at startup** was rejected: it would violate the existing,
|
||||
explicit, operator-invoked migration model (ADR 0009) and risk running unreviewed DDL as a
|
||||
side effect of an API restart.
|
||||
- **A generic, string-keyed service locator** (`services.get("database")`) was rejected in favor of
|
||||
a fixed, typed `ModuleRuntimeServices` shape: a locator would erase type information at exactly
|
||||
the boundary this ADR exists to keep typed, and would let a module probe for services that do
|
||||
not exist yet.
|
||||
- **A global schema/query object spanning every installed module** (Drizzle's own schema-bound
|
||||
`query` relational builder, extended to cover every module) was rejected: it would require the
|
||||
platform to aggregate every module's schema into one object at runtime, reintroducing exactly the
|
||||
cross-module coupling ADR 0007's per-module schema ownership already rejected.
|
||||
- **SQL parsing, rewriting, or query interception as a runtime isolation boundary** was rejected:
|
||||
modules are trusted compiled code already capable of running arbitrary queries through `execute`;
|
||||
a parsing layer would be both bypassable and a false promise of a security boundary this facade
|
||||
does not attempt to provide.
|
||||
|
||||
## Testing evidence
|
||||
|
||||
- Unit coverage (contracts, Module SDK, Core, `apps/api`) proves: the public `ModuleDatabase`
|
||||
surface preserves Drizzle's table-aware inference with no `any`; raw pool/client/session/dialect
|
||||
properties are absent from the type and, separately, from the constructed facade at runtime;
|
||||
every forwarded method remains correctly bound when destructured; `register` receives no
|
||||
services/database field; `start`/`stop` receive the same frozen services object; a module HTTP
|
||||
handler's database is reference-equal to its module's lifecycle database; denied,
|
||||
trusted-origin-rejected, and `OPTIONS` requests never touch the database fake; a zero-module
|
||||
runtime never constructs or touches a database; startup/shutdown cleanup ordering holds under
|
||||
every failure path exercised.
|
||||
- A real installed-module PostgreSQL integration fixture (`core/tests/fixtures/
|
||||
module-database-integration/db-facade/`, exercised from `apps/api/tests/
|
||||
module-database.integration.ts`) proves the same guarantees against a real, disposable
|
||||
PostgreSQL database: the module's table does not exist before its migration is explicitly
|
||||
applied; constructing the pool and facade performs no DDL; starting the API never runs or
|
||||
regenerates a migration; a real `start` hook write is visible to a later authorized HTTP read
|
||||
through the identical facade object; a real authorized mutation persists and a later read
|
||||
observes it, gated by permission enforcement and trusted-Origin checking exactly as ADR 0015
|
||||
requires; 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 real failure sanitizes
|
||||
to the existing `MODULE_HANDLER_FAILED` boundary with no leaked SQL, error text, or path; a
|
||||
`stop` hook's write completes and is independently observable before the runtime's pool closes.
|
||||
@@ -24,3 +24,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
|
||||
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
|
||||
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
|
||||
- [0016: Module database access](0016-module-database-access.md) — Accepted
|
||||
|
||||
@@ -95,12 +95,29 @@ identical lowest-privilege baseline status, and signup order never grants elevat
|
||||
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
|
||||
[the API guide](../development/api.md), and
|
||||
[the identity development guide](../development/identity.md) for the underlying schema and
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
|
||||
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
|
||||
permission.
|
||||
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out.
|
||||
|
||||
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
|
||||
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
|
||||
command. Other target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
|
||||
contracts, dashboard, Builder, and reference feature modules.
|
||||
Phase 4C is complete: Core's authorization foundation — a schema, evaluator, and store for
|
||||
platform owners, explicit grants, and relationship-derived grants, plus an immutable permission
|
||||
registry — and an explicit, single-use, auditable platform-owner bootstrap command, plus reusable
|
||||
Hono enforcement and an `/api/v1/grants` HTTP API in `apps/api`. See
|
||||
[ADR 0013](decisions/0013-core-authorization-and-owner-bootstrap.md) and
|
||||
[the authorization guide](../development/authorization.md).
|
||||
|
||||
Phase 5A wired module-declared permissions into that same authorization system — a version-3
|
||||
manifest, one composed platform permission registry spanning Core's own declarations and every
|
||||
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
|
||||
module mount its own framework-neutral HTTP routes in production, under
|
||||
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. This
|
||||
branch completes Phase 5C: typed module database access (`context.services.database`) for a
|
||||
module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL
|
||||
pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
|
||||
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[ADR 0016](decisions/0016-module-database-access.md), and the repository root
|
||||
[README](../../README.md#current-status) for the complete, current status of every phase.
|
||||
|
||||
The project continues finishing the module platform's backend foundations before Phase 6 Surface
|
||||
work begins. Target architecture not yet implemented includes production approval-provider
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
|
||||
scheduled by this document.
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
|
||||
generated module-owned migration, and logical storage-bucket declarations. Migration execution,
|
||||
API routing, and storage provisioning are intentionally not included.
|
||||
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
|
||||
route (`GET /records`, declared in `register`) that reads that table through
|
||||
`context.services.database` — the platform-provided `ModuleDatabase` facade, never a
|
||||
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
|
||||
provisioning are intentionally not included.
|
||||
|
||||
To create a module inside this repository:
|
||||
|
||||
@@ -14,7 +17,10 @@ To create a module inside this repository:
|
||||
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
|
||||
module actually implements and consumes.
|
||||
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
|
||||
must match the manifest; do not duplicate the rest of the manifest in TypeScript.
|
||||
must match the manifest; do not duplicate the rest of the manifest in TypeScript. Declare HTTP
|
||||
routes only in `register`, against a permission the manifest already declares; `start`, `stop`,
|
||||
and every route handler — never `register` — receive `context.services.database`, a typed
|
||||
`ModuleDatabase` view over the platform's one PostgreSQL pool.
|
||||
6. Replace the pure Drizzle declarations in `src/schema/tables.ts` and keep its table prefix aligned
|
||||
with the module ID. Keep the `defineModuleSchema` ownership wrapper in `src/schema/index.ts`
|
||||
aligned too, and never import another module's schema.
|
||||
@@ -29,7 +35,9 @@ To create a module inside this repository:
|
||||
|
||||
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
||||
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||
and release them in `stop`.
|
||||
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
|
||||
raw `pg` client — `context.services.database` is the only supported way to query this module's own
|
||||
tables, and it is never available during `register`.
|
||||
|
||||
The migration directory uses the pinned stable Drizzle Kit `0.31.10` PostgreSQL journal and
|
||||
snapshot format. Migrations are forward-only and may modify only this module's objects. Offline
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core";
|
||||
import { createModuleTable } from "@ekklesia/module-sdk";
|
||||
import { text, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
const moduleTable = pgTableCreator((localName) => `example_${localName}`);
|
||||
const moduleTable = createModuleTable("example");
|
||||
|
||||
export const records = moduleTable("records", {
|
||||
id: uuid("id").primaryKey(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||
import { records } from "../schema/tables.js";
|
||||
|
||||
export const recordsCapability = Object.freeze({
|
||||
kind: "example-records",
|
||||
@@ -7,7 +8,25 @@ export const recordsCapability = Object.freeze({
|
||||
export default defineModuleRuntime({
|
||||
id: "example",
|
||||
version: "0.1.0",
|
||||
register({ capabilities }) {
|
||||
register({ capabilities, http }) {
|
||||
capabilities.provide("example.records", recordsCapability);
|
||||
|
||||
// The accepted module database boundary: `context.services.database` — provided only to an
|
||||
// authorized handler, never to `register` — is this module's one way to reach PostgreSQL. It
|
||||
// is a fully typed Drizzle query surface over the module's own table (`../schema/tables.js`),
|
||||
// never a raw `pg` client, a connection string, or a Drizzle instance this module built
|
||||
// itself. `example.records.read` is declared in `ekklesia.module.json`, exactly like every
|
||||
// other permission a module route enforces.
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
async handler(_request, context) {
|
||||
const rows = await context.services.database
|
||||
.select({ id: records.id, value: records.value })
|
||||
.from(records);
|
||||
return Response.json(rows);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type CapabilityId,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
parseDrizzleMigrationJournal,
|
||||
parseModuleManifest,
|
||||
parseModuleMigrationPolicy,
|
||||
@@ -12,6 +17,38 @@ import { describe, expect, it } from "vitest";
|
||||
import moduleSchema, { records } from "../src/schema/index.js";
|
||||
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
|
||||
|
||||
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for this unit test, which only
|
||||
* proves the example handler queries through the facade and shapes its response correctly. */
|
||||
function fakeModuleDatabase(rows: readonly { id: string; value: string }[]): ModuleDatabase {
|
||||
const selectBuilder = { from: () => Promise.resolve(rows) };
|
||||
return { select: () => selectBuilder } as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
async function registerAndCaptureRoutes(): Promise<{
|
||||
provided: Map<CapabilityId, unknown>;
|
||||
routes: ModuleRouteDefinitionInput[];
|
||||
}> {
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
const routes: ModuleRouteDefinitionInput[] = [];
|
||||
await moduleRuntime.register?.({
|
||||
capabilities: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
},
|
||||
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
http: {
|
||||
route(definition) {
|
||||
routes.push(definition);
|
||||
},
|
||||
},
|
||||
moduleId: "example",
|
||||
});
|
||||
return { provided, routes };
|
||||
}
|
||||
|
||||
describe("module template", () => {
|
||||
it("ships a valid backend-only manifest", async () => {
|
||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||
@@ -59,27 +96,41 @@ describe("module template", () => {
|
||||
expect(sql.toString("utf8")).toContain('CREATE TABLE "example_records"');
|
||||
});
|
||||
|
||||
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
await moduleRuntime.register?.({
|
||||
capabilities: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
},
|
||||
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
http: {
|
||||
route() {
|
||||
throw new Error("The template does not register any HTTP route.");
|
||||
},
|
||||
},
|
||||
moduleId: "example",
|
||||
});
|
||||
it("exports the typed runtime definition used by the compiled server entrypoint, registering its one example route database-free", async () => {
|
||||
const { provided, routes } = await registerAndCaptureRoutes();
|
||||
|
||||
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
|
||||
expect(provided.get("example.records")).toBe(recordsCapability);
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]).toMatchObject({
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
});
|
||||
});
|
||||
|
||||
it("queries the module's own table through context.services.database in the example route handler", async () => {
|
||||
const { routes } = await registerAndCaptureRoutes();
|
||||
const route = routes[0];
|
||||
if (!route) {
|
||||
throw new Error("Expected the example route to be registered.");
|
||||
}
|
||||
|
||||
const fakeRows = [{ id: "9d3f9f7e-3b1a-4c9a-8b8e-2b6b1e6f9a11", value: "hello" }];
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
database: fakeModuleDatabase(fakeRows),
|
||||
});
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
moduleId: "example",
|
||||
params: Object.freeze({}),
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
services,
|
||||
});
|
||||
|
||||
const response = await route.handler(new Request("http://localhost/records"), context);
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(await response.json()).toEqual(fakeRows);
|
||||
});
|
||||
|
||||
it("exports a namespaced module-owned Drizzle schema", () => {
|
||||
|
||||
Reference in New Issue
Block a user