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
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
+1 -1
View File
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
+10 -4
View File
@@ -1,8 +1,8 @@
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import {
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
function discoveredModule(
id: string,
version = "0.1.0",
database?: { readonly abi: 1 },
): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [],
...(database ? { database } : {}),
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0");
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)],
[people, calendar],
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"],
status: "pass",
@@ -1,5 +1,5 @@
{
"manifestVersion": 7,
"manifestVersion": 8,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -17,7 +17,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+1 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+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");
+63 -2
View File
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(
id: string,
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
options: {
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
schema?: string | false;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -49,6 +53,7 @@ function manifestFor(
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
...(options.database ? { database: options.database } : {}),
entrypoints: {
server: "./dist/server/index.js",
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
}
describe("module schema discovery", () => {
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", { database: { abi: 1 } }),
schemaSource: `
export default {
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
]
};
`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(aggregate.tables.map((table) => table.name)).toEqual([
"calendar_attendees",
"calendar_events",
]);
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
expect(aggregate.drizzleSchema).toEqual({});
});
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", { database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "alpha",
tables: [
{ name: "wrong_records", foreignKeys: [] },
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
]
};`,
});
await createSchemaModule(modulesDirectory, "malformed", {
manifest: manifestFor("malformed", { database: { abi: 1 } }),
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
"MODULE_SCHEMA_TABLE_DUPLICATE",
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
]),
);
});
it("returns a frozen empty aggregate without importing module servers", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+1 -1
View File
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: contribution.version,
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
@@ -50,7 +50,7 @@ export function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",