feat: define module database access

Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
This commit is contained in:
ekklesia
2026-07-25 20:57:32 -05:00
parent 9c9ede0418
commit 0e308eb9ad
17 changed files with 702 additions and 51 deletions
+28 -18
View File
@@ -1,5 +1,6 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
import {
createAuthorizationEvaluator,
createAuthorizationStore,
@@ -84,12 +85,15 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
* 1. Discover every installed module's manifest exactly once and compose the one immutable
* platform permission registry from Core's own declarations plus every module's
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed
* Drizzle view over it — see `./database.js`).
* 3. Construct the Core identity runtime, authorization store, and evaluator.
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 4. Start the module runtime from that exact same discovered inventory
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time.
* 5. Create the Hono application, mounting the started module runtime's published routes.
* (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time — passing it the
* same services object every module's `start`/`stop` hook receives.
* 5. Create the Hono application, mounting the started module runtime's published routes and
* passing the exact same services object to every module HTTP handler.
* 6. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -97,12 +101,13 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
* manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission
* namespace, or a duplicate permission declaration — leaves nothing to close: it propagates
* directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool,
* identity, authorization) exists before any executable module lifecycle code runs, and every
* module route is published only after the entire module runtime has registered and started
* successfully, so no request can ever reach a module before every module has started. Returns a
* handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the
* server, the module runtime, and the shared pool deterministically. Never applies migrations or
* generates schema — the database must already be migrated through `pnpm migrations:apply`.
* module database facade, identity, authorization) exists before any executable module lifecycle
* code runs, and every module route is published only after the entire module runtime has
* registered and started successfully, so no request can ever reach a module before every module
* has started. Returns a handle with an idempotent `shutdown()` so both the executable entry point
* and tests can stop the server, the module runtime, and the shared pool deterministically. Never
* applies migrations, generates schema, or issues a module database query merely because modules
* were discovered — the database must already be migrated through `pnpm migrations:apply`.
*/
export async function startApi(
config: ApiRuntimeConfig,
@@ -122,9 +127,11 @@ export async function startApi(
operationTimeoutMs,
});
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
@@ -146,7 +153,7 @@ export async function startApi(
// handle to stop here, only the pool this failure leaves open.
let moduleRuntime: StartedModuleRuntime;
try {
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules);
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services);
} catch (error) {
await safeCloseDatabase(databaseHandle);
throw error;
@@ -161,6 +168,7 @@ export async function startApi(
store: authorizationStore,
},
moduleRoutes: moduleRuntime.routes,
moduleServices: services,
runtime,
trustedOrigins: config.identity.trustedOrigins,
});
@@ -184,12 +192,14 @@ export async function startApi(
// would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and
// closed first so no new or in-flight request can reach a module handler while the module
// runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the
// startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine
// shutdown result a caller should observe — but database cleanup still always runs first, so a
// stop failure never leaves the pool open.
// observes it complete (or fail) exactly once. Order matters, and is now additionally
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
// no new or in-flight request can reach a module handler while the module runtime is stopping;
// then the module runtime stops — its stop hooks may still use `services.database`, which is
// safe because the pool is not yet closed; only then does the shared pool close, so no module
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
// database cleanup still always runs first, so a stop failure never leaves the pool open.
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {