docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s

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:
ekklesia
2026-07-25 21:50:03 -05:00
parent d1b9efd4d2
commit c38b572778
15 changed files with 584 additions and 91 deletions
+69 -18
View File
@@ -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", () => {