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:
@@ -1,6 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import {
|
||||
@@ -108,11 +109,50 @@ function createEventRecorder(): { events: string[]; expression: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeOptions(modulesDirectory: string) {
|
||||
/** Same globalThis-symbol technique as `createEventRecorder`, but for capturing an actual object
|
||||
* reference (such as `context.services`) pushed from inside a dynamically imported fixture module,
|
||||
* rather than a string label — dynamic `import()` of a file:// URL still shares this process's
|
||||
* heap, so a reference pushed from the fixture and read back here is the exact same object. */
|
||||
function createReferenceRecorder(): { values: unknown[]; expression: string } {
|
||||
const key = `ekklesia.runtime.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)})]`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleDatabase` — no PostgreSQL required for these unit tests, which only
|
||||
* ever assert on context wiring and object identity, never on actual query behavior. */
|
||||
function fakeModuleDatabase(): ModuleDatabase {
|
||||
return Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
select: vi.fn(),
|
||||
selectDistinct: vi.fn(),
|
||||
selectDistinctOn: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -822,7 +862,7 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered);
|
||||
const runtime = await startDiscoveredModuleRuntime(discovered, fakeModuleRuntimeServices());
|
||||
expect(discoverModules).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["already-discovered"]);
|
||||
await runtime.stop();
|
||||
@@ -840,7 +880,10 @@ describe("already-discovered module runtime", () => {
|
||||
const discovered = await discoverModules(runtimeOptions(modulesDirectory));
|
||||
expect(discovered.map((module) => module.manifest.id)).toEqual(["first", "second"]);
|
||||
|
||||
const runtime = await startDiscoveredModuleRuntime([...discovered].reverse());
|
||||
const runtime = await startDiscoveredModuleRuntime(
|
||||
[...discovered].reverse(),
|
||||
fakeModuleRuntimeServices(),
|
||||
);
|
||||
expect(runtime.modules.map((module) => module.id)).toEqual(["second", "first"]);
|
||||
await runtime.stop();
|
||||
});
|
||||
@@ -857,3 +900,98 @@ describe("already-discovered module runtime", () => {
|
||||
await runtime.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const registerServices = createReferenceRecorder();
|
||||
const lifecycleServices = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "wired", {
|
||||
serverSource: `export default {
|
||||
id: "wired", version: "0.1.0",
|
||||
register(context) { ${registerServices.expression}.push(context.services); },
|
||||
start(context) { ${lifecycleServices.expression}.push(context.services); },
|
||||
stop(context) { ${lifecycleServices.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "alpha", {
|
||||
serverSource: `export default {
|
||||
id: "alpha", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "beta", {
|
||||
manifest: manifestFor("beta", {
|
||||
dependencies: { modules: { required: { alpha: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "beta", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("gives a rollback stop hook the same services object a start failure left available", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
await createRuntimeModule(modulesDirectory, "started", {
|
||||
serverSource: `export default {
|
||||
id: "started", version: "0.1.0",
|
||||
start(context) { ${recorder.expression}.push(context.services); },
|
||||
stop(context) { ${recorder.expression}.push(context.services); }
|
||||
};`,
|
||||
});
|
||||
await createRuntimeModule(modulesDirectory, "failing", {
|
||||
manifest: manifestFor("failing", {
|
||||
dependencies: { modules: { required: { started: "^0.1.0" } } },
|
||||
}),
|
||||
serverSource: `export default {
|
||||
id: "failing", version: "0.1.0",
|
||||
start() { throw new Error("boom"); }
|
||||
};`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user