feat: add module contracts and discovery
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
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,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
interface ModuleFixtureOptions {
|
||||
directory?: string;
|
||||
manifest?: Record<string, unknown>;
|
||||
packageJson?: Record<string, unknown>;
|
||||
serverSource?: string;
|
||||
}
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
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-modules-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: [`${id}.records`],
|
||||
permissions: [`${id}.records.read`],
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
options: ModuleFixtureOptions = {},
|
||||
): Promise<void> {
|
||||
const directoryName = options.directory ?? id;
|
||||
const moduleDirectory = path.join(modulesDirectory, directoryName);
|
||||
const manifest = options.manifest ?? manifestFor(id);
|
||||
const packageJson = options.packageJson ?? {
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
};
|
||||
|
||||
await mkdir(path.join(moduleDirectory, "src/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)};`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async function discoveryError(
|
||||
modulesDirectory: string,
|
||||
versions: { moduleSdkVersion?: string; platformVersion?: string } = {},
|
||||
): Promise<ModuleDiscoveryError> {
|
||||
try {
|
||||
await discoverModules({
|
||||
moduleSdkVersion: versions.moduleSdkVersion ?? "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: versions.platformVersion ?? "0.1.0",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleDiscoveryError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected module discovery to fail.");
|
||||
}
|
||||
|
||||
function issueCodes(error: ModuleDiscoveryError): ModuleDiscoveryIssueCode[] {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
|
||||
describe("module discovery", () => {
|
||||
it("treats a missing or empty modules directory as zero installed modules", async () => {
|
||||
const parent = await createModulesDirectory();
|
||||
const missing = path.join(parent, "missing");
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: missing,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: parent,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("discovers static manifests and orders dependencies deterministically", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "calendar", {
|
||||
manifest: {
|
||||
...manifestFor("calendar"),
|
||||
dependencies: {
|
||||
modules: { required: { people: "^0.1.0" } },
|
||||
capabilities: { required: ["people.records"] },
|
||||
},
|
||||
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
|
||||
},
|
||||
serverSource: 'throw new Error("discovery must never execute server code");',
|
||||
});
|
||||
await createModule(modulesDirectory, "people");
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules.map((module) => module.manifest.id)).toEqual(["people", "calendar"]);
|
||||
expect(modules[1]?.manifest.storage.buckets.map((bucket) => bucket.id)).toEqual([
|
||||
"assets",
|
||||
"exports",
|
||||
]);
|
||||
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
||||
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
||||
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"));
|
||||
await createModule(modulesDirectory, "invalid", {
|
||||
manifest: { ...manifestFor("invalid"), renderers: ["not-allowed"] },
|
||||
packageJson: {
|
||||
name: "@test/module-invalid",
|
||||
version: "not-semver",
|
||||
type: "commonjs",
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_MANIFEST_INVALID",
|
||||
"MODULE_MANIFEST_MISSING",
|
||||
"MODULE_PACKAGE_INVALID",
|
||||
"MODULE_PACKAGE_MISSING",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("aggregates malformed JSON without exposing machine-specific paths", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const moduleDirectory = path.join(modulesDirectory, "broken-json");
|
||||
await mkdir(moduleDirectory);
|
||||
await Promise.all([
|
||||
writeFile(path.join(moduleDirectory, "package.json"), "{"),
|
||||
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), "["),
|
||||
]);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining(["MODULE_MANIFEST_INVALID", "MODULE_PACKAGE_INVALID"]),
|
||||
);
|
||||
expect(error.issues.every((issue) => !issue.message.includes(modulesDirectory))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires package and manifest versions to match", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "mismatch", {
|
||||
packageJson: {
|
||||
name: "@test/module-mismatch",
|
||||
version: "0.2.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_VERSION_MISMATCH");
|
||||
});
|
||||
|
||||
it("rejects duplicate module IDs and package names", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "one", {
|
||||
manifest: {
|
||||
...manifestFor("one"),
|
||||
id: "shared",
|
||||
capabilities: ["shared.records"],
|
||||
permissions: ["shared.records.read"],
|
||||
},
|
||||
packageJson: {
|
||||
name: "@test/duplicate",
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
},
|
||||
});
|
||||
await createModule(modulesDirectory, "two", {
|
||||
manifest: {
|
||||
...manifestFor("two"),
|
||||
id: "shared",
|
||||
capabilities: ["shared.records"],
|
||||
permissions: ["shared.records.read"],
|
||||
},
|
||||
packageJson: {
|
||||
name: "@test/duplicate",
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining(["MODULE_ID_DUPLICATE", "MODULE_PACKAGE_NAME_DUPLICATE"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces platform and Module SDK compatibility", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "future", {
|
||||
manifest: {
|
||||
...manifestFor("future"),
|
||||
compatibility: { platform: ">=1.0.0", moduleSdk: ">=2.0.0" },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining(["MODULE_PLATFORM_INCOMPATIBLE", "MODULE_SDK_INCOMPATIBLE"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows missing optional dependencies but rejects missing required dependencies", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "optional-consumer", {
|
||||
manifest: {
|
||||
...manifestFor("optional-consumer"),
|
||||
dependencies: { modules: { optional: { absent: "^1.0.0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).resolves.toHaveLength(1);
|
||||
|
||||
await createModule(modulesDirectory, "required-consumer", {
|
||||
manifest: {
|
||||
...manifestFor("required-consumer"),
|
||||
dependencies: { modules: { required: { absent: "^1.0.0" } } },
|
||||
},
|
||||
});
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_MISSING");
|
||||
});
|
||||
|
||||
it("rejects installed dependencies with incompatible versions", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "people");
|
||||
await createModule(modulesDirectory, "calendar", {
|
||||
manifest: {
|
||||
...manifestFor("calendar"),
|
||||
dependencies: { modules: { optional: { people: "^2.0.0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE");
|
||||
});
|
||||
|
||||
it("rejects missing required capabilities", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "consumer", {
|
||||
manifest: {
|
||||
...manifestFor("consumer"),
|
||||
dependencies: { capabilities: { required: ["people.records"] } },
|
||||
},
|
||||
});
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING");
|
||||
});
|
||||
|
||||
it("detects dependency cycles", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "one", {
|
||||
manifest: {
|
||||
...manifestFor("one"),
|
||||
dependencies: { modules: { required: { two: "^0.1.0" } } },
|
||||
},
|
||||
});
|
||||
await createModule(modulesDirectory, "two", {
|
||||
manifest: {
|
||||
...manifestFor("two"),
|
||||
dependencies: { modules: { required: { one: "^0.1.0" } } },
|
||||
},
|
||||
});
|
||||
await createModule(modulesDirectory, "three", {
|
||||
manifest: {
|
||||
...manifestFor("three"),
|
||||
dependencies: { modules: { required: { two: "^0.1.0" } } },
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_CYCLE");
|
||||
expect(error.issues.find((issue) => issue.code === "MODULE_DEPENDENCY_CYCLE")?.message).toBe(
|
||||
"Module dependency cycle contains: one, two.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing entrypoint targets and invalid caller versions", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "missing-server", {
|
||||
manifest: {
|
||||
...manifestFor("missing-server"),
|
||||
entrypoints: { server: "./src/server/missing.ts" },
|
||||
},
|
||||
});
|
||||
|
||||
let error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
||||
|
||||
error = await discoveryError(modulesDirectory, { platformVersion: "invalid" });
|
||||
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID");
|
||||
});
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects package metadata links that escape the module directory", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "linked");
|
||||
const externalManifest = path.join(modulesDirectory, "external-manifest.json");
|
||||
const linkedManifest = path.join(modulesDirectory, "linked", "ekklesia.module.json");
|
||||
await writeFile(externalManifest, JSON.stringify(manifestFor("linked")));
|
||||
await rm(linkedManifest);
|
||||
await symlink("../external-manifest.json", linkedManifest);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_MANIFEST_INVALID");
|
||||
});
|
||||
|
||||
it("rejects symbolic links masquerading as installed module directories", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const externalModules = await createModulesDirectory();
|
||||
await createModule(externalModules, "outside");
|
||||
await symlink(
|
||||
path.join(externalModules, "outside"),
|
||||
path.join(modulesDirectory, "linked-module"),
|
||||
"dir",
|
||||
);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
expect(issueCodes(error)).toContain("MODULE_DIRECTORY_INVALID");
|
||||
});
|
||||
|
||||
it("produces stable diagnostics independent of the absolute modules path", async () => {
|
||||
const firstModulesDirectory = await createModulesDirectory();
|
||||
const secondModulesDirectory = await createModulesDirectory();
|
||||
await mkdir(path.join(firstModulesDirectory, "broken"));
|
||||
await mkdir(path.join(secondModulesDirectory, "broken"));
|
||||
|
||||
const [firstError, secondError] = await Promise.all([
|
||||
discoveryError(firstModulesDirectory),
|
||||
discoveryError(secondModulesDirectory),
|
||||
]);
|
||||
|
||||
expect(firstError.issues).toEqual(secondError.issues);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user