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
+22 -8
View File
@@ -10,6 +10,7 @@ import {
type ModuleRouteDefinitionInput,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
moduleRouteShapeKey,
parseModuleRuntimeDefinition,
type RegisteredCapability,
@@ -70,7 +71,12 @@ export interface StartedModuleRuntime {
stop(): Promise<void>;
}
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {}
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
}
interface LoadedModule {
readonly definition: ModuleRuntimeDefinition;
@@ -201,6 +207,7 @@ class StagedCapabilityRegistry {
createRuntimeContext(
module: DiscoveredModule,
phase: "rollback" | "start" | "stop",
services: ModuleRuntimeServices,
): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
@@ -208,7 +215,7 @@ class StagedCapabilityRegistry {
return this.#registered.get(capabilityId);
},
});
return Object.freeze({ capabilities, moduleId: module.manifest.id });
return Object.freeze({ capabilities, moduleId: module.manifest.id, services });
}
publish(): CapabilityRegistry {
@@ -466,11 +473,14 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
): Promise<readonly ModuleRuntimeIssue[]> {
const issues: ModuleRuntimeIssue[] = [];
for (const module of [...modules].reverse()) {
try {
await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase));
await module.definition.stop?.(
registry.createRuntimeContext(module.discovered, phase, services),
);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
issues.push(error.issue);
@@ -492,11 +502,14 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
): Promise<void> {
const started: LoadedModule[] = [];
for (const module of modules) {
try {
await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start"));
await module.definition.start?.(
registry.createRuntimeContext(module.discovered, "start", services),
);
started.push(module);
} catch (error) {
const startIssue =
@@ -508,7 +521,7 @@ async function startModules(
module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`,
);
const rollbackIssues = await stopModules(started, registry, "rollback");
const rollbackIssues = await stopModules(started, registry, "rollback", services);
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
}
}
@@ -524,12 +537,13 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, capabilityRegistry);
await startModules(modules, capabilityRegistry, services);
const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
@@ -549,7 +563,7 @@ export async function startDiscoveredModuleRuntime(
routes,
stop: (): Promise<void> => {
stopPromise ??= (async () => {
const issues = await stopModules(modules, capabilityRegistry, "stop");
const issues = await stopModules(modules, capabilityRegistry, "stop", services);
if (issues.length > 0) {
throw new ModuleRuntimeError(issues);
}
@@ -563,5 +577,5 @@ export async function startModuleRuntime(
options: StartModuleRuntimeOptions,
): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered);
return startDiscoveredModuleRuntime(discovered, options.services);
}