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:
@@ -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