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:
@@ -111,6 +111,19 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but captures an actual object
|
||||
* reference (such as `context.services.database`) rather than a string label, so a test can prove
|
||||
* two values pushed from different, independently dynamically-imported invocations (a module's own
|
||||
* `start` hook and one of its HTTP handlers) are the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.api.lifecycle.test.ref.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
||||
const symbol = Symbol.for(key);
|
||||
const values: unknown[] = [];
|
||||
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = values;
|
||||
eventSymbols.push(symbol);
|
||||
return { values, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
||||
}
|
||||
|
||||
interface WorkingFixtureModuleOptions {
|
||||
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
|
||||
readonly withRoute?: boolean;
|
||||
@@ -659,6 +672,47 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("gives a module's HTTP handler the exact same database facade object its own start hook received", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-identity", {
|
||||
permissions: [{ baseline: "public", description: "Ping.", id: "db-identity.ping.read" }],
|
||||
});
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-identity",
|
||||
version: "0.1.0",
|
||||
register({ http }) {
|
||||
http.route({
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: "db-identity.ping.read",
|
||||
handler: async (_request, context) => {
|
||||
${refs.expression}.push(context.services.database);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
},
|
||||
start(context) {
|
||||
${refs.expression}.push(context.services.database);
|
||||
}
|
||||
};`,
|
||||
);
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
const response = await running.app.request("/api/v1/modules/db-identity/ping");
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
|
||||
await running.shutdown();
|
||||
});
|
||||
|
||||
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
await createFixtureModule(modulesDirectory, "broken-export");
|
||||
@@ -789,6 +843,31 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
|
||||
});
|
||||
|
||||
it("gives a module's stop hook the exact same database facade object its start hook received, while the pool is still open", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const refs = createReferenceRecorder();
|
||||
await createFixtureModule(modulesDirectory, "db-stop-identity");
|
||||
await writeFile(
|
||||
path.join(modulesDirectory, "db-stop-identity", "dist/server/index.js"),
|
||||
`export default {
|
||||
id: "db-stop-identity",
|
||||
version: "0.1.0",
|
||||
start(context) { ${refs.expression}.push(context.services.database); },
|
||||
stop(context) { ${refs.expression}.push(context.services.database); }
|
||||
};`,
|
||||
);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
|
||||
await running.shutdown();
|
||||
|
||||
expect(refs.values).toHaveLength(2);
|
||||
expect(refs.values[0]).toBeDefined();
|
||||
expect(refs.values[0]).toBe(refs.values[1]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
Reference in New Issue
Block a user