feat: add module runtime lifecycle
CI / verify (push) Successful in 2m14s
CI / infrastructure-smoke (push) Failing after 1m36s

This commit is contained in:
ekklesia
2026-07-12 22:49:51 -05:00
parent 57552a21a4
commit 7b448d8706
28 changed files with 1382 additions and 203 deletions
+1
View File
@@ -1,4 +1,5 @@
export * from "./identifiers.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-runtime.js";
export * from "./versions.js";
+41 -41
View File
@@ -9,6 +9,7 @@ import {
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
const capabilityVersionMapSchema = z.record(capabilityIdSchema, semanticVersionRangeSchema);
const moduleDependenciesSchema = z
.object({
@@ -20,11 +21,11 @@ const moduleDependenciesSchema = z
const capabilityDependenciesSchema = z
.object({
required: z.array(capabilityIdSchema).default(() => []),
optional: z.array(capabilityIdSchema).default(() => []),
required: capabilityVersionMapSchema.default(() => ({})),
optional: capabilityVersionMapSchema.default(() => ({})),
})
.strict()
.default(() => ({ required: [], optional: [] }));
.default(() => ({ required: {}, optional: {} }));
const dependenciesSchema = z
.object({
@@ -34,9 +35,16 @@ const dependenciesSchema = z
.strict()
.default(() => ({
modules: { required: {}, optional: {} },
capabilities: { required: [], optional: [] },
capabilities: { required: {}, optional: {} },
}));
export const moduleCapabilityDeclarationSchema = z
.object({
id: capabilityIdSchema,
version: semanticVersionSchema,
})
.strict();
export const moduleBucketDeclarationSchema = z
.object({
id: logicalBucketIdSchema,
@@ -64,6 +72,7 @@ function reportDuplicates(
values: readonly string[],
path: readonly (string | number)[],
context: z.RefinementCtx,
property?: string,
): void {
const seen = new Set<string>();
values.forEach((value, index) => {
@@ -71,7 +80,7 @@ function reportDuplicates(
context.addIssue({
code: "custom",
message: `${value} is declared more than once.`,
path: [...path, index],
path: [...path, index, ...(property ? [property] : [])],
});
}
seen.add(value);
@@ -80,7 +89,7 @@ function reportDuplicates(
export const moduleManifestSchema = z
.object({
manifestVersion: z.literal(1),
manifestVersion: z.literal(2),
id: moduleIdSchema,
name: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500).optional(),
@@ -92,37 +101,33 @@ export const moduleManifestSchema = z
})
.strict(),
dependencies: dependenciesSchema,
capabilities: z.array(capabilityIdSchema).default(() => []),
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
permissions: z.array(permissionIdSchema).default(() => []),
storage: moduleStorageSchema,
entrypoints: entrypointsSchema,
})
.strict()
.superRefine((manifest, context) => {
reportDuplicates(manifest.capabilities, ["capabilities"], context);
reportDuplicates(
manifest.capabilities.map((capability) => capability.id),
["capabilities"],
context,
"id",
);
reportDuplicates(manifest.permissions, ["permissions"], context);
reportDuplicates(
manifest.dependencies.capabilities.required,
["dependencies", "capabilities", "required"],
context,
);
reportDuplicates(
manifest.dependencies.capabilities.optional,
["dependencies", "capabilities", "optional"],
context,
);
reportDuplicates(
manifest.storage.buckets.map((bucket) => bucket.id),
["storage", "buckets"],
context,
"id",
);
manifest.capabilities.forEach((capability, index) => {
if (!capability.startsWith(`${manifest.id}.`)) {
if (!capability.id.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
path: ["capabilities", index],
path: ["capabilities", index, "id"],
});
}
});
@@ -157,33 +162,27 @@ export const moduleManifestSchema = z
}
}
const ownedCapabilities = new Set(manifest.capabilities);
const requiredCapabilities = new Set(manifest.dependencies.capabilities.required);
manifest.dependencies.capabilities.required.forEach((capability, index) => {
if (ownedCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", "required", index],
});
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
for (const dependencyKind of ["required", "optional"] as const) {
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
if (ownedCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", dependencyKind, capability],
});
}
}
});
manifest.dependencies.capabilities.optional.forEach((capability, index) => {
if (ownedCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", "optional", index],
});
}
if (requiredCapabilities.has(capability)) {
}
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
context.addIssue({
code: "custom",
message: `${capability} cannot be both a required and optional capability dependency.`,
path: ["dependencies", "capabilities", "optional", index],
path: ["dependencies", "capabilities", "optional", capability],
});
}
});
}
});
export function parseModuleManifest(input: unknown): ModuleManifest {
@@ -197,3 +196,4 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
+73
View File
@@ -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> {
return {
manifestVersion: 1,
manifestVersion: 2,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -17,13 +17,13 @@ function validManifest(): Record<string, unknown> {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: ["calendar.events"],
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
permissions: ["calendar.events.read"],
storage: {
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
},
entrypoints: {
server: "./src/server/index.ts",
server: "./dist/server/index.js",
},
};
}
@@ -34,7 +34,7 @@ describe("module manifest contract", () => {
expect(manifest.id).toBe("calendar");
expect(manifest.dependencies).toEqual({
capabilities: { optional: [], required: [] },
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
});
expect(manifest.storage.buckets).toEqual([
@@ -49,11 +49,11 @@ describe("module manifest contract", () => {
const second = parseModuleManifest(input);
first.dependencies.modules.required.people = "^0.1.0";
first.dependencies.capabilities.required.push("people.records");
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
first.storage.buckets.push({ id: "exports" });
expect(second.dependencies.modules.required).toEqual({});
expect(second.dependencies.capabilities.required).toEqual([]);
expect(second.dependencies.capabilities.required).toEqual({});
expect(second.storage.buckets).toEqual([]);
});
@@ -67,7 +67,7 @@ describe("module manifest contract", () => {
it("requires module-owned capability and permission namespaces", () => {
const manifest = {
...validManifest(),
capabilities: ["media.items"],
capabilities: [{ id: "media.items", version: "1.0.0" }],
permissions: ["media.items.read"],
};
const result = safeParseModuleManifest(manifest);
@@ -75,7 +75,7 @@ describe("module manifest contract", () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["capabilities.0", "permissions.0"]),
expect.arrayContaining(["capabilities.0.id", "permissions.0"]),
);
}
});
@@ -89,11 +89,14 @@ describe("module manifest contract", () => {
optional: { people: "^1.0.0" },
},
capabilities: {
required: ["people.records"],
optional: ["people.records"],
required: { "people.records": "^1.0.0" },
optional: { "people.records": "^1.0.0" },
},
},
capabilities: ["calendar.events", "calendar.events"],
capabilities: [
{ id: "calendar.events", version: "2.3.0" },
{ id: "calendar.events", version: "2.4.0" },
],
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
};
const result = safeParseModuleManifest(manifest);
@@ -109,13 +112,45 @@ describe("module manifest contract", () => {
...validManifest(),
dependencies: {
modules: { required: { calendar: "^0.1.0" } },
capabilities: { required: ["calendar.events"] },
capabilities: { required: { "calendar.events": "^2.0.0" } },
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it("versions capabilities independently from the module release", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.version).toBe("0.1.0");
expect(manifest.capabilities).toEqual([{ id: "calendar.events", version: "2.3.0" }]);
});
it("requires exact capability versions and dependency ranges", () => {
const manifest = {
...validManifest(),
capabilities: [{ id: "calendar.events", version: "^2.0.0" }],
dependencies: {
capabilities: { required: { "people.records": "not-a-range" } },
},
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining([
"capabilities.0.version",
"dependencies.capabilities.required.people.records",
]),
);
}
});
it("rejects obsolete manifest versions instead of guessing a migration", () => {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
});
it.each([
"../outside.ts",
"/absolute.ts",
@@ -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>;
}
-9
View File
@@ -1,9 +0,0 @@
import {
type ModuleManifest,
type ModuleManifestInput,
parseModuleManifest,
} from "@ekklesia/contracts";
export function defineModule(manifest: ModuleManifestInput): ModuleManifest {
return parseModuleManifest(manifest);
}
+1 -1
View File
@@ -1,2 +1,2 @@
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();
});
});