feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s

This commit is contained in:
ekklesia
2026-10-07 17:29:32 -05:00
parent a1cdcb3ad1
commit 49b4d4165d
60 changed files with 948 additions and 237 deletions
+19 -6
View File
@@ -1,6 +1,8 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
@@ -9,6 +11,7 @@ import {
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */
readonly services: ModuleRuntimeServices;
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* Like `services`, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it.
*/
readonly configurations: ModuleConfigurationLookup;
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
const context = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
services,
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
let response: Response;
try {
response = await route.handler(c.req.raw, context);
response = await (
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}