diff --git a/README.md b/README.md index 237064f..9690392 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ surfaces. ## Current status -Phase 2 is underway. The repository now includes public module contracts, the Module SDK, -side-effect-free module manifests, deterministic automatic discovery, compatibility and dependency -validation, and a cloneable backend-only module template. Runtime registration, Drizzle, identity, +Phase 2 is underway. The repository now includes public module contracts, independently versioned +capabilities, static module manifests, deterministic automatic discovery, compiled server loading, +a staged capability registry, dependency-ordered lifecycle with rollback, and a cloneable +backend-only module template. Drizzle, identity, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules remain later vertical slices. ## Quick start diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index 28185bd..3b72b3f 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -3,6 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { isVersionCompatible, + type ModuleCapabilityDeclaration, type ModuleManifest, safeParseModuleManifest, semanticVersionSchema, @@ -12,6 +13,7 @@ export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export type ModuleDiscoveryIssueCode = | "DISCOVERY_CONFIGURATION_INVALID" + | "MODULE_CAPABILITY_INCOMPATIBLE" | "MODULE_CAPABILITY_MISSING" | "MODULE_DEPENDENCY_CYCLE" | "MODULE_DEPENDENCY_INCOMPATIBLE" @@ -83,6 +85,11 @@ interface ModuleCandidate { packageName: string; } +interface CapabilityProvider { + candidate: ModuleCandidate; + declaration: ModuleCapabilityDeclaration; +} + export class ModuleDiscoveryError extends Error { readonly issues: readonly ModuleDiscoveryIssue[]; @@ -90,6 +97,7 @@ export class ModuleDiscoveryError extends Error { const sortedIssues = [...issues].sort(compareIssues); super(`Module discovery failed with ${sortedIssues.length} issue(s).`); this.name = "ModuleDiscoveryError"; + this.stack = `${this.name}: ${this.message}`; this.issues = freezeRecursively(sortedIssues); } } @@ -229,6 +237,19 @@ async function resolveEntrypoints( if (relativeEntrypoint === undefined) { continue; } + if ( + entrypointName === "server" && + ![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase()) + ) { + issues.push({ + code: "MODULE_ENTRYPOINT_INVALID", + message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.", + moduleDirectory: directoryName, + moduleId: manifest.id, + path: "entrypoints.server", + }); + continue; + } const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint); if (!pathIsInside(moduleDirectory, configuredPath)) { issues.push({ @@ -590,7 +611,7 @@ export async function discoverModules( .map((candidate) => [candidate.manifest.id, candidate] as const), ); - const capabilityProviders = new Map(); + const capabilityProviders = new Map(); for (const candidate of modulesById.values()) { if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) { issues.push({ @@ -613,8 +634,8 @@ export async function discoverModules( }); } - for (const capability of candidate.manifest.capabilities) { - capabilityProviders.set(capability, candidate); + for (const declaration of candidate.manifest.capabilities) { + capabilityProviders.set(declaration.id, { candidate, declaration }); } } @@ -657,9 +678,9 @@ export async function discoverModules( } for (const dependencyKind of ["required", "optional"] as const) { - for (const [index, capability] of candidate.manifest.dependencies.capabilities[ - dependencyKind - ].entries()) { + for (const [capability, versionRange] of Object.entries( + candidate.manifest.dependencies.capabilities[dependencyKind], + )) { const provider = capabilityProviders.get(capability); if (!provider) { if (dependencyKind === "required") { @@ -668,12 +689,21 @@ export async function discoverModules( message: `Required capability ${capability} is not installed.`, moduleDirectory: candidate.directoryName, moduleId: candidate.manifest.id, - path: `dependencies.capabilities.${dependencyKind}.${index}`, + path: `dependencies.capabilities.${dependencyKind}.${capability}`, }); } continue; } - addGraphEdge(provider.manifest.id, candidate.manifest.id, dependents, indegrees); + if (!isVersionCompatible(provider.declaration.version, versionRange)) { + issues.push({ + code: "MODULE_CAPABILITY_INCOMPATIBLE", + message: `Capability ${capability} ${provider.declaration.version} does not satisfy ${versionRange}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: `dependencies.capabilities.${dependencyKind}.${capability}`, + }); + } + addGraphEdge(provider.candidate.manifest.id, candidate.manifest.id, dependents, indegrees); } } } diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 540ec03..aa15594 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1 +1,2 @@ export * from "./discovery.js"; +export * from "./runtime.js"; diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts new file mode 100644 index 0000000..111005d --- /dev/null +++ b/core/src/modules/runtime.ts @@ -0,0 +1,441 @@ +import { pathToFileURL } from "node:url"; +import { + type CapabilityId, + type CapabilityRegistrar, + type CapabilityResolver, + capabilityIdSchema, + type ModuleId, + type ModuleRegistrationContext, + type ModuleRuntimeContext, + type ModuleRuntimeDefinition, + parseModuleRuntimeDefinition, + type RegisteredCapability, +} from "@ekklesia/contracts"; +import { + type DiscoveredModule, + discoverModules, + type ModuleDiscoveryOptions, +} from "./discovery.js"; + +export type ModuleRuntimePhase = "load" | "register" | "rollback" | "start" | "stop"; + +export type ModuleRuntimeIssueCode = + | "MODULE_CAPABILITY_ACCESS_UNDECLARED" + | "MODULE_CAPABILITY_DUPLICATE" + | "MODULE_CAPABILITY_ID_INVALID" + | "MODULE_CAPABILITY_UNDECLARED" + | "MODULE_CAPABILITY_UNREGISTERED" + | "MODULE_REGISTER_FAILED" + | "MODULE_ROLLBACK_FAILED" + | "MODULE_RUNTIME_EXPORT_INVALID" + | "MODULE_RUNTIME_ID_MISMATCH" + | "MODULE_RUNTIME_IMPORT_FAILED" + | "MODULE_RUNTIME_VERSION_MISMATCH" + | "MODULE_START_FAILED" + | "MODULE_STOP_FAILED"; + +export interface ModuleRuntimeIssue { + readonly code: ModuleRuntimeIssueCode; + readonly message: string; + readonly moduleId: ModuleId; + readonly phase: ModuleRuntimePhase; +} + +export interface CapabilityRegistry extends CapabilityResolver { + list(): readonly RegisteredCapability[]; +} + +export interface RunningModule { + readonly id: ModuleId; + readonly version: string; +} + +export interface StartedModuleRuntime { + readonly capabilities: CapabilityRegistry; + readonly modules: readonly RunningModule[]; + stop(): Promise; +} + +export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {} + +interface LoadedModule { + readonly definition: ModuleRuntimeDefinition; + readonly discovered: DiscoveredModule; +} + +interface RegistrationStage { + readonly context: ModuleRegistrationContext; + commit(): readonly ModuleRuntimeIssue[]; +} + +class ModuleRuntimeSignal extends Error { + readonly issue: ModuleRuntimeIssue; + + constructor(issue: ModuleRuntimeIssue) { + super(issue.message); + this.name = "ModuleRuntimeSignal"; + this.issue = issue; + } +} + +export class ModuleRuntimeError extends Error { + readonly issues: readonly ModuleRuntimeIssue[]; + + constructor(issues: readonly ModuleRuntimeIssue[]) { + super(`Module runtime failed with ${issues.length} issue(s).`); + this.name = "ModuleRuntimeError"; + this.stack = `${this.name}: ${this.message}`; + this.issues = Object.freeze(issues.map((issue) => Object.freeze({ ...issue }))); + } +} + +function runtimeIssue( + code: ModuleRuntimeIssueCode, + phase: ModuleRuntimePhase, + moduleId: ModuleId, + message: string, +): ModuleRuntimeIssue { + return { code, message, moduleId, phase }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function defaultExport(moduleNamespace: unknown): unknown { + return isRecord(moduleNamespace) ? moduleNamespace.default : undefined; +} + +class StagedCapabilityRegistry { + readonly #registered = new Map(); + + createRegistrationStage(module: DiscoveredModule): RegistrationStage { + const local = new Map(); + const declarations = new Map( + module.manifest.capabilities.map((declaration) => [declaration.id, declaration] as const), + ); + const resolve = (capabilityId: CapabilityId): RegisteredCapability | undefined => { + this.#assertAccess(module, capabilityId, "register"); + return local.get(capabilityId) ?? this.#registered.get(capabilityId); + }; + const capabilities: CapabilityRegistrar = Object.freeze({ + provide: (capabilityId: CapabilityId, implementation: unknown): void => { + this.#assertValidCapabilityId(module, capabilityId, "register"); + const declaration = declarations.get(capabilityId); + if (!declaration) { + throw new ModuleRuntimeSignal( + runtimeIssue( + "MODULE_CAPABILITY_UNDECLARED", + "register", + module.manifest.id, + `Module ${module.manifest.id} attempted to provide undeclared capability ${capabilityId}.`, + ), + ); + } + if (local.has(capabilityId) || this.#registered.has(capabilityId)) { + throw new ModuleRuntimeSignal( + runtimeIssue( + "MODULE_CAPABILITY_DUPLICATE", + "register", + module.manifest.id, + `Capability ${capabilityId} was provided more than once.`, + ), + ); + } + local.set( + capabilityId, + Object.freeze({ + id: capabilityId, + implementation, + moduleId: module.manifest.id, + version: declaration.version, + }), + ); + }, + resolve, + }); + const context: ModuleRegistrationContext = Object.freeze({ + capabilities, + moduleId: module.manifest.id, + }); + + return { + context, + commit: (): readonly ModuleRuntimeIssue[] => { + const missing = [...declarations.keys()] + .filter((capabilityId) => !local.has(capabilityId)) + .sort(); + if (missing.length > 0) { + return missing.map((capabilityId) => + runtimeIssue( + "MODULE_CAPABILITY_UNREGISTERED", + "register", + module.manifest.id, + `Module ${module.manifest.id} did not provide declared capability ${capabilityId}.`, + ), + ); + } + for (const [capabilityId, capability] of local) { + this.#registered.set(capabilityId, capability); + } + return []; + }, + }; + } + + createRuntimeContext( + module: DiscoveredModule, + phase: "rollback" | "start" | "stop", + ): ModuleRuntimeContext { + const capabilities: CapabilityResolver = Object.freeze({ + resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { + this.#assertAccess(module, capabilityId, phase); + return this.#registered.get(capabilityId); + }, + }); + return Object.freeze({ capabilities, moduleId: module.manifest.id }); + } + + publish(): CapabilityRegistry { + const capabilities = Object.freeze( + [...this.#registered.values()].sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ), + ); + const byId = new Map(capabilities.map((capability) => [capability.id, capability] as const)); + return Object.freeze({ + list: (): readonly RegisteredCapability[] => capabilities, + resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => + byId.get(capabilityId), + }); + } + + #assertAccess( + module: DiscoveredModule, + capabilityId: CapabilityId, + phase: "register" | "rollback" | "start" | "stop", + ): void { + this.#assertValidCapabilityId(module, capabilityId, phase); + const ownsCapability = module.manifest.capabilities.some( + (declaration) => declaration.id === capabilityId, + ); + const declaresDependency = + Object.hasOwn(module.manifest.dependencies.capabilities.required, capabilityId) || + Object.hasOwn(module.manifest.dependencies.capabilities.optional, capabilityId); + if (!ownsCapability && !declaresDependency) { + throw new ModuleRuntimeSignal( + runtimeIssue( + "MODULE_CAPABILITY_ACCESS_UNDECLARED", + phase, + module.manifest.id, + `Module ${module.manifest.id} attempted to resolve undeclared capability ${capabilityId}.`, + ), + ); + } + } + + #assertValidCapabilityId( + module: DiscoveredModule, + capabilityId: unknown, + phase: "register" | "rollback" | "start" | "stop", + ): asserts capabilityId is CapabilityId { + if (!capabilityIdSchema.safeParse(capabilityId).success) { + throw new ModuleRuntimeSignal( + runtimeIssue( + "MODULE_CAPABILITY_ID_INVALID", + phase, + module.manifest.id, + `Module ${module.manifest.id} used an invalid capability identifier.`, + ), + ); + } + } +} + +async function loadRuntimeDefinitions( + discoveredModules: readonly DiscoveredModule[], +): Promise { + const issues: ModuleRuntimeIssue[] = []; + const loaded: LoadedModule[] = []; + + for (const discovered of discoveredModules) { + let moduleNamespace: unknown; + try { + moduleNamespace = (await import( + pathToFileURL(discovered.entrypoints.server).href + )) as unknown; + } catch { + issues.push( + runtimeIssue( + "MODULE_RUNTIME_IMPORT_FAILED", + "load", + discovered.manifest.id, + `Module ${discovered.manifest.id} server entrypoint could not be imported.`, + ), + ); + continue; + } + + let definition: ModuleRuntimeDefinition; + try { + definition = parseModuleRuntimeDefinition(defaultExport(moduleNamespace)); + } catch { + issues.push( + runtimeIssue( + "MODULE_RUNTIME_EXPORT_INVALID", + "load", + discovered.manifest.id, + `Module ${discovered.manifest.id} server entrypoint has an invalid default export.`, + ), + ); + continue; + } + + let matchesManifest = true; + if (definition.id !== discovered.manifest.id) { + matchesManifest = false; + issues.push( + runtimeIssue( + "MODULE_RUNTIME_ID_MISMATCH", + "load", + discovered.manifest.id, + `Module runtime ID ${definition.id} does not match manifest ID ${discovered.manifest.id}.`, + ), + ); + } + if (definition.version !== discovered.manifest.version) { + matchesManifest = false; + issues.push( + runtimeIssue( + "MODULE_RUNTIME_VERSION_MISMATCH", + "load", + discovered.manifest.id, + `Module runtime version ${definition.version} does not match manifest version ${discovered.manifest.version}.`, + ), + ); + } + if (matchesManifest) { + loaded.push({ definition, discovered }); + } + } + + if (issues.length > 0) { + throw new ModuleRuntimeError(issues); + } + return Object.freeze(loaded); +} + +async function registerModules( + modules: readonly LoadedModule[], + registry: StagedCapabilityRegistry, +): Promise { + for (const module of modules) { + const stage = registry.createRegistrationStage(module.discovered); + try { + await module.definition.register?.(stage.context); + } catch (error) { + if (error instanceof ModuleRuntimeSignal) { + throw new ModuleRuntimeError([error.issue]); + } + throw new ModuleRuntimeError([ + runtimeIssue( + "MODULE_REGISTER_FAILED", + "register", + module.discovered.manifest.id, + `Module ${module.discovered.manifest.id} registration failed.`, + ), + ]); + } + const issues = stage.commit(); + if (issues.length > 0) { + throw new ModuleRuntimeError(issues); + } + } +} + +async function stopModules( + modules: readonly LoadedModule[], + registry: StagedCapabilityRegistry, + phase: "rollback" | "stop", +): Promise { + const issues: ModuleRuntimeIssue[] = []; + for (const module of [...modules].reverse()) { + try { + await module.definition.stop?.(registry.createRuntimeContext(module.discovered, phase)); + } catch (error) { + if (error instanceof ModuleRuntimeSignal) { + issues.push(error.issue); + } else { + issues.push( + runtimeIssue( + phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED", + phase, + module.discovered.manifest.id, + `Module ${module.discovered.manifest.id} ${phase} failed.`, + ), + ); + } + } + } + return issues; +} + +async function startModules( + modules: readonly LoadedModule[], + registry: StagedCapabilityRegistry, +): Promise { + const started: LoadedModule[] = []; + for (const module of modules) { + try { + await module.definition.start?.(registry.createRuntimeContext(module.discovered, "start")); + started.push(module); + } catch (error) { + const startIssue = + error instanceof ModuleRuntimeSignal + ? error.issue + : runtimeIssue( + "MODULE_START_FAILED", + "start", + module.discovered.manifest.id, + `Module ${module.discovered.manifest.id} startup failed.`, + ); + const rollbackIssues = await stopModules(started, registry, "rollback"); + throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); + } + } +} + +export async function startModuleRuntime( + options: StartModuleRuntimeOptions, +): Promise { + const discovered = await discoverModules(options); + const modules = await loadRuntimeDefinitions(discovered); + const stagedRegistry = new StagedCapabilityRegistry(); + await registerModules(modules, stagedRegistry); + await startModules(modules, stagedRegistry); + + const capabilities = stagedRegistry.publish(); + const runningModules = Object.freeze( + modules.map((module) => + Object.freeze({ + id: module.discovered.manifest.id, + version: module.discovered.manifest.version, + }), + ), + ); + let stopPromise: Promise | undefined; + + return Object.freeze({ + capabilities, + modules: runningModules, + stop: (): Promise => { + stopPromise ??= (async () => { + const issues = await stopModules(modules, stagedRegistry, "stop"); + if (issues.length > 0) { + throw new ModuleRuntimeError(issues); + } + })(); + return stopPromise; + }, + }); +} diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 61b2fdf..4a8ac91 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -1,7 +1,6 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { discoverModules, @@ -32,7 +31,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 1, + manifestVersion: 2, id, name: `${id} module`, version: "0.1.0", @@ -40,10 +39,10 @@ function manifestFor(id: string): Record { platform: "^0.1.0", moduleSdk: "^0.1.0", }, - capabilities: [`${id}.records`], + capabilities: [{ id: `${id}.records`, version: "1.0.0" }], permissions: [`${id}.records.read`], entrypoints: { - server: "./src/server/index.ts", + server: "./dist/server/index.js", }, }; } @@ -63,13 +62,13 @@ async function createModule( ekklesia: { layer: "module" }, }; - await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await Promise.all([ writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)), writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), writeFile( - path.join(moduleDirectory, "src/server/index.ts"), - options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`, + path.join(moduleDirectory, "dist/server/index.js"), + options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`, ), ]); } @@ -125,13 +124,18 @@ describe("module discovery", () => { ...manifestFor("calendar"), dependencies: { modules: { required: { people: "^0.1.0" } }, - capabilities: { required: ["people.records"] }, + capabilities: { required: { "people.records": "^2.0.0" } }, }, storage: { buckets: [{ id: "assets" }, { id: "exports" }] }, }, serverSource: 'throw new Error("discovery must never execute server code");', }); - await createModule(modulesDirectory, "people"); + await createModule(modulesDirectory, "people", { + manifest: { + ...manifestFor("people"), + capabilities: [{ id: "people.records", version: "2.1.0" }], + }, + }); const modules = await discoverModules({ moduleSdkVersion: "0.1.0", @@ -149,19 +153,6 @@ describe("module discovery", () => { expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError); }); - it("accepts the repository's cloneable module template", async () => { - const testDirectory = path.dirname(fileURLToPath(import.meta.url)); - const templatesDirectory = path.resolve(testDirectory, "../../templates"); - - const modules = await discoverModules({ - moduleSdkVersion: "0.1.0", - modulesDirectory: templatesDirectory, - platformVersion: "0.1.0", - }); - - expect(modules.map((module) => module.manifest.id)).toEqual(["example"]); - }); - it("aggregates malformed packages and manifests without partial discovery", async () => { const modulesDirectory = await createModulesDirectory(); await mkdir(path.join(modulesDirectory, "missing")); @@ -223,7 +214,7 @@ describe("module discovery", () => { manifest: { ...manifestFor("one"), id: "shared", - capabilities: ["shared.records"], + capabilities: [{ id: "shared.records", version: "1.0.0" }], permissions: ["shared.records.read"], }, packageJson: { @@ -237,7 +228,7 @@ describe("module discovery", () => { manifest: { ...manifestFor("two"), id: "shared", - capabilities: ["shared.records"], + capabilities: [{ id: "shared.records", version: "1.0.0" }], permissions: ["shared.records.read"], }, packageJson: { @@ -312,18 +303,67 @@ describe("module discovery", () => { expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE"); }); - it("rejects missing required capabilities", async () => { + it("allows absent optional capabilities but rejects missing required capabilities", async () => { const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "optional-consumer", { + manifest: { + ...manifestFor("optional-consumer"), + dependencies: { capabilities: { optional: { "people.records": "^1.0.0" } } }, + }, + }); + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }), + ).resolves.toHaveLength(1); + await createModule(modulesDirectory, "consumer", { manifest: { ...manifestFor("consumer"), - dependencies: { capabilities: { required: ["people.records"] } }, + dependencies: { capabilities: { required: { "people.records": "^1.0.0" } } }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING"); }); + it("rejects incompatible installed required and optional capabilities", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "people"); + await createModule(modulesDirectory, "required-consumer", { + manifest: { + ...manifestFor("required-consumer"), + dependencies: { capabilities: { required: { "people.records": "^2.0.0" } } }, + }, + }); + await createModule(modulesDirectory, "optional-consumer", { + manifest: { + ...manifestFor("optional-consumer"), + dependencies: { capabilities: { optional: { "people.records": "^3.0.0" } } }, + }, + }); + + const error = await discoveryError(modulesDirectory); + const incompatibilities = error.issues.filter( + (issue) => issue.code === "MODULE_CAPABILITY_INCOMPATIBLE", + ); + expect(incompatibilities).toHaveLength(2); + expect(incompatibilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: "Capability people.records 1.0.0 does not satisfy ^2.0.0.", + path: "dependencies.capabilities.required.people.records", + }), + expect.objectContaining({ + message: "Capability people.records 1.0.0 does not satisfy ^3.0.0.", + path: "dependencies.capabilities.optional.people.records", + }), + ]), + ); + }); + it("detects dependency cycles", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "one", { @@ -357,7 +397,7 @@ describe("module discovery", () => { await createModule(modulesDirectory, "missing-server", { manifest: { ...manifestFor("missing-server"), - entrypoints: { server: "./src/server/missing.ts" }, + entrypoints: { server: "./dist/server/missing.js" }, }, }); @@ -368,14 +408,33 @@ describe("module discovery", () => { expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID"); }); + it("rejects source TypeScript server entrypoints", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "source-server", { + manifest: { + ...manifestFor("source-server"), + entrypoints: { server: "./src/server/index.ts" }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(error.issues).toContainEqual( + expect.objectContaining({ + code: "MODULE_ENTRYPOINT_INVALID", + message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.", + path: "entrypoints.server", + }), + ); + }); + it("requires executable entrypoints to be files and migrations to be a directory", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "wrong-entrypoint-kind", { manifest: { ...manifestFor("wrong-entrypoint-kind"), entrypoints: { - server: "./src/server", - migrations: "./src/server/index.ts", + server: "./dist/server", + migrations: "./dist/server/index.js", }, }, }); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts new file mode 100644 index 0000000..71edd82 --- /dev/null +++ b/core/tests/module-runtime.test.ts @@ -0,0 +1,372 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ModuleDiscoveryError, + ModuleRuntimeError, + type ModuleRuntimeIssueCode, + startModuleRuntime, +} from "../src/modules/index.js"; + +interface RuntimeModuleFixture { + readonly directory?: string; + readonly manifest?: Record; + readonly packageJson?: Record; + readonly serverSource: string; +} + +const temporaryDirectories: string[] = []; +const eventSymbols: symbol[] = []; + +afterEach(async () => { + const globalValues = globalThis as unknown as Record; + for (const symbol of eventSymbols.splice(0)) { + delete globalValues[symbol]; + } + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-runtime-")); + temporaryDirectories.push(directory); + return directory; +} + +function manifestFor( + id: string, + options: { + capabilities?: Array<{ id: string; version: string }>; + dependencies?: Record; + } = {}, +): Record { + return { + manifestVersion: 2, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { + platform: "^0.1.0", + moduleSdk: "^0.1.0", + }, + capabilities: options.capabilities ?? [], + permissions: [], + ...(options.dependencies ? { dependencies: options.dependencies } : {}), + entrypoints: { + server: "./dist/server/index.js", + }, + }; +} + +async function createRuntimeModule( + modulesDirectory: string, + id: string, + fixture: RuntimeModuleFixture, +): Promise { + const directoryName = fixture.directory ?? id; + const moduleDirectory = path.join(modulesDirectory, directoryName); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify( + fixture.packageJson ?? { + name: `@test/module-${id}`, + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }, + ), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify(fixture.manifest ?? manifestFor(id)), + ), + writeFile(path.join(moduleDirectory, "dist/server/index.js"), fixture.serverSource), + ]); +} + +function createEventRecorder(): { events: string[]; expression: string } { + const key = `ekklesia.runtime.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`; + const symbol = Symbol.for(key); + const events: string[] = []; + (globalThis as unknown as Record)[symbol] = events; + eventSymbols.push(symbol); + return { + events, + expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`, + }; +} + +function runtimeOptions(modulesDirectory: string) { + return { + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }; +} + +function runtimeIssueCodes(error: ModuleRuntimeError): ModuleRuntimeIssueCode[] { + return error.issues.map((issue) => issue.code); +} + +async function captureRuntimeError(modulesDirectory: string): Promise { + try { + await startModuleRuntime(runtimeOptions(modulesDirectory)); + } catch (error) { + if (error instanceof ModuleRuntimeError) { + return error; + } + throw error; + } + throw new Error("Expected module runtime startup to fail."); +} + +describe("module runtime", () => { + it("discovers, loads, registers, starts, and stops dependency-first modules end to end", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createEventRecorder(); + await createRuntimeModule(modulesDirectory, "people", { + manifest: manifestFor("people", { + capabilities: [{ id: "people.records", version: "2.1.0" }], + }), + serverSource: ` + export default { + id: "people", + version: "0.1.0", + register({ capabilities }) { + ${recorder.expression}.push("people:register"); + capabilities.provide("people.records", { kind: "people-records" }); + }, + start() { ${recorder.expression}.push("people:start"); }, + stop() { ${recorder.expression}.push("people:stop"); } + }; + `, + }); + await createRuntimeModule(modulesDirectory, "calendar", { + manifest: manifestFor("calendar", { + capabilities: [{ id: "calendar.events", version: "1.0.0" }], + dependencies: { + capabilities: { required: { "people.records": "^2.0.0" } }, + }, + }), + serverSource: ` + export default { + id: "calendar", + version: "0.1.0", + register({ capabilities }) { + ${recorder.expression}.push("calendar:register"); + const people = capabilities.resolve("people.records"); + if (!people || people.version !== "2.1.0") throw new Error("people unavailable"); + capabilities.provide("calendar.events", { kind: "calendar-events" }); + }, + start({ capabilities }) { + if (!capabilities.resolve("people.records")) throw new Error("people unavailable"); + ${recorder.expression}.push("calendar:start"); + }, + stop() { ${recorder.expression}.push("calendar:stop"); } + }; + `, + }); + + const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory)); + + expect(recorder.events).toEqual([ + "people:register", + "calendar:register", + "people:start", + "calendar:start", + ]); + expect(runtime.modules.map((module) => module.id)).toEqual(["people", "calendar"]); + expect(runtime.capabilities.resolve("people.records")).toMatchObject({ + moduleId: "people", + version: "2.1.0", + }); + expect(runtime.capabilities.list().map((capability) => capability.id)).toEqual([ + "calendar.events", + "people.records", + ]); + + await Promise.all([runtime.stop(), runtime.stop()]); + expect(recorder.events.slice(-2)).toEqual(["calendar:stop", "people:stop"]); + }); + + it("completes discovery before importing any server entrypoint", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createEventRecorder(); + await createRuntimeModule(modulesDirectory, "valid", { + serverSource: `${recorder.expression}.push("valid:import"); export default { id: "valid", version: "0.1.0" };`, + }); + await createRuntimeModule(modulesDirectory, "invalid", { + manifest: { ...manifestFor("invalid"), manifestVersion: 1 }, + serverSource: `${recorder.expression}.push("invalid:import"); export default { id: "invalid", version: "0.1.0" };`, + }); + + await expect(startModuleRuntime(runtimeOptions(modulesDirectory))).rejects.toBeInstanceOf( + ModuleDiscoveryError, + ); + expect(recorder.events).toEqual([]); + }); + + it("validates every imported definition before running registration hooks", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createEventRecorder(); + await createRuntimeModule(modulesDirectory, "alpha", { + serverSource: `export default { + id: "alpha", version: "0.1.0", + register() { ${recorder.expression}.push("alpha:register"); } + };`, + }); + await createRuntimeModule(modulesDirectory, "beta", { + serverSource: `export default { id: "wrong-id", version: "0.1.0" };`, + }); + await createRuntimeModule(modulesDirectory, "gamma", { + serverSource: `export default { id: "gamma", version: "9.0.0" };`, + }); + + const error = await captureRuntimeError(modulesDirectory); + + expect(runtimeIssueCodes(error)).toEqual([ + "MODULE_RUNTIME_ID_MISMATCH", + "MODULE_RUNTIME_VERSION_MISMATCH", + ]); + expect(recorder.events).toEqual([]); + }); + + it("rejects incomplete and undeclared capability registration before startup", async () => { + const missingDirectory = await createModulesDirectory(); + await createRuntimeModule(missingDirectory, "missing", { + manifest: manifestFor("missing", { + capabilities: [{ id: "missing.records", version: "1.0.0" }], + }), + serverSource: `export default { id: "missing", version: "0.1.0", register() {} };`, + }); + let error = await captureRuntimeError(missingDirectory); + expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNREGISTERED"]); + + const undeclaredDirectory = await createModulesDirectory(); + await createRuntimeModule(undeclaredDirectory, "undeclared", { + serverSource: `export default { + id: "undeclared", version: "0.1.0", + register({ capabilities }) { capabilities.provide("undeclared.extra", {}); } + };`, + }); + error = await captureRuntimeError(undeclaredDirectory); + expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_UNDECLARED"]); + }); + + it("restricts module capability lookup to declared ownership and dependencies", async () => { + const modulesDirectory = await createModulesDirectory(); + await createRuntimeModule(modulesDirectory, "snooper", { + serverSource: `export default { + id: "snooper", version: "0.1.0", + register({ capabilities }) { capabilities.resolve("people.records"); } + };`, + }); + + const error = await captureRuntimeError(modulesDirectory); + expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ACCESS_UNDECLARED"]); + }); + + it("rejects malformed runtime capability IDs without echoing their contents", async () => { + const modulesDirectory = await createModulesDirectory(); + const unsafeCapabilityId = "secret-token at /machine/private/path"; + await createRuntimeModule(modulesDirectory, "unsafe", { + serverSource: `export default { + id: "unsafe", version: "0.1.0", + register({ capabilities }) { + capabilities.resolve(${JSON.stringify(unsafeCapabilityId)}); + } + };`, + }); + + const error = await captureRuntimeError(modulesDirectory); + expect(runtimeIssueCodes(error)).toEqual(["MODULE_CAPABILITY_ID_INVALID"]); + expect(`${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`).not.toContain( + unsafeCapabilityId, + ); + expect(error.stack).not.toContain(modulesDirectory); + }); + + it("rolls back started modules in reverse order and aggregates cleanup failures", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createEventRecorder(); + for (const [id, dependency, stopFails] of [ + ["one", undefined, false], + ["two", "one", true], + ["three", "two", false], + ] as const) { + await createRuntimeModule(modulesDirectory, id, { + manifest: manifestFor(id, { + ...(dependency + ? { dependencies: { modules: { required: { [dependency]: "^0.1.0" } } } } + : {}), + }), + serverSource: `export default { + id: ${JSON.stringify(id)}, version: "0.1.0", + start() { + ${recorder.expression}.push(${JSON.stringify(`${id}:start`)}); + ${id === "three" ? 'throw new Error("secret-token at /private/path");' : ""} + }, + stop() { + ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); + ${stopFails ? 'throw new Error("rollback secret");' : ""} + } + };`, + }); + } + + const error = await captureRuntimeError(modulesDirectory); + + expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]); + expect(recorder.events).toEqual([ + "one:start", + "two:start", + "three:start", + "two:stop", + "one:stop", + ]); + expect(JSON.stringify(error.issues)).not.toContain("secret"); + expect(JSON.stringify(error.issues)).not.toContain("/private/path"); + }); + + it("continues reverse shutdown after a module stop failure", async () => { + const modulesDirectory = await createModulesDirectory(); + const recorder = createEventRecorder(); + await createRuntimeModule(modulesDirectory, "provider", { + serverSource: `export default { + id: "provider", version: "0.1.0", + stop() { ${recorder.expression}.push("provider:stop"); } + };`, + }); + await createRuntimeModule(modulesDirectory, "consumer", { + manifest: manifestFor("consumer", { + dependencies: { modules: { required: { provider: "^0.1.0" } } }, + }), + serverSource: `export default { + id: "consumer", version: "0.1.0", + stop() { ${recorder.expression}.push("consumer:stop"); throw new Error("hidden"); } + };`, + }); + const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory)); + + await expect(runtime.stop()).rejects.toMatchObject({ + issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "consumer" })], + }); + expect(recorder.events).toEqual(["consumer:stop", "provider:stop"]); + }); + + it("reports import failures without leaking exception text or machine paths", async () => { + const modulesDirectory = await createModulesDirectory(); + await createRuntimeModule(modulesDirectory, "broken", { + serverSource: `throw new Error("database-password at /machine/private/path");`, + }); + + const error = await captureRuntimeError(modulesDirectory); + expect(runtimeIssueCodes(error)).toEqual(["MODULE_RUNTIME_IMPORT_FAILED"]); + expect(JSON.stringify(error.issues)).not.toContain("database-password"); + expect(JSON.stringify(error.issues)).not.toContain(modulesDirectory); + }); +}); diff --git a/docs/architecture/decisions/0005-static-module-manifests.md b/docs/architecture/decisions/0005-static-module-manifests.md index fdcb0b0..9fd59ab 100644 --- a/docs/architecture/decisions/0005-static-module-manifests.md +++ b/docs/architecture/decisions/0005-static-module-manifests.md @@ -1,6 +1,7 @@ # 0005: Static module manifests and atomic discovery -- Status: Accepted +- Status: Superseded +- Superseded by: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) - Date: 2026-07-12 ## Context diff --git a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md new file mode 100644 index 0000000..ac88c1f --- /dev/null +++ b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md @@ -0,0 +1,81 @@ +# 0006: Versioned capabilities and compiled module runtime + +- Status: Accepted +- Date: 2026-07-12 +- Supersedes: [0005](0005-static-module-manifests.md) + +## Context + +Static module manifests let Core validate the complete installed module graph without executing +module code. The first manifest shape treated capabilities as unversioned identifiers and the +Module SDK helper validated a second programmatic copy of that manifest. Neither model could +express compatibility between independently evolving capability contracts without duplicating +canonical metadata. + +Core also needs an explicit production loading boundary. Importing source TypeScript would couple +production startup to a development loader. Resolving arbitrary package exports from Core would +require each discovered module to become a declared Core dependency or require a central import +registry, which conflicts with install-by-cloning discovery. + +## Decision + +`ekklesia.module.json` remains the one canonical static manifest and moves to +`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact +semantic version. Required and optional capability dependencies map identifiers to semantic-version +ranges. Capability versions describe their public contracts and evolve independently from the +module package version. A missing optional capability is allowed; an installed but incompatible +optional capability fails discovery. + +`defineModuleRuntime` is the Module SDK helper for a module's executable server definition. The +definition repeats only module ID and module version as build-artifact identity markers, plus the +`register`, `start`, and `stop` hooks. It does not duplicate capabilities, dependencies, +permissions, storage requests, or entrypoints from the static manifest. Core validates every +runtime definition against its manifest before running any hook. + +Production server entrypoints are direct package-relative paths to compiled ESM JavaScript ending +in `.js` or `.mjs`, normally `./dist/server/index.js`. Core imports their canonical file URLs only +after complete atomic discovery succeeds. A module can therefore be installed without a registry +edit by cloning it into `modules/`, installing workspace dependencies, building the workspace, and +running module validation. `pnpm modules:validate` builds the workspace before discovery so missing +or stale build artifacts are caught at that boundary. + +Registration and lifecycle execution use discovery's deterministic dependency-first order. Each +module registers its declared capabilities into a private stage; successful stages become visible +to later dependent registrations but are not published to callers until all registration and +startup hooks succeed. All registration completes before startup. Normal shutdown runs in reverse +order. If startup fails, Core stops modules whose startup completed, in reverse order, and continues +rollback even if cleanup fails. + +The injected contexts contain only the module ID and a capability registrar or resolver. Runtime +errors use stable structured issues and do not expose caught exception messages, stacks, import +URLs, absolute paths, or causes. + +## Alternatives + +- Source TypeScript entrypoints were rejected because production should not depend on an implicit + TypeScript loader or execute unbuilt source. +- Package export subpaths were deferred because pnpm's strict dependency model cannot resolve an + arbitrary discovered package from Core without a declared dependency or generated registry. +- Duplicating the complete static manifest in TypeScript was rejected because the two copies could + drift and make authority ambiguous. +- Publishing capability registrations as each hook runs was rejected because a later registration + failure would expose a partially initialized platform. + +## Consequences + +Module builds must preserve the runtime definition's ID and version and produce the declared server +artifact. Entrypoint modules are trusted backend plugins, not a sandbox boundary. Their top-level +evaluation must be side-effect-free because ESM evaluation cannot be rolled back or unloaded. +Registration hooks must remain declarative; modules acquire resources in `start` and release them +in `stop`. A failing `start` hook is responsible for cleaning up work it began before rejecting; +Core rollback covers modules whose startup already completed. + +Runtime restart, hot replacement, typed capability tokens, schema aggregation, authentication, +HTTP routing, jobs, and physical storage provisioning remain outside this slice. + +## Migration implications + +Manifest version 1 is rejected rather than interpreted as version 2. Existing development modules +must convert capability arrays into versioned declarations, capability dependency arrays into +range maps, build their server entrypoint to ESM JavaScript, and export a `defineModuleRuntime` +definition from that entrypoint. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 9b6b536..5a2160b 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -13,4 +13,5 @@ ADRs capture durable choices that affect more than one package or phase. - [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted - [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted - [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted -- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Accepted +- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded +- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 61f7c8c..f55cfe3 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -59,11 +59,11 @@ surfaces. ## Current implementation status Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts, -the Module SDK manifest API, static versioned module manifests, automatic filesystem discovery, -compatibility/dependency ordering, structured discovery failures, and a backend-only module -template. Core can validate zero or more modules without importing their server behavior. +static versioned manifests, independent capability versions and dependency ranges, automatic +filesystem discovery, compiled server loading, runtime-definition verification, staged capability +registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module +template. -Target architecture, not yet implemented: lifecycle execution and runtime registration, Drizzle -schema aggregation/migrations, Better Auth, permissions, physical storage-bucket provisioning, -Surface SDK, capability/resource/action registries, API server, dashboard, Builder, and reference -feature modules. +Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth, +permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource +and action contracts, API server, dashboard, Builder, and reference feature modules. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index f89570b..77e72b4 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -29,8 +29,9 @@ The lint, typecheck, and test commands run both repository-root checks and every package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts; the root verification command discovers them automatically. -`pnpm modules:validate` scans `modules/*` without a central registry. A missing or empty modules -directory is valid and reports zero installed modules. +`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A +missing or empty modules directory is valid and reports zero installed modules. Installed modules +must produce the compiled JavaScript entrypoints declared by their static manifests. CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate CI job runs the live infrastructure smoke test so code-only local verification does not require diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 7307f6a..5ed5819 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -5,35 +5,37 @@ it never owns components, styles, layouts, renderers, navigation, or other UI. ## Required package shape -Each immediate child of `modules/` must contain: +Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a +strict semantic version, and the module layer marker: ```text modules/example/ +├── dist/server/index.js ├── ekklesia.module.json ├── package.json └── src/server/index.ts ``` -`package.json` must use ESM, a strict semantic version, a unique package name, and: - ```json { + "type": "module", "ekklesia": { "layer": "module" } } ``` -Its version must match the module manifest version. Internal package dependencies use `workspace:` -ranges and supported package entrypoints. +The package version must match the manifest and runtime-definition versions. Internal package +dependencies use `workspace:` ranges and supported public package entrypoints. -## Manifest +## Canonical manifest -The canonical manifest is inert JSON so discovery can validate a module without executing it: +`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module +without executing module code: ```json { - "manifestVersion": 1, + "manifestVersion": 2, "id": "calendar", "name": "Calendar", "version": "0.1.0", @@ -48,10 +50,20 @@ The canonical manifest is inert JSON so discovery can validate a module without } }, "capabilities": { - "optional": ["media.items"] + "required": { + "people.records": "^2.0.0" + }, + "optional": { + "media.items": ">=1.1.0 <2.0.0" + } } }, - "capabilities": ["calendar.events"], + "capabilities": [ + { + "id": "calendar.events", + "version": "1.0.0" + } + ], "permissions": ["calendar.events.read"], "storage": { "buckets": [ @@ -62,38 +74,83 @@ The canonical manifest is inert JSON so discovery can validate a module without ] }, "entrypoints": { - "server": "./src/server/index.ts" + "server": "./dist/server/index.js" } } ``` -Module, capability, and permission identifiers are lowercase and namespaced. Entrypoints must begin -with `./`, stay inside the package, and exist. Unknown manifest properties are rejected, including -presentation fields such as components or renderers. +Module, capability, and permission identifiers are lowercase and namespaced. A provided capability +has its own exact semantic version because its public contract can evolve independently from the +module release. Capability dependencies use semantic-version ranges. Optional means the provider +may be absent; if it is installed, its capability version must be compatible. + +Server entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, remain inside the +package, and exist. Production never imports source TypeScript directly. Unknown manifest +properties are rejected, including presentation fields such as components or renderers. Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. Core will later map them to deployment-specific physical S3 buckets and provide scoped access; -module code must not configure SeaweedFS or other S3 clients directly. +module code must not configure SeaweedFS or another S3 client directly. -## Discovery behavior +## Runtime server definition -Run: +The source entrypoint exports one typed runtime definition and does not repeat the static manifest: -```bash -pnpm modules:validate +```ts +import { defineModuleRuntime } from "@ekklesia/module-sdk"; + +export default defineModuleRuntime({ + id: "calendar", + version: "0.1.0", + + register({ capabilities }) { + capabilities.provide("calendar.events", calendarEvents); + }, + + async start({ capabilities }) { + const people = capabilities.resolve("people.records"); + // Acquire runtime resources here. + }, + + async stop() { + // Release runtime resources here. + } +}); ``` -Discovery checks every installed module atomically. It rejects malformed manifests, duplicate IDs -or package names, version mismatches, incompatible platform or SDK ranges, missing required modules -or capabilities, incompatible installed dependencies, cycles, and missing or escaping entrypoints. -Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable -identifier sorting. +ID and version are intentional build-artifact identity markers and must match the manifest. Core +rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its +own capabilities and the capability dependencies declared in its manifest. The runtime context is +deliberately limited to the module ID and capability access. -Discovery does not load server code. Lifecycle execution, route/job registration, Drizzle schema -aggregation and migrations, and physical bucket provisioning are deliberately deferred. +Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM +evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and +close them in `stop`. + +## Discovery and lifecycle + +From the repository root, run: + +```bash +pnpm install +pnpm modules:validate +pnpm verify +``` + +`pnpm modules:validate` builds the workspace and then atomically validates every installed module. +It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible +platform or SDK ranges, missing required modules or capabilities, incompatible installed +dependencies, cycles, and missing, escaping, or non-JavaScript server entrypoints. Missing optional +dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting. + +Core completes discovery before importing any server entrypoint. It then loads and verifies every +runtime definition before running hooks, registers all modules dependency-first into an unpublished +staged capability registry, and starts them in the same order. Shutdown is reverse-order. If a later +module fails to start, Core stops already-started modules in reverse order and reports any cleanup +failures without leaking caught exceptions or machine paths. A failing `start` hook must clean up +its own partial work before rejecting. ## Start from the template -Copy `templates/module` to `modules/` and follow its README. The template is part of -the workspace and is validated by the same contracts and discovery implementation as installed -modules. +Copy `templates/module` to `modules/` and follow its README. No central registry edit +is needed: the workspace glob, build, discovery, and runtime loading all find the new package. diff --git a/package.json b/package.json index a3d3bbb..62c3776 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait", "lint": "turbo run lint lint:root", "lint:root": "biome lint .", - "modules:validate": "turbo run build --filter=@ekklesia/core && node core/dist/cli/validate-modules.js", + "modules:validate": "turbo run build && node core/dist/cli/validate-modules.js", "test": "turbo run test test:root", "test:root": "vitest run --config vitest.config.ts", "test:watch": "vitest", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 81dda42..8815b08 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./identifiers.js"; export * from "./module-lifecycle.js"; export * from "./module-manifest.js"; +export * from "./module-runtime.js"; export * from "./versions.js"; diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 9c439bf..322939b 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -9,6 +9,7 @@ import { import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema); +const capabilityVersionMapSchema = z.record(capabilityIdSchema, semanticVersionRangeSchema); const moduleDependenciesSchema = z .object({ @@ -20,11 +21,11 @@ const moduleDependenciesSchema = z const capabilityDependenciesSchema = z .object({ - required: z.array(capabilityIdSchema).default(() => []), - optional: z.array(capabilityIdSchema).default(() => []), + required: capabilityVersionMapSchema.default(() => ({})), + optional: capabilityVersionMapSchema.default(() => ({})), }) .strict() - .default(() => ({ required: [], optional: [] })); + .default(() => ({ required: {}, optional: {} })); const dependenciesSchema = z .object({ @@ -34,9 +35,16 @@ const dependenciesSchema = z .strict() .default(() => ({ modules: { required: {}, optional: {} }, - capabilities: { required: [], optional: [] }, + capabilities: { required: {}, optional: {} }, })); +export const moduleCapabilityDeclarationSchema = z + .object({ + id: capabilityIdSchema, + version: semanticVersionSchema, + }) + .strict(); + export const moduleBucketDeclarationSchema = z .object({ id: logicalBucketIdSchema, @@ -64,6 +72,7 @@ function reportDuplicates( values: readonly string[], path: readonly (string | number)[], context: z.RefinementCtx, + property?: string, ): void { const seen = new Set(); values.forEach((value, index) => { @@ -71,7 +80,7 @@ function reportDuplicates( context.addIssue({ code: "custom", message: `${value} is declared more than once.`, - path: [...path, index], + path: [...path, index, ...(property ? [property] : [])], }); } seen.add(value); @@ -80,7 +89,7 @@ function reportDuplicates( export const moduleManifestSchema = z .object({ - manifestVersion: z.literal(1), + manifestVersion: z.literal(2), id: moduleIdSchema, name: z.string().trim().min(1).max(120), description: z.string().trim().min(1).max(500).optional(), @@ -92,37 +101,33 @@ export const moduleManifestSchema = z }) .strict(), dependencies: dependenciesSchema, - capabilities: z.array(capabilityIdSchema).default(() => []), + capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), permissions: z.array(permissionIdSchema).default(() => []), storage: moduleStorageSchema, entrypoints: entrypointsSchema, }) .strict() .superRefine((manifest, context) => { - reportDuplicates(manifest.capabilities, ["capabilities"], context); + reportDuplicates( + manifest.capabilities.map((capability) => capability.id), + ["capabilities"], + context, + "id", + ); reportDuplicates(manifest.permissions, ["permissions"], context); - reportDuplicates( - manifest.dependencies.capabilities.required, - ["dependencies", "capabilities", "required"], - context, - ); - reportDuplicates( - manifest.dependencies.capabilities.optional, - ["dependencies", "capabilities", "optional"], - context, - ); reportDuplicates( manifest.storage.buckets.map((bucket) => bucket.id), ["storage", "buckets"], context, + "id", ); manifest.capabilities.forEach((capability, index) => { - if (!capability.startsWith(`${manifest.id}.`)) { + if (!capability.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", message: `Capability identifiers owned by this module must begin with ${manifest.id}.`, - path: ["capabilities", index], + path: ["capabilities", index, "id"], }); } }); @@ -157,33 +162,27 @@ export const moduleManifestSchema = z } } - const ownedCapabilities = new Set(manifest.capabilities); - const requiredCapabilities = new Set(manifest.dependencies.capabilities.required); - manifest.dependencies.capabilities.required.forEach((capability, index) => { - if (ownedCapabilities.has(capability)) { - context.addIssue({ - code: "custom", - message: "A module does not need to depend on a capability it owns.", - path: ["dependencies", "capabilities", "required", index], - }); + const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id)); + for (const dependencyKind of ["required", "optional"] as const) { + for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) { + if (ownedCapabilities.has(capability)) { + context.addIssue({ + code: "custom", + message: "A module does not need to depend on a capability it owns.", + path: ["dependencies", "capabilities", dependencyKind, capability], + }); + } } - }); - manifest.dependencies.capabilities.optional.forEach((capability, index) => { - if (ownedCapabilities.has(capability)) { - context.addIssue({ - code: "custom", - message: "A module does not need to depend on a capability it owns.", - path: ["dependencies", "capabilities", "optional", index], - }); - } - if (requiredCapabilities.has(capability)) { + } + for (const capability of Object.keys(manifest.dependencies.capabilities.required)) { + if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) { context.addIssue({ code: "custom", message: `${capability} cannot be both a required and optional capability dependency.`, - path: ["dependencies", "capabilities", "optional", index], + path: ["dependencies", "capabilities", "optional", capability], }); } - }); + } }); export function parseModuleManifest(input: unknown): ModuleManifest { @@ -197,3 +196,4 @@ export function safeParseModuleManifest(input: unknown) { export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; +export type ModuleCapabilityDeclaration = z.output; diff --git a/packages/contracts/src/module-runtime.ts b/packages/contracts/src/module-runtime.ts new file mode 100644 index 0000000..7bb16e7 --- /dev/null +++ b/packages/contracts/src/module-runtime.ts @@ -0,0 +1,73 @@ +import type { CapabilityId, ModuleId } from "./identifiers.js"; +import { moduleIdSchema } from "./identifiers.js"; +import { semanticVersionSchema } from "./versions.js"; + +export type Awaitable = Value | PromiseLike; + +export interface RegisteredCapability { + readonly id: CapabilityId; + readonly implementation: unknown; + readonly moduleId: ModuleId; + readonly version: string; +} + +export interface CapabilityResolver { + resolve(capabilityId: CapabilityId): RegisteredCapability | undefined; +} + +export interface CapabilityRegistrar extends CapabilityResolver { + provide(capabilityId: CapabilityId, implementation: unknown): void; +} + +export interface ModuleRegistrationContext { + readonly capabilities: CapabilityRegistrar; + readonly moduleId: ModuleId; +} + +export interface ModuleRuntimeContext { + readonly capabilities: CapabilityResolver; + readonly moduleId: ModuleId; +} + +export interface ModuleRuntimeDefinition { + readonly id: ModuleId; + readonly register?: (context: ModuleRegistrationContext) => Awaitable; + readonly start?: (context: ModuleRuntimeContext) => Awaitable; + readonly stop?: (context: ModuleRuntimeContext) => Awaitable; + readonly version: string; +} + +const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntimeDefinition { + if ( + !isRecord(value) || + !moduleIdSchema.safeParse(value.id).success || + !semanticVersionSchema.safeParse(value.version).success + ) { + return false; + } + if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) { + return false; + } + return [value.register, value.start, value.stop].every( + (hook) => hook === undefined || typeof hook === "function", + ); +} + +export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefinition { + if (!isModuleRuntimeDefinition(value)) { + throw new TypeError("Invalid Ekklesia module runtime definition."); + } + return Object.freeze({ + id: value.id, + version: value.version, + ...(value.register ? { register: value.register } : {}), + ...(value.start ? { start: value.start } : {}), + ...(value.stop ? { stop: value.stop } : {}), + }); +} diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index 97362e2..5997339 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -9,7 +9,7 @@ import { function validManifest(): Record { return { - manifestVersion: 1, + manifestVersion: 2, id: "calendar", name: "Calendar", version: "0.1.0", @@ -17,13 +17,13 @@ function validManifest(): Record { platform: "^0.1.0", moduleSdk: "^0.1.0", }, - capabilities: ["calendar.events"], + capabilities: [{ id: "calendar.events", version: "2.3.0" }], permissions: ["calendar.events.read"], storage: { buckets: [{ id: "assets", description: "Calendar-owned event assets." }], }, entrypoints: { - server: "./src/server/index.ts", + server: "./dist/server/index.js", }, }; } @@ -34,7 +34,7 @@ describe("module manifest contract", () => { expect(manifest.id).toBe("calendar"); expect(manifest.dependencies).toEqual({ - capabilities: { optional: [], required: [] }, + capabilities: { optional: {}, required: {} }, modules: { optional: {}, required: {} }, }); expect(manifest.storage.buckets).toEqual([ @@ -49,11 +49,11 @@ describe("module manifest contract", () => { const second = parseModuleManifest(input); first.dependencies.modules.required.people = "^0.1.0"; - first.dependencies.capabilities.required.push("people.records"); + first.dependencies.capabilities.required["people.records"] = "^1.0.0"; first.storage.buckets.push({ id: "exports" }); expect(second.dependencies.modules.required).toEqual({}); - expect(second.dependencies.capabilities.required).toEqual([]); + expect(second.dependencies.capabilities.required).toEqual({}); expect(second.storage.buckets).toEqual([]); }); @@ -67,7 +67,7 @@ describe("module manifest contract", () => { it("requires module-owned capability and permission namespaces", () => { const manifest = { ...validManifest(), - capabilities: ["media.items"], + capabilities: [{ id: "media.items", version: "1.0.0" }], permissions: ["media.items.read"], }; const result = safeParseModuleManifest(manifest); @@ -75,7 +75,7 @@ describe("module manifest contract", () => { expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual( - expect.arrayContaining(["capabilities.0", "permissions.0"]), + expect.arrayContaining(["capabilities.0.id", "permissions.0"]), ); } }); @@ -89,11 +89,14 @@ describe("module manifest contract", () => { optional: { people: "^1.0.0" }, }, capabilities: { - required: ["people.records"], - optional: ["people.records"], + required: { "people.records": "^1.0.0" }, + optional: { "people.records": "^1.0.0" }, }, }, - capabilities: ["calendar.events", "calendar.events"], + capabilities: [ + { id: "calendar.events", version: "2.3.0" }, + { id: "calendar.events", version: "2.4.0" }, + ], storage: { buckets: [{ id: "assets" }, { id: "assets" }] }, }; const result = safeParseModuleManifest(manifest); @@ -109,13 +112,45 @@ describe("module manifest contract", () => { ...validManifest(), dependencies: { modules: { required: { calendar: "^0.1.0" } }, - capabilities: { required: ["calendar.events"] }, + capabilities: { required: { "calendar.events": "^2.0.0" } }, }, }; expect(safeParseModuleManifest(manifest).success).toBe(false); }); + it("versions capabilities independently from the module release", () => { + const manifest = parseModuleManifest(validManifest()); + + expect(manifest.version).toBe("0.1.0"); + expect(manifest.capabilities).toEqual([{ id: "calendar.events", version: "2.3.0" }]); + }); + + it("requires exact capability versions and dependency ranges", () => { + const manifest = { + ...validManifest(), + capabilities: [{ id: "calendar.events", version: "^2.0.0" }], + dependencies: { + capabilities: { required: { "people.records": "not-a-range" } }, + }, + }; + const result = safeParseModuleManifest(manifest); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual( + expect.arrayContaining([ + "capabilities.0.version", + "dependencies.capabilities.required.people.records", + ]), + ); + } + }); + + it("rejects obsolete manifest versions instead of guessing a migration", () => { + expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false); + }); + it.each([ "../outside.ts", "/absolute.ts", diff --git a/packages/module-sdk/src/define-module-runtime.ts b/packages/module-sdk/src/define-module-runtime.ts new file mode 100644 index 0000000..1c8961b --- /dev/null +++ b/packages/module-sdk/src/define-module-runtime.ts @@ -0,0 +1,7 @@ +import { type ModuleRuntimeDefinition, parseModuleRuntimeDefinition } from "@ekklesia/contracts"; + +export function defineModuleRuntime( + definition: Definition, +): Readonly { + return parseModuleRuntimeDefinition(definition) as Readonly; +} diff --git a/packages/module-sdk/src/define-module.ts b/packages/module-sdk/src/define-module.ts deleted file mode 100644 index 254424d..0000000 --- a/packages/module-sdk/src/define-module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { - type ModuleManifest, - type ModuleManifestInput, - parseModuleManifest, -} from "@ekklesia/contracts"; - -export function defineModule(manifest: ModuleManifestInput): ModuleManifest { - return parseModuleManifest(manifest); -} diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts index 8091634..d1915cd 100644 --- a/packages/module-sdk/src/index.ts +++ b/packages/module-sdk/src/index.ts @@ -1,2 +1,2 @@ export * from "@ekklesia/contracts"; -export * from "./define-module.js"; +export * from "./define-module-runtime.js"; diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts new file mode 100644 index 0000000..e53213f --- /dev/null +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { defineModuleRuntime } from "../src/index.js"; + +describe("defineModuleRuntime", () => { + it("defines a frozen server runtime without duplicating the static manifest", async () => { + const start = vi.fn(); + const runtime = defineModuleRuntime({ + id: "example", + start, + version: "0.1.0", + }); + + expect(runtime).toEqual({ id: "example", start, version: "0.1.0" }); + expect(Object.isFrozen(runtime)).toBe(true); + }); + + it("rejects invalid runtime definitions", () => { + expect(() => + defineModuleRuntime({ + id: "Not Valid", + version: "0.1.0", + }), + ).toThrow("Invalid Ekklesia module runtime definition."); + }); +}); diff --git a/packages/module-sdk/tests/define-module.test.ts b/packages/module-sdk/tests/define-module.test.ts deleted file mode 100644 index f511183..0000000 --- a/packages/module-sdk/tests/define-module.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { defineModule } from "../src/index.js"; - -describe("defineModule", () => { - it("returns a validated and normalized public manifest", () => { - const module = defineModule({ - manifestVersion: 1, - id: "example", - name: "Example", - version: "0.1.0", - compatibility: { - platform: "^0.1.0", - moduleSdk: "^0.1.0", - }, - entrypoints: { - server: "./src/server/index.ts", - }, - }); - - expect(module.capabilities).toEqual([]); - expect(module.permissions).toEqual([]); - expect(module.storage.buckets).toEqual([]); - }); - - it("rejects invalid module definitions before runtime registration", () => { - expect(() => - defineModule({ - manifestVersion: 1, - id: "Not Valid", - name: "Invalid", - version: "0.1.0", - compatibility: { - platform: "^0.1.0", - moduleSdk: "^0.1.0", - }, - entrypoints: { - server: "./src/server/index.ts", - }, - }), - ).toThrow(); - }); -}); diff --git a/templates/module/README.md b/templates/module/README.md index 9943313..c587821 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -1,8 +1,8 @@ # Ekklesia module template -This is the smallest supported backend-only Ekklesia module. It proves the manifest, package, -Module SDK, discovery, capability, permission, and logical storage-bucket contracts without -pretending that the later runtime, API, or Drizzle registration layers already exist. +This is the smallest supported backend-only Ekklesia module. It proves the static manifest, +compiled runtime entrypoint, capability registration, and logical storage-bucket declarations +without pretending that API, Drizzle, or storage provisioning already exist. To create a module inside this repository: @@ -10,7 +10,14 @@ To create a module inside this repository: 2. Rename the package in `package.json` and remove `private` when it is ready for publication. 3. Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in `ekklesia.module.json`. -4. Replace the example server entrypoint with backend behavior only. -5. Run `pnpm install`, `pnpm modules:validate`, and `pnpm verify` from the repository root. +4. Keep the manifest capability versions and dependency ranges aligned with the contracts the + module actually implements and consumes. +5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version + must match the manifest; do not duplicate the rest of the manifest in TypeScript. +6. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository + root. The declared production entrypoint is the compiled `dist/server/index.js`, not source + TypeScript. Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces. +Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, +and release them in `stop`. diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index 5a7705d..de30f9e 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 1, + "manifestVersion": 2, "id": "example", "name": "Example Module", "description": "A minimal backend-only Ekklesia module template.", @@ -8,7 +8,12 @@ "platform": "^0.1.0", "moduleSdk": "^0.1.0" }, - "capabilities": ["example.records"], + "capabilities": [ + { + "id": "example.records", + "version": "1.0.0" + } + ], "permissions": ["example.records.read"], "storage": { "buckets": [ @@ -19,6 +24,6 @@ ] }, "entrypoints": { - "server": "./src/server/index.ts" + "server": "./dist/server/index.js" } } diff --git a/templates/module/package.json b/templates/module/package.json index c02d0aa..c0d1cf6 100644 --- a/templates/module/package.json +++ b/templates/module/package.json @@ -5,7 +5,6 @@ "type": "module", "files": [ "dist", - "src", "ekklesia.module.json" ], "exports": { diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index cd5fb83..3fe1ecc 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -1,7 +1,13 @@ -export interface ExampleModuleServer { - readonly id: "example"; -} +import { defineModuleRuntime } from "@ekklesia/module-sdk"; -export const exampleModuleServer: ExampleModuleServer = { +export const recordsCapability = Object.freeze({ + kind: "example-records", +}); + +export default defineModuleRuntime({ id: "example", -}; + version: "0.1.0", + register({ capabilities }) { + capabilities.provide("example.records", recordsCapability); + }, +}); diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 525d50f..b4a6f28 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -1,14 +1,38 @@ import { readFile } from "node:fs/promises"; -import { defineModule, type ModuleManifestInput } from "@ekklesia/module-sdk"; +import { + type CapabilityId, + parseModuleManifest, + type RegisteredCapability, +} from "@ekklesia/module-sdk"; import { describe, expect, it } from "vitest"; +import moduleRuntime, { recordsCapability } from "../src/server/index.js"; describe("module template", () => { it("ships a valid backend-only manifest", async () => { const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); - const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as ModuleManifestInput; - const manifest = defineModule(manifestInput); + const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown; + const manifest = parseModuleManifest(manifestInput); expect(manifest.id).toBe("example"); + expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]); expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]); }); + + it("exports the typed runtime definition used by the compiled server entrypoint", async () => { + const provided = new Map(); + await moduleRuntime.register?.({ + capabilities: { + provide(capabilityId, implementation) { + provided.set(capabilityId, implementation); + }, + resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined { + return undefined; + }, + }, + moduleId: "example", + }); + + expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" }); + expect(provided.get("example.records")).toBe(recordsCapability); + }); }); diff --git a/tests/infrastructure.test.ts b/tests/infrastructure.test.ts index 8403365..91da13c 100644 --- a/tests/infrastructure.test.ts +++ b/tests/infrastructure.test.ts @@ -104,6 +104,9 @@ describe("development infrastructure", () => { expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root"); expect(packageDocument.scripts?.test).toBe("turbo run test test:root"); expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck:root"); + expect(packageDocument.scripts?.["modules:validate"]).toBe( + "turbo run build && node core/dist/cli/validate-modules.js", + ); expect(packageDocument.scripts?.verify).toContain("pnpm modules:validate"); expect(turboDocument.tasks).toHaveProperty("//#lint:root"); expect(turboDocument.tasks).toHaveProperty("//#test:root");