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
+67 -23
View File
@@ -1,5 +1,6 @@
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
type CapabilityId,
type CapabilityRegistrar,
type CapabilityResolver,
@@ -7,12 +8,15 @@ import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar,
type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
@@ -68,6 +72,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -105,9 +110,8 @@ export interface StartedModuleRuntime {
/**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
*
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
* shared object identity every module receives** — a property this runtime has a test for — while
* configuration is per-module by definition. A module with no entry receives
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
* frozen per module independently of its live service object. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one.
*/
@@ -124,12 +128,33 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030).
*
* A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
* Sharing it would hand every
* module every other module's storage.
*/
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
}
@@ -164,10 +189,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
readonly services: ModuleRuntimeServicesSource;
}
interface LoadedModule {
@@ -175,6 +199,13 @@ interface LoadedModule {
readonly discovered: DiscoveredModule;
}
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[];
@@ -469,7 +500,7 @@ class StagedModuleHttpRoutes {
local.push(
Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler,
handler: parsed.data.handler as ModuleRouteHandler,
maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method,
moduleId: module.manifest.id,
@@ -555,6 +586,19 @@ async function loadRuntimeDefinitions(
),
);
}
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) {
loaded.push({ definition, discovered });
}
@@ -580,15 +624,15 @@ async function registerModules(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({
const context = Object.freeze({
capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http,
moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
});
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
try {
await module.definition.register?.(context);
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
@@ -685,7 +729,7 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -698,7 +742,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop;
const stop = module.definition.stop as AnyRuntimeHook | undefined;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
continue;
@@ -710,13 +754,13 @@ async function stopModules(
registry.createRuntimeContext(
module.discovered,
phase,
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
),
),
).then(() => undefined),
timeoutMs,
);
if (outcome === "timed-out") {
@@ -750,7 +794,7 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -766,15 +810,15 @@ async function startModules(
for (const module of modules) {
rollbackCandidates.push(module);
try {
await module.definition.start?.(
await (module.definition.start as AnyRuntimeHook | undefined)?.(
registry.createRuntimeContext(
module.discovered,
"start",
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
);
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
@@ -831,7 +875,7 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
+129 -11
View File
@@ -2,9 +2,11 @@ import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import { is } from "drizzle-orm";
@@ -40,7 +42,9 @@ export interface DiscoveredModuleTable {
readonly exportName: string;
readonly moduleId: ModuleId;
readonly name: string;
readonly table: AnyPgTable;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
}
export interface DiscoveredModuleSchema {
@@ -55,21 +59,40 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[];
}
interface LoadedModuleSchema {
interface LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "legacy";
}
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface ModuleTableCandidate extends DiscoveredModuleTable {
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
readonly table: AnyPgTable;
}
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error {
@@ -156,9 +179,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
let definition: ModuleSchemaDefinition;
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch {
issues.push(
issue(
@@ -182,7 +209,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
loaded.push({ definition, discovered });
loaded.push(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
}
return { issues, loaded };
@@ -197,6 +228,58 @@ function inspectTables(
for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -293,6 +376,7 @@ function inspectTables(
const candidate: ModuleTableCandidate = {
exportName,
foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId,
name: config.name,
path,
@@ -321,8 +405,8 @@ function inspectTables(
}
function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate,
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate,
): AnyPgTable | undefined {
try {
const reference = foreignKey.reference();
@@ -356,7 +440,9 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) {
ownerByTable.set(candidate.table, candidate.moduleId);
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId);
}
}
}
@@ -364,6 +450,32 @@ function inspectForeignKeys(
const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -446,7 +558,9 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id,
tables: Object.freeze(
inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)),
),
}),
@@ -458,7 +572,11 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)),
);
const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
Object.fromEntries(
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
);
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
+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",