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
+96 -23
View File
@@ -2,20 +2,23 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
AnyModuleRuntimeServices,
ModuleConfiguration,
ModuleDatabase,
ModuleDatabaseExecutor,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
import {
ModuleDiscoveryError,
ModuleRuntimeError,
type ModuleRuntimeIssueCode,
type ModuleRuntimeServicesLookup,
startDiscoveredModuleRuntime,
startModuleRuntime,
} from "../src/modules/index.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
@@ -52,12 +55,13 @@ function manifestFor(
id: string,
options: {
capabilities?: Array<{ id: string; version: string }>;
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -67,6 +71,7 @@ function manifestFor(
},
capabilities: options.capabilities ?? [],
permissions: options.permissions ?? [],
...(options.database ? { database: options.database } : {}),
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
server: "./dist/server/index.js",
@@ -145,13 +150,29 @@ function fakeModuleDatabase(): ModuleDatabase {
}) as unknown as ModuleDatabase;
}
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
return Object.freeze({ database: fakeModuleDatabase() });
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
const services = new Map<string, ModuleRuntimeServices>();
const database = fakeModuleDatabase();
return new Proxy(services, {
get(target, property, receiver) {
if (property === "get") {
return (moduleId: string) => {
let value = target.get(moduleId);
if (!value) {
value = Object.freeze({ database });
target.set(moduleId, value);
}
return value;
};
}
return Reflect.get(target, property, receiver);
},
});
}
function runtimeOptions(
modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
) {
return {
moduleSdkVersion: "0.1.0",
@@ -1067,6 +1088,54 @@ describe("already-discovered module runtime", () => {
});
describe("module runtime services", () => {
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
const legacyDatabase = fakeModuleDatabase();
const alphaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const betaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
["legacy", Object.freeze({ database: legacyDatabase })],
["alpha", Object.freeze({ database: alphaExecutor })],
["beta", Object.freeze({ database: betaExecutor })],
]);
await createRuntimeModule(modulesDirectory, "legacy", {
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
for (const id of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: manifestFor(id, { database: { abi: 1 } }),
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
}
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
expect("select" in alphaExecutor).toBe(false);
expect(alphaExecutor).not.toBe(betaExecutor);
await runtime.stop();
});
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mismatch", {
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
});
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();
@@ -1086,11 +1155,11 @@ describe("module runtime services", () => {
expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services);
expect(lifecycleServices.values[1]).toBe(services);
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
});
it("shares the exact same services object identity across every module's start hook", async () => {
it("selects a stable services object per module rather than sharing one across modules", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1112,7 +1181,8 @@ describe("module runtime services", () => {
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
expect(recorder.values[0]).not.toBe(recorder.values[1]);
await runtime.stop();
});
@@ -1141,23 +1211,26 @@ describe("module runtime services", () => {
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
});
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const database = fakeModuleDatabase();
const services: ModuleRuntimeServicesLookup = new Map([
["unused", Object.freeze({ database })],
]);
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();
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
});
});
@@ -1463,7 +1536,7 @@ describe("module configuration delivery (ADR 0029)", () => {
expect(recorder.values).toEqual([["object", []]]);
});
it("keeps the services object one shared identity even though configuration is per-module", async () => {
it("keeps each module's services object stable while configuration remains per-module", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1492,11 +1565,11 @@ describe("module configuration delivery (ADR 0029)", () => {
await runtime.stop();
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
// This is the reason configuration is a sibling of `services` rather than a field inside it:
// `services` is deliberately one shared object for the whole runtime, while configuration must
// differ per module. Both properties hold simultaneously.
expect(alphaServices).toBe(services);
expect(bravoServices).toBe(services);
// Configuration remains a sibling of `services`: both are selected per module, but they are
// independently composed snapshots with separate contracts and lifetimes.
expect(alphaServices).toBe(services.get("alpha"));
expect(bravoServices).toBe(services.get("bravo"));
expect(alphaServices).not.toBe(bravoServices);
expect(alphaConfig).not.toBe(bravoConfig);
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");