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
+88 -29
View File
@@ -1,7 +1,6 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
@@ -32,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 1,
manifestVersion: 2,
id,
name: `${id} module`,
version: "0.1.0",
@@ -40,10 +39,10 @@ function manifestFor(id: string): Record<string, unknown> {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: [`${id}.records`],
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
permissions: [`${id}.records.read`],
entrypoints: {
server: "./src/server/index.ts",
server: "./dist/server/index.js",
},
};
}
@@ -63,13 +62,13 @@ async function createModule(
ekklesia: { layer: "module" },
};
await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "src/server/index.ts"),
options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`,
path.join(moduleDirectory, "dist/server/index.js"),
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
]);
}
@@ -125,13 +124,18 @@ describe("module discovery", () => {
...manifestFor("calendar"),
dependencies: {
modules: { required: { people: "^0.1.0" } },
capabilities: { required: ["people.records"] },
capabilities: { required: { "people.records": "^2.0.0" } },
},
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
},
serverSource: 'throw new Error("discovery must never execute server code");',
});
await createModule(modulesDirectory, "people");
await createModule(modulesDirectory, "people", {
manifest: {
...manifestFor("people"),
capabilities: [{ id: "people.records", version: "2.1.0" }],
},
});
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
@@ -149,19 +153,6 @@ describe("module discovery", () => {
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
});
it("accepts the repository's cloneable module template", async () => {
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const templatesDirectory = path.resolve(testDirectory, "../../templates");
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory: templatesDirectory,
platformVersion: "0.1.0",
});
expect(modules.map((module) => module.manifest.id)).toEqual(["example"]);
});
it("aggregates malformed packages and manifests without partial discovery", async () => {
const modulesDirectory = await createModulesDirectory();
await mkdir(path.join(modulesDirectory, "missing"));
@@ -223,7 +214,7 @@ describe("module discovery", () => {
manifest: {
...manifestFor("one"),
id: "shared",
capabilities: ["shared.records"],
capabilities: [{ id: "shared.records", version: "1.0.0" }],
permissions: ["shared.records.read"],
},
packageJson: {
@@ -237,7 +228,7 @@ describe("module discovery", () => {
manifest: {
...manifestFor("two"),
id: "shared",
capabilities: ["shared.records"],
capabilities: [{ id: "shared.records", version: "1.0.0" }],
permissions: ["shared.records.read"],
},
packageJson: {
@@ -312,18 +303,67 @@ describe("module discovery", () => {
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE");
});
it("rejects missing required capabilities", async () => {
it("allows absent optional capabilities but rejects missing required capabilities", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "optional-consumer", {
manifest: {
...manifestFor("optional-consumer"),
dependencies: { capabilities: { optional: { "people.records": "^1.0.0" } } },
},
});
await expect(
discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
}),
).resolves.toHaveLength(1);
await createModule(modulesDirectory, "consumer", {
manifest: {
...manifestFor("consumer"),
dependencies: { capabilities: { required: ["people.records"] } },
dependencies: { capabilities: { required: { "people.records": "^1.0.0" } } },
},
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING");
});
it("rejects incompatible installed required and optional capabilities", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "people");
await createModule(modulesDirectory, "required-consumer", {
manifest: {
...manifestFor("required-consumer"),
dependencies: { capabilities: { required: { "people.records": "^2.0.0" } } },
},
});
await createModule(modulesDirectory, "optional-consumer", {
manifest: {
...manifestFor("optional-consumer"),
dependencies: { capabilities: { optional: { "people.records": "^3.0.0" } } },
},
});
const error = await discoveryError(modulesDirectory);
const incompatibilities = error.issues.filter(
(issue) => issue.code === "MODULE_CAPABILITY_INCOMPATIBLE",
);
expect(incompatibilities).toHaveLength(2);
expect(incompatibilities).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "Capability people.records 1.0.0 does not satisfy ^2.0.0.",
path: "dependencies.capabilities.required.people.records",
}),
expect.objectContaining({
message: "Capability people.records 1.0.0 does not satisfy ^3.0.0.",
path: "dependencies.capabilities.optional.people.records",
}),
]),
);
});
it("detects dependency cycles", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "one", {
@@ -357,7 +397,7 @@ describe("module discovery", () => {
await createModule(modulesDirectory, "missing-server", {
manifest: {
...manifestFor("missing-server"),
entrypoints: { server: "./src/server/missing.ts" },
entrypoints: { server: "./dist/server/missing.js" },
},
});
@@ -368,14 +408,33 @@ describe("module discovery", () => {
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID");
});
it("rejects source TypeScript server entrypoints", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "source-server", {
manifest: {
...manifestFor("source-server"),
entrypoints: { server: "./src/server/index.ts" },
},
});
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
path: "entrypoints.server",
}),
);
});
it("requires executable entrypoints to be files and migrations to be a directory", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
manifest: {
...manifestFor("wrong-entrypoint-kind"),
entrypoints: {
server: "./src/server",
migrations: "./src/server/index.ts",
server: "./dist/server",
migrations: "./dist/server/index.js",
},
},
});