feat: add module runtime lifecycle
This commit is contained in:
@@ -6,9 +6,10 @@ surfaces.
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 2 is underway. The repository now includes public module contracts, the Module SDK,
|
Phase 2 is underway. The repository now includes public module contracts, independently versioned
|
||||||
side-effect-free module manifests, deterministic automatic discovery, compatibility and dependency
|
capabilities, static module manifests, deterministic automatic discovery, compiled server loading,
|
||||||
validation, and a cloneable backend-only module template. Runtime registration, Drizzle, identity,
|
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.
|
surfaces, and feature modules remain later vertical slices.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
type ModuleCapabilityDeclaration,
|
||||||
type ModuleManifest,
|
type ModuleManifest,
|
||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
@@ -12,6 +13,7 @@ export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
|||||||
|
|
||||||
export type ModuleDiscoveryIssueCode =
|
export type ModuleDiscoveryIssueCode =
|
||||||
| "DISCOVERY_CONFIGURATION_INVALID"
|
| "DISCOVERY_CONFIGURATION_INVALID"
|
||||||
|
| "MODULE_CAPABILITY_INCOMPATIBLE"
|
||||||
| "MODULE_CAPABILITY_MISSING"
|
| "MODULE_CAPABILITY_MISSING"
|
||||||
| "MODULE_DEPENDENCY_CYCLE"
|
| "MODULE_DEPENDENCY_CYCLE"
|
||||||
| "MODULE_DEPENDENCY_INCOMPATIBLE"
|
| "MODULE_DEPENDENCY_INCOMPATIBLE"
|
||||||
@@ -83,6 +85,11 @@ interface ModuleCandidate {
|
|||||||
packageName: string;
|
packageName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CapabilityProvider {
|
||||||
|
candidate: ModuleCandidate;
|
||||||
|
declaration: ModuleCapabilityDeclaration;
|
||||||
|
}
|
||||||
|
|
||||||
export class ModuleDiscoveryError extends Error {
|
export class ModuleDiscoveryError extends Error {
|
||||||
readonly issues: readonly ModuleDiscoveryIssue[];
|
readonly issues: readonly ModuleDiscoveryIssue[];
|
||||||
|
|
||||||
@@ -90,6 +97,7 @@ export class ModuleDiscoveryError extends Error {
|
|||||||
const sortedIssues = [...issues].sort(compareIssues);
|
const sortedIssues = [...issues].sort(compareIssues);
|
||||||
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
|
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
|
||||||
this.name = "ModuleDiscoveryError";
|
this.name = "ModuleDiscoveryError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
this.issues = freezeRecursively(sortedIssues);
|
this.issues = freezeRecursively(sortedIssues);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,6 +237,19 @@ async function resolveEntrypoints(
|
|||||||
if (relativeEntrypoint === undefined) {
|
if (relativeEntrypoint === undefined) {
|
||||||
continue;
|
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);
|
const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint);
|
||||||
if (!pathIsInside(moduleDirectory, configuredPath)) {
|
if (!pathIsInside(moduleDirectory, configuredPath)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -590,7 +611,7 @@ export async function discoverModules(
|
|||||||
.map((candidate) => [candidate.manifest.id, candidate] as const),
|
.map((candidate) => [candidate.manifest.id, candidate] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
const capabilityProviders = new Map<string, ModuleCandidate>();
|
const capabilityProviders = new Map<string, CapabilityProvider>();
|
||||||
for (const candidate of modulesById.values()) {
|
for (const candidate of modulesById.values()) {
|
||||||
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
|
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -613,8 +634,8 @@ export async function discoverModules(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const capability of candidate.manifest.capabilities) {
|
for (const declaration of candidate.manifest.capabilities) {
|
||||||
capabilityProviders.set(capability, candidate);
|
capabilityProviders.set(declaration.id, { candidate, declaration });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,9 +678,9 @@ export async function discoverModules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dependencyKind of ["required", "optional"] as const) {
|
for (const dependencyKind of ["required", "optional"] as const) {
|
||||||
for (const [index, capability] of candidate.manifest.dependencies.capabilities[
|
for (const [capability, versionRange] of Object.entries(
|
||||||
dependencyKind
|
candidate.manifest.dependencies.capabilities[dependencyKind],
|
||||||
].entries()) {
|
)) {
|
||||||
const provider = capabilityProviders.get(capability);
|
const provider = capabilityProviders.get(capability);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
if (dependencyKind === "required") {
|
if (dependencyKind === "required") {
|
||||||
@@ -668,12 +689,21 @@ export async function discoverModules(
|
|||||||
message: `Required capability ${capability} is not installed.`,
|
message: `Required capability ${capability} is not installed.`,
|
||||||
moduleDirectory: candidate.directoryName,
|
moduleDirectory: candidate.directoryName,
|
||||||
moduleId: candidate.manifest.id,
|
moduleId: candidate.manifest.id,
|
||||||
path: `dependencies.capabilities.${dependencyKind}.${index}`,
|
path: `dependencies.capabilities.${dependencyKind}.${capability}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> {
|
||||||
|
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<CapabilityId, RegisteredCapability>();
|
||||||
|
|
||||||
|
createRegistrationStage(module: DiscoveredModule): RegistrationStage {
|
||||||
|
const local = new Map<CapabilityId, RegisteredCapability>();
|
||||||
|
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<readonly LoadedModule[]> {
|
||||||
|
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<void> {
|
||||||
|
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<readonly ModuleRuntimeIssue[]> {
|
||||||
|
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<void> {
|
||||||
|
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<StartedModuleRuntime> {
|
||||||
|
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<void> | undefined;
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
capabilities,
|
||||||
|
modules: runningModules,
|
||||||
|
stop: (): Promise<void> => {
|
||||||
|
stopPromise ??= (async () => {
|
||||||
|
const issues = await stopModules(modules, stagedRegistry, "stop");
|
||||||
|
if (issues.length > 0) {
|
||||||
|
throw new ModuleRuntimeError(issues);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return stopPromise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -32,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 1,
|
manifestVersion: 2,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -40,10 +39,10 @@ function manifestFor(id: string): Record<string, unknown> {
|
|||||||
platform: "^0.1.0",
|
platform: "^0.1.0",
|
||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: [`${id}.records`],
|
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
||||||
permissions: [`${id}.records.read`],
|
permissions: [`${id}.records.read`],
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./src/server/index.ts",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -63,13 +62,13 @@ async function createModule(
|
|||||||
ekklesia: { layer: "module" },
|
ekklesia: { layer: "module" },
|
||||||
};
|
};
|
||||||
|
|
||||||
await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)),
|
writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)),
|
||||||
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "src/server/index.ts"),
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`,
|
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -125,13 +124,18 @@ describe("module discovery", () => {
|
|||||||
...manifestFor("calendar"),
|
...manifestFor("calendar"),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
modules: { required: { people: "^0.1.0" } },
|
modules: { required: { people: "^0.1.0" } },
|
||||||
capabilities: { required: ["people.records"] },
|
capabilities: { required: { "people.records": "^2.0.0" } },
|
||||||
},
|
},
|
||||||
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
|
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
|
||||||
},
|
},
|
||||||
serverSource: 'throw new Error("discovery must never execute server code");',
|
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({
|
const modules = await discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
@@ -149,19 +153,6 @@ describe("module discovery", () => {
|
|||||||
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
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 () => {
|
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await mkdir(path.join(modulesDirectory, "missing"));
|
await mkdir(path.join(modulesDirectory, "missing"));
|
||||||
@@ -223,7 +214,7 @@ describe("module discovery", () => {
|
|||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("one"),
|
...manifestFor("one"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: ["shared.records"],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: ["shared.records.read"],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
@@ -237,7 +228,7 @@ describe("module discovery", () => {
|
|||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("two"),
|
...manifestFor("two"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: ["shared.records"],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: ["shared.records.read"],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
@@ -312,18 +303,67 @@ describe("module discovery", () => {
|
|||||||
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE");
|
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();
|
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", {
|
await createModule(modulesDirectory, "consumer", {
|
||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("consumer"),
|
...manifestFor("consumer"),
|
||||||
dependencies: { capabilities: { required: ["people.records"] } },
|
dependencies: { capabilities: { required: { "people.records": "^1.0.0" } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const error = await discoveryError(modulesDirectory);
|
const error = await discoveryError(modulesDirectory);
|
||||||
expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING");
|
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 () => {
|
it("detects dependency cycles", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await createModule(modulesDirectory, "one", {
|
await createModule(modulesDirectory, "one", {
|
||||||
@@ -357,7 +397,7 @@ describe("module discovery", () => {
|
|||||||
await createModule(modulesDirectory, "missing-server", {
|
await createModule(modulesDirectory, "missing-server", {
|
||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("missing-server"),
|
...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");
|
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 () => {
|
it("requires executable entrypoints to be files and migrations to be a directory", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
|
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
|
||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("wrong-entrypoint-kind"),
|
...manifestFor("wrong-entrypoint-kind"),
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./src/server",
|
server: "./dist/server",
|
||||||
migrations: "./src/server/index.ts",
|
migrations: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
readonly packageJson?: Record<string, unknown>;
|
||||||
|
readonly serverSource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
const eventSymbols: symbol[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<PropertyKey, unknown>)[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<ModuleRuntimeError> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# 0005: Static module manifests and atomic discovery
|
# 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
|
- Date: 2026-07-12
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|||||||
+81
@@ -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.
|
||||||
@@ -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
|
- [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted
|
||||||
- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted
|
- [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted
|
||||||
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.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
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ surfaces.
|
|||||||
## Current implementation status
|
## Current implementation status
|
||||||
|
|
||||||
Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts,
|
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,
|
static versioned manifests, independent capability versions and dependency ranges, automatic
|
||||||
compatibility/dependency ordering, structured discovery failures, and a backend-only module
|
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
|
||||||
template. Core can validate zero or more modules without importing their server behavior.
|
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
|
Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth,
|
||||||
schema aggregation/migrations, Better Auth, permissions, physical storage-bucket provisioning,
|
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||||
Surface SDK, capability/resource/action registries, API server, dashboard, Builder, and reference
|
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||||
feature modules.
|
|
||||||
|
|||||||
@@ -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;
|
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
||||||
the root verification command discovers them automatically.
|
the root verification command discovers them automatically.
|
||||||
|
|
||||||
`pnpm modules:validate` scans `modules/*` without a central registry. A missing or empty modules
|
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
||||||
directory is valid and reports zero installed modules.
|
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 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
|
CI job runs the live infrastructure smoke test so code-only local verification does not require
|
||||||
|
|||||||
@@ -5,35 +5,37 @@ it never owns components, styles, layouts, renderers, navigation, or other UI.
|
|||||||
|
|
||||||
## Required package shape
|
## 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
|
```text
|
||||||
modules/example/
|
modules/example/
|
||||||
|
├── dist/server/index.js
|
||||||
├── ekklesia.module.json
|
├── ekklesia.module.json
|
||||||
├── package.json
|
├── package.json
|
||||||
└── src/server/index.ts
|
└── src/server/index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
`package.json` must use ESM, a strict semantic version, a unique package name, and:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"type": "module",
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "module"
|
"layer": "module"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Its version must match the module manifest version. Internal package dependencies use `workspace:`
|
The package version must match the manifest and runtime-definition versions. Internal package
|
||||||
ranges and supported package entrypoints.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 2,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -48,10 +50,20 @@ The canonical manifest is inert JSON so discovery can validate a module without
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"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"],
|
"permissions": ["calendar.events.read"],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
@@ -62,38 +74,83 @@ The canonical manifest is inert JSON so discovery can validate a module without
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./src/server/index.ts"
|
"server": "./dist/server/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Module, capability, and permission identifiers are lowercase and namespaced. Entrypoints must begin
|
Module, capability, and permission identifiers are lowercase and namespaced. A provided capability
|
||||||
with `./`, stay inside the package, and exist. Unknown manifest properties are rejected, including
|
has its own exact semantic version because its public contract can evolve independently from the
|
||||||
presentation fields such as components or renderers.
|
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.
|
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;
|
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
|
```ts
|
||||||
pnpm modules:validate
|
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
|
ID and version are intentional build-artifact identity markers and must match the manifest. Core
|
||||||
or package names, version mismatches, incompatible platform or SDK ranges, missing required modules
|
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
|
||||||
or capabilities, incompatible installed dependencies, cycles, and missing or escaping entrypoints.
|
own capabilities and the capability dependencies declared in its manifest. The runtime context is
|
||||||
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
|
deliberately limited to the module ID and capability access.
|
||||||
identifier sorting.
|
|
||||||
|
|
||||||
Discovery does not load server code. Lifecycle execution, route/job registration, Drizzle schema
|
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
|
||||||
aggregation and migrations, and physical bucket provisioning are deliberately deferred.
|
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
|
## Start from the template
|
||||||
|
|
||||||
Copy `templates/module` to `modules/<your-module-id>` and follow its README. The template is part of
|
Copy `templates/module` to `modules/<your-module-id>` and follow its README. No central registry edit
|
||||||
the workspace and is validated by the same contracts and discovery implementation as installed
|
is needed: the workspace glob, build, discovery, and runtime loading all find the new package.
|
||||||
modules.
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
||||||
"lint": "turbo run lint lint:root",
|
"lint": "turbo run lint lint:root",
|
||||||
"lint:root": "biome lint .",
|
"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": "turbo run test test:root",
|
||||||
"test:root": "vitest run --config vitest.config.ts",
|
"test:root": "vitest run --config vitest.config.ts",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./identifiers.js";
|
export * from "./identifiers.js";
|
||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
|
export * from "./module-runtime.js";
|
||||||
export * from "./versions.js";
|
export * from "./versions.js";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||||
|
const capabilityVersionMapSchema = z.record(capabilityIdSchema, semanticVersionRangeSchema);
|
||||||
|
|
||||||
const moduleDependenciesSchema = z
|
const moduleDependenciesSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -20,11 +21,11 @@ const moduleDependenciesSchema = z
|
|||||||
|
|
||||||
const capabilityDependenciesSchema = z
|
const capabilityDependenciesSchema = z
|
||||||
.object({
|
.object({
|
||||||
required: z.array(capabilityIdSchema).default(() => []),
|
required: capabilityVersionMapSchema.default(() => ({})),
|
||||||
optional: z.array(capabilityIdSchema).default(() => []),
|
optional: capabilityVersionMapSchema.default(() => ({})),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.default(() => ({ required: [], optional: [] }));
|
.default(() => ({ required: {}, optional: {} }));
|
||||||
|
|
||||||
const dependenciesSchema = z
|
const dependenciesSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -34,9 +35,16 @@ const dependenciesSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.default(() => ({
|
.default(() => ({
|
||||||
modules: { required: {}, optional: {} },
|
modules: { required: {}, optional: {} },
|
||||||
capabilities: { required: [], optional: [] },
|
capabilities: { required: {}, optional: {} },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const moduleCapabilityDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
id: capabilityIdSchema,
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const moduleBucketDeclarationSchema = z
|
export const moduleBucketDeclarationSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: logicalBucketIdSchema,
|
id: logicalBucketIdSchema,
|
||||||
@@ -64,6 +72,7 @@ function reportDuplicates(
|
|||||||
values: readonly string[],
|
values: readonly string[],
|
||||||
path: readonly (string | number)[],
|
path: readonly (string | number)[],
|
||||||
context: z.RefinementCtx,
|
context: z.RefinementCtx,
|
||||||
|
property?: string,
|
||||||
): void {
|
): void {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
values.forEach((value, index) => {
|
values.forEach((value, index) => {
|
||||||
@@ -71,7 +80,7 @@ function reportDuplicates(
|
|||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: `${value} is declared more than once.`,
|
message: `${value} is declared more than once.`,
|
||||||
path: [...path, index],
|
path: [...path, index, ...(property ? [property] : [])],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
seen.add(value);
|
seen.add(value);
|
||||||
@@ -80,7 +89,7 @@ function reportDuplicates(
|
|||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
manifestVersion: z.literal(1),
|
manifestVersion: z.literal(2),
|
||||||
id: moduleIdSchema,
|
id: moduleIdSchema,
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
@@ -92,37 +101,33 @@ export const moduleManifestSchema = z
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
dependencies: dependenciesSchema,
|
dependencies: dependenciesSchema,
|
||||||
capabilities: z.array(capabilityIdSchema).default(() => []),
|
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||||
permissions: z.array(permissionIdSchema).default(() => []),
|
permissions: z.array(permissionIdSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
storage: moduleStorageSchema,
|
||||||
entrypoints: entrypointsSchema,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((manifest, context) => {
|
.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.permissions, ["permissions"], context);
|
||||||
reportDuplicates(
|
|
||||||
manifest.dependencies.capabilities.required,
|
|
||||||
["dependencies", "capabilities", "required"],
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
reportDuplicates(
|
|
||||||
manifest.dependencies.capabilities.optional,
|
|
||||||
["dependencies", "capabilities", "optional"],
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
reportDuplicates(
|
reportDuplicates(
|
||||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
["storage", "buckets"],
|
["storage", "buckets"],
|
||||||
context,
|
context,
|
||||||
|
"id",
|
||||||
);
|
);
|
||||||
|
|
||||||
manifest.capabilities.forEach((capability, index) => {
|
manifest.capabilities.forEach((capability, index) => {
|
||||||
if (!capability.startsWith(`${manifest.id}.`)) {
|
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
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 ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||||
const requiredCapabilities = new Set(manifest.dependencies.capabilities.required);
|
for (const dependencyKind of ["required", "optional"] as const) {
|
||||||
manifest.dependencies.capabilities.required.forEach((capability, index) => {
|
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||||
if (ownedCapabilities.has(capability)) {
|
if (ownedCapabilities.has(capability)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "A module does not need to depend on a capability it owns.",
|
message: "A module does not need to depend on a capability it owns.",
|
||||||
path: ["dependencies", "capabilities", "required", index],
|
path: ["dependencies", "capabilities", dependencyKind, capability],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
manifest.dependencies.capabilities.optional.forEach((capability, index) => {
|
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
|
||||||
if (ownedCapabilities.has(capability)) {
|
if (Object.hasOwn(manifest.dependencies.capabilities.optional, 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)) {
|
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: `${capability} cannot be both a required and optional capability dependency.`,
|
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 {
|
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||||
@@ -197,3 +196,4 @@ export function safeParseModuleManifest(input: unknown) {
|
|||||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
|
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||||
|
|||||||
@@ -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> = Value | PromiseLike<Value>;
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||||
|
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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 } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 1,
|
manifestVersion: 2,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -17,13 +17,13 @@ function validManifest(): Record<string, unknown> {
|
|||||||
platform: "^0.1.0",
|
platform: "^0.1.0",
|
||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: ["calendar.events"],
|
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
|
||||||
permissions: ["calendar.events.read"],
|
permissions: ["calendar.events.read"],
|
||||||
storage: {
|
storage: {
|
||||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||||
},
|
},
|
||||||
entrypoints: {
|
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.id).toBe("calendar");
|
||||||
expect(manifest.dependencies).toEqual({
|
expect(manifest.dependencies).toEqual({
|
||||||
capabilities: { optional: [], required: [] },
|
capabilities: { optional: {}, required: {} },
|
||||||
modules: { optional: {}, required: {} },
|
modules: { optional: {}, required: {} },
|
||||||
});
|
});
|
||||||
expect(manifest.storage.buckets).toEqual([
|
expect(manifest.storage.buckets).toEqual([
|
||||||
@@ -49,11 +49,11 @@ describe("module manifest contract", () => {
|
|||||||
const second = parseModuleManifest(input);
|
const second = parseModuleManifest(input);
|
||||||
|
|
||||||
first.dependencies.modules.required.people = "^0.1.0";
|
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" });
|
first.storage.buckets.push({ id: "exports" });
|
||||||
|
|
||||||
expect(second.dependencies.modules.required).toEqual({});
|
expect(second.dependencies.modules.required).toEqual({});
|
||||||
expect(second.dependencies.capabilities.required).toEqual([]);
|
expect(second.dependencies.capabilities.required).toEqual({});
|
||||||
expect(second.storage.buckets).toEqual([]);
|
expect(second.storage.buckets).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ describe("module manifest contract", () => {
|
|||||||
it("requires module-owned capability and permission namespaces", () => {
|
it("requires module-owned capability and permission namespaces", () => {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
capabilities: ["media.items"],
|
capabilities: [{ id: "media.items", version: "1.0.0" }],
|
||||||
permissions: ["media.items.read"],
|
permissions: ["media.items.read"],
|
||||||
};
|
};
|
||||||
const result = safeParseModuleManifest(manifest);
|
const result = safeParseModuleManifest(manifest);
|
||||||
@@ -75,7 +75,7 @@ describe("module manifest contract", () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
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" },
|
optional: { people: "^1.0.0" },
|
||||||
},
|
},
|
||||||
capabilities: {
|
capabilities: {
|
||||||
required: ["people.records"],
|
required: { "people.records": "^1.0.0" },
|
||||||
optional: ["people.records"],
|
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" }] },
|
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
|
||||||
};
|
};
|
||||||
const result = safeParseModuleManifest(manifest);
|
const result = safeParseModuleManifest(manifest);
|
||||||
@@ -109,13 +112,45 @@ describe("module manifest contract", () => {
|
|||||||
...validManifest(),
|
...validManifest(),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
modules: { required: { calendar: "^0.1.0" } },
|
modules: { required: { calendar: "^0.1.0" } },
|
||||||
capabilities: { required: ["calendar.events"] },
|
capabilities: { required: { "calendar.events": "^2.0.0" } },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
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([
|
it.each([
|
||||||
"../outside.ts",
|
"../outside.ts",
|
||||||
"/absolute.ts",
|
"/absolute.ts",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { type ModuleRuntimeDefinition, parseModuleRuntimeDefinition } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export function defineModuleRuntime<const Definition extends ModuleRuntimeDefinition>(
|
||||||
|
definition: Definition,
|
||||||
|
): Readonly<Definition> {
|
||||||
|
return parseModuleRuntimeDefinition(definition) as Readonly<Definition>;
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import {
|
|
||||||
type ModuleManifest,
|
|
||||||
type ModuleManifestInput,
|
|
||||||
parseModuleManifest,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
|
|
||||||
export function defineModule(manifest: ModuleManifestInput): ModuleManifest {
|
|
||||||
return parseModuleManifest(manifest);
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export * from "@ekklesia/contracts";
|
export * from "@ekklesia/contracts";
|
||||||
export * from "./define-module.js";
|
export * from "./define-module-runtime.js";
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Ekklesia module template
|
# Ekklesia module template
|
||||||
|
|
||||||
This is the smallest supported backend-only Ekklesia module. It proves the manifest, package,
|
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||||
Module SDK, discovery, capability, permission, and logical storage-bucket contracts without
|
compiled runtime entrypoint, capability registration, and logical storage-bucket declarations
|
||||||
pretending that the later runtime, API, or Drizzle registration layers already exist.
|
without pretending that API, Drizzle, or storage provisioning already exist.
|
||||||
|
|
||||||
To create a module inside this repository:
|
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.
|
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
|
3. Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in
|
||||||
`ekklesia.module.json`.
|
`ekklesia.module.json`.
|
||||||
4. Replace the example server entrypoint with backend behavior only.
|
4. Keep the manifest capability versions and dependency ranges aligned with the contracts the
|
||||||
5. Run `pnpm install`, `pnpm modules:validate`, and `pnpm verify` from the repository root.
|
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.
|
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`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 1,
|
"manifestVersion": 2,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal backend-only Ekklesia module template.",
|
"description": "A minimal backend-only Ekklesia module template.",
|
||||||
@@ -8,7 +8,12 @@
|
|||||||
"platform": "^0.1.0",
|
"platform": "^0.1.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.1.0"
|
||||||
},
|
},
|
||||||
"capabilities": ["example.records"],
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "example.records",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
"permissions": ["example.records.read"],
|
"permissions": ["example.records.read"],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
@@ -19,6 +24,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./src/server/index.ts"
|
"server": "./dist/server/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"src",
|
|
||||||
"ekklesia.module.json"
|
"ekklesia.module.json"
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
export interface ExampleModuleServer {
|
import { defineModuleRuntime } from "@ekklesia/module-sdk";
|
||||||
readonly id: "example";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const exampleModuleServer: ExampleModuleServer = {
|
export const recordsCapability = Object.freeze({
|
||||||
|
kind: "example-records",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineModuleRuntime({
|
||||||
id: "example",
|
id: "example",
|
||||||
};
|
version: "0.1.0",
|
||||||
|
register({ capabilities }) {
|
||||||
|
capabilities.provide("example.records", recordsCapability);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
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 { describe, expect, it } from "vitest";
|
||||||
|
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
|
||||||
|
|
||||||
describe("module template", () => {
|
describe("module template", () => {
|
||||||
it("ships a valid backend-only manifest", async () => {
|
it("ships a valid backend-only manifest", async () => {
|
||||||
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||||
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as ModuleManifestInput;
|
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
|
||||||
const manifest = defineModule(manifestInput);
|
const manifest = parseModuleManifest(manifestInput);
|
||||||
|
|
||||||
expect(manifest.id).toBe("example");
|
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"]);
|
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<CapabilityId, unknown>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ describe("development infrastructure", () => {
|
|||||||
expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root");
|
expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root");
|
||||||
expect(packageDocument.scripts?.test).toBe("turbo run test test:root");
|
expect(packageDocument.scripts?.test).toBe("turbo run test test:root");
|
||||||
expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck: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(packageDocument.scripts?.verify).toContain("pnpm modules:validate");
|
||||||
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
|
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
|
||||||
expect(turboDocument.tasks).toHaveProperty("//#test:root");
|
expect(turboDocument.tasks).toHaveProperty("//#test:root");
|
||||||
|
|||||||
Reference in New Issue
Block a user