feat: add module contracts and discovery
CI / verify (push) Has been cancelled
CI / infrastructure-smoke (push) Has been cancelled

This commit is contained in:
ekklesia
2026-07-12 22:14:01 -05:00
parent d9489384c1
commit 57552a21a4
43 changed files with 2277 additions and 21 deletions
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@ekklesia/core",
"version": "0.1.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./modules": {
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "core"
}
}
+51
View File
@@ -0,0 +1,51 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { discoverModules, ModuleDiscoveryError } from "../modules/index.js";
interface PackageVersion {
version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string") {
throw new Error(`${packagePath} does not declare a version.`);
}
return packageData.version;
}
async function main(): Promise<void> {
const repositoryRoot = process.cwd();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
if (modules.length === 0) {
console.log("Module validation passed (0 modules installed).");
return;
}
console.log(`Module validation passed (${modules.length} modules):`);
for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
}
}
main().catch((error: unknown) => {
if (error instanceof ModuleDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
} else {
console.error(error instanceof Error ? error.message : String(error));
}
process.exitCode = 1;
});
+1
View File
@@ -0,0 +1 @@
export * from "./modules/index.js";
+696
View File
@@ -0,0 +1,696 @@
import type { Dirent } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
import {
isVersionCompatible,
type ModuleManifest,
safeParseModuleManifest,
semanticVersionSchema,
} from "@ekklesia/contracts";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
export type ModuleDiscoveryIssueCode =
| "DISCOVERY_CONFIGURATION_INVALID"
| "MODULE_CAPABILITY_MISSING"
| "MODULE_DEPENDENCY_CYCLE"
| "MODULE_DEPENDENCY_INCOMPATIBLE"
| "MODULE_DEPENDENCY_MISSING"
| "MODULE_DIRECTORY_INVALID"
| "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE"
| "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID"
| "MODULE_PACKAGE_MISSING"
| "MODULE_PACKAGE_NAME_DUPLICATE"
| "MODULE_PLATFORM_INCOMPATIBLE"
| "MODULE_SDK_INCOMPATIBLE"
| "MODULE_VERSION_MISMATCH"
| "MODULES_DIRECTORY_UNREADABLE";
export interface ModuleDiscoveryIssue {
readonly code: ModuleDiscoveryIssueCode;
readonly message: string;
readonly moduleDirectory: string;
readonly moduleId?: string;
readonly path?: string;
}
export interface ResolvedModuleEntrypoints {
readonly server: string;
readonly schema?: string;
readonly migrations?: string;
readonly jobs?: string;
}
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
export interface DiscoveredModule {
readonly directory: string;
readonly entrypoints: ResolvedModuleEntrypoints;
readonly manifest: ReadonlyModuleManifest;
readonly manifestPath: string;
readonly packageName: string;
}
export interface ModuleDiscoveryOptions {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
}
interface ModulePackageData {
name: string;
version: string;
}
interface ModuleCandidate {
directory: string;
directoryName: string;
entrypoints: ResolvedModuleEntrypoints;
manifest: ModuleManifest;
manifestPath: string;
packageName: string;
}
export class ModuleDiscoveryError extends Error {
readonly issues: readonly ModuleDiscoveryIssue[];
constructor(issues: readonly ModuleDiscoveryIssue[]) {
const sortedIssues = [...issues].sort(compareIssues);
super(`Module discovery failed with ${sortedIssues.length} issue(s).`);
this.name = "ModuleDiscoveryError";
this.issues = freezeRecursively(sortedIssues);
}
}
function compareText(left: string, right: string): number {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function compareIssues(left: ModuleDiscoveryIssue, right: ModuleDiscoveryIssue): number {
return (
compareText(left.moduleDirectory, right.moduleDirectory) ||
compareText(left.code, right.code) ||
compareText(left.path ?? "", right.path ?? "") ||
compareText(left.message, right.message)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function freezeRecursively<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
return value;
}
for (const nestedValue of Object.values(value)) {
freezeRecursively(nestedValue);
}
Object.freeze(value);
return value;
}
function errorCode(error: unknown): string | undefined {
if (isRecord(error) && typeof error.code === "string") {
return error.code;
}
return undefined;
}
function issuePath(pathParts: readonly PropertyKey[]): string {
return pathParts.map(String).join(".");
}
function pathIsInside(parent: string, candidate: string): boolean {
const relativePath = path.relative(parent, candidate);
return (
relativePath === "" ||
(relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
async function readJson(pathname: string): Promise<unknown> {
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
}
function validatePackageData(
input: unknown,
directoryName: string,
issues: ModuleDiscoveryIssue[],
): ModulePackageData | undefined {
if (!isRecord(input)) {
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: "package.json must contain a JSON object.",
moduleDirectory: directoryName,
});
return undefined;
}
const name = input.name;
const version = input.version;
const packageType = input.type;
const layer = isRecord(input.ekklesia) ? input.ekklesia.layer : undefined;
let validPackage = true;
if (typeof name !== "string" || name.length === 0) {
validPackage = false;
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: "package.json must declare a non-empty package name.",
moduleDirectory: directoryName,
path: "name",
});
}
if (semanticVersionSchema.safeParse(version).success === false) {
validPackage = false;
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: "package.json must declare a strict semantic version.",
moduleDirectory: directoryName,
path: "version",
});
}
if (packageType !== "module") {
validPackage = false;
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: 'package.json must declare "type": "module".',
moduleDirectory: directoryName,
path: "type",
});
}
if (layer !== "module") {
validPackage = false;
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: 'package.json must declare "ekklesia.layer": "module".',
moduleDirectory: directoryName,
path: "ekklesia.layer",
});
}
if (!validPackage || typeof name !== "string" || typeof version !== "string") {
return undefined;
}
return { name, version };
}
async function resolveEntrypoints(
moduleDirectory: string,
directoryName: string,
manifest: ModuleManifest,
issues: ModuleDiscoveryIssue[],
): Promise<ResolvedModuleEntrypoints | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
const canonicalModuleDirectory = await realpath(moduleDirectory);
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) {
if (relativeEntrypoint === undefined) {
continue;
}
const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint);
if (!pathIsInside(moduleDirectory, configuredPath)) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `${entrypointName} resolves outside its module directory.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
});
continue;
}
try {
const canonicalEntrypoint = await realpath(configuredPath);
if (!pathIsInside(canonicalModuleDirectory, canonicalEntrypoint)) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: `${entrypointName} resolves through a link outside its module directory.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
});
continue;
}
const entrypointStats = await stat(canonicalEntrypoint);
const hasExpectedKind =
entrypointName === "migrations" ? entrypointStats.isDirectory() : entrypointStats.isFile();
if (!hasExpectedKind) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message:
entrypointName === "migrations"
? "migrations must resolve to a directory."
: `${entrypointName} must resolve to a file.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
});
continue;
}
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
} catch (error) {
const code = errorCode(error);
issues.push({
code: code === "ENOENT" ? "MODULE_ENTRYPOINT_MISSING" : "MODULE_ENTRYPOINT_INVALID",
message:
code === "ENOENT"
? `${entrypointName} does not exist at ${relativeEntrypoint}.`
: `${entrypointName} could not be resolved at ${relativeEntrypoint}${
code ? ` (${code})` : ""
}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: `entrypoints.${entrypointName}`,
});
}
}
if (!resolved.server) {
return undefined;
}
return {
server: resolved.server,
...(resolved.schema ? { schema: resolved.schema } : {}),
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
};
}
async function scanModuleDirectory(
modulesDirectory: string,
directoryName: string,
issues: ModuleDiscoveryIssue[],
): Promise<ModuleCandidate | undefined> {
const moduleDirectory = path.join(modulesDirectory, directoryName);
const packagePath = path.join(moduleDirectory, "package.json");
const manifestPath = path.join(moduleDirectory, MODULE_MANIFEST_FILENAME);
const canonicalModuleDirectory = await realpath(moduleDirectory);
let canonicalManifestPath: string | undefined;
let packageInput: unknown;
let manifestInput: unknown;
let packageLoaded = false;
let manifestLoaded = false;
try {
const canonicalPackagePath = await realpath(packagePath);
if (!pathIsInside(canonicalModuleDirectory, canonicalPackagePath)) {
issues.push({
code: "MODULE_PACKAGE_INVALID",
message: "package.json must not resolve outside its module directory.",
moduleDirectory: directoryName,
});
} else {
packageInput = await readJson(canonicalPackagePath);
packageLoaded = true;
}
} catch (error) {
const code = errorCode(error);
issues.push({
code: code === "ENOENT" ? "MODULE_PACKAGE_MISSING" : "MODULE_PACKAGE_INVALID",
message:
code === "ENOENT"
? "package.json is missing."
: `package.json could not be read or parsed${code ? ` (${code})` : ""}.`,
moduleDirectory: directoryName,
});
}
try {
canonicalManifestPath = await realpath(manifestPath);
if (!pathIsInside(canonicalModuleDirectory, canonicalManifestPath)) {
issues.push({
code: "MODULE_MANIFEST_INVALID",
message: `${MODULE_MANIFEST_FILENAME} must not resolve outside its module directory.`,
moduleDirectory: directoryName,
});
} else {
manifestInput = await readJson(canonicalManifestPath);
manifestLoaded = true;
}
} catch (error) {
const code = errorCode(error);
issues.push({
code: code === "ENOENT" ? "MODULE_MANIFEST_MISSING" : "MODULE_MANIFEST_INVALID",
message:
code === "ENOENT"
? `${MODULE_MANIFEST_FILENAME} is missing.`
: `${MODULE_MANIFEST_FILENAME} could not be read or parsed${code ? ` (${code})` : ""}.`,
moduleDirectory: directoryName,
});
}
if (!packageLoaded || !manifestLoaded || !canonicalManifestPath) {
return undefined;
}
const packageData = validatePackageData(packageInput, directoryName, issues);
const parsedManifest = safeParseModuleManifest(manifestInput);
if (!parsedManifest.success) {
for (const validationIssue of parsedManifest.error.issues) {
issues.push({
code: "MODULE_MANIFEST_INVALID",
message: validationIssue.message,
moduleDirectory: directoryName,
path: issuePath(validationIssue.path),
});
}
}
if (!packageData || !parsedManifest.success) {
return undefined;
}
const manifest = parsedManifest.data;
if (packageData.version !== manifest.version) {
issues.push({
code: "MODULE_VERSION_MISMATCH",
message: `package.json version ${packageData.version} does not match manifest version ${manifest.version}.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "version",
});
}
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!entrypoints) {
return undefined;
}
return {
directory: canonicalModuleDirectory,
directoryName,
entrypoints,
manifest,
manifestPath: canonicalManifestPath,
packageName: packageData.name,
};
}
function addGraphEdge(
dependencyId: string,
dependentId: string,
dependents: Map<string, Set<string>>,
indegrees: Map<string, number>,
): void {
const targets = dependents.get(dependencyId);
if (!targets || targets.has(dependentId)) {
return;
}
targets.add(dependentId);
indegrees.set(dependentId, (indegrees.get(dependentId) ?? 0) + 1);
}
function nodeParticipatesInCycle(node: string, graph: ReadonlyMap<string, Set<string>>): boolean {
const visited = new Set<string>();
const pending = [...(graph.get(node) ?? [])];
while (pending.length > 0) {
const candidate = pending.pop();
if (!candidate) {
continue;
}
if (candidate === node) {
return true;
}
if (visited.has(candidate)) {
continue;
}
visited.add(candidate);
pending.push(...(graph.get(candidate) ?? []));
}
return false;
}
function topologicalModuleIds(
modulesById: ReadonlyMap<string, ModuleCandidate>,
dependents: Map<string, Set<string>>,
indegrees: Map<string, number>,
issues: ModuleDiscoveryIssue[],
): string[] {
const ready = [...modulesById.keys()]
.filter((moduleId) => indegrees.get(moduleId) === 0)
.sort(compareText);
const ordered: string[] = [];
while (ready.length > 0) {
const moduleId = ready.shift();
if (!moduleId) {
break;
}
ordered.push(moduleId);
for (const dependentId of [...(dependents.get(moduleId) ?? [])].sort(compareText)) {
const nextIndegree = (indegrees.get(dependentId) ?? 0) - 1;
indegrees.set(dependentId, nextIndegree);
if (nextIndegree === 0) {
ready.push(dependentId);
ready.sort(compareText);
}
}
}
if (ordered.length !== modulesById.size) {
const cycleMembers = [...modulesById.keys()]
.filter((moduleId) => nodeParticipatesInCycle(moduleId, dependents))
.sort(compareText);
issues.push({
code: "MODULE_DEPENDENCY_CYCLE",
message: `Module dependency cycle contains: ${cycleMembers.join(", ")}.`,
moduleDirectory: cycleMembers.join(","),
});
}
return ordered;
}
export async function discoverModules(
options: ModuleDiscoveryOptions,
): Promise<readonly DiscoveredModule[]> {
const issues: ModuleDiscoveryIssue[] = [];
for (const [versionName, version] of [
["platformVersion", options.platformVersion],
["moduleSdkVersion", options.moduleSdkVersion],
] as const) {
if (!semanticVersionSchema.safeParse(version).success) {
issues.push({
code: "DISCOVERY_CONFIGURATION_INVALID",
message: `${versionName} must be a strict semantic version, received ${version}.`,
moduleDirectory: ".",
path: versionName,
});
}
}
if (issues.length > 0) {
throw new ModuleDiscoveryError(issues);
}
let directoryEntries: Dirent[];
try {
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
} catch (error) {
if (errorCode(error) === "ENOENT") {
return Object.freeze([]);
}
throw new ModuleDiscoveryError([
{
code: "MODULES_DIRECTORY_UNREADABLE",
message: `The configured modules directory could not be read${
errorCode(error) ? ` (${errorCode(error)})` : ""
}.`,
moduleDirectory: ".",
},
]);
}
const visibleEntries = directoryEntries
.filter((entry) => !entry.name.startsWith("."))
.sort((left, right) => compareText(left.name, right.name));
const directoryNames: string[] = [];
for (const entry of visibleEntries) {
if (entry.isSymbolicLink()) {
issues.push({
code: "MODULE_DIRECTORY_INVALID",
message: "Module directories must not be symbolic links.",
moduleDirectory: entry.name,
});
} else if (entry.isDirectory()) {
directoryNames.push(entry.name);
}
}
const candidates: ModuleCandidate[] = [];
for (const directoryName of directoryNames) {
const candidate = await scanModuleDirectory(options.modulesDirectory, directoryName, issues);
if (candidate) {
candidates.push(candidate);
}
}
const duplicateIds = new Set<string>();
const duplicatePackageNames = new Set<string>();
const firstById = new Map<string, ModuleCandidate>();
const firstByPackageName = new Map<string, ModuleCandidate>();
for (const candidate of candidates) {
const existingId = firstById.get(candidate.manifest.id);
if (existingId) {
duplicateIds.add(candidate.manifest.id);
issues.push({
code: "MODULE_ID_DUPLICATE",
message: `Module ID ${candidate.manifest.id} is also declared by ${existingId.directoryName}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: "id",
});
} else {
firstById.set(candidate.manifest.id, candidate);
}
const existingPackage = firstByPackageName.get(candidate.packageName);
if (existingPackage) {
duplicatePackageNames.add(candidate.packageName);
issues.push({
code: "MODULE_PACKAGE_NAME_DUPLICATE",
message: `Package name ${candidate.packageName} is also used by ${existingPackage.directoryName}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: "name",
});
} else {
firstByPackageName.set(candidate.packageName, candidate);
}
}
const modulesById = new Map(
candidates
.filter(
(candidate) =>
!duplicateIds.has(candidate.manifest.id) &&
!duplicatePackageNames.has(candidate.packageName),
)
.map((candidate) => [candidate.manifest.id, candidate] as const),
);
const capabilityProviders = new Map<string, ModuleCandidate>();
for (const candidate of modulesById.values()) {
if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) {
issues.push({
code: "MODULE_PLATFORM_INCOMPATIBLE",
message: `Platform ${options.platformVersion} does not satisfy ${candidate.manifest.compatibility.platform}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: "compatibility.platform",
});
}
if (
!isVersionCompatible(options.moduleSdkVersion, candidate.manifest.compatibility.moduleSdk)
) {
issues.push({
code: "MODULE_SDK_INCOMPATIBLE",
message: `Module SDK ${options.moduleSdkVersion} does not satisfy ${candidate.manifest.compatibility.moduleSdk}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: "compatibility.moduleSdk",
});
}
for (const capability of candidate.manifest.capabilities) {
capabilityProviders.set(capability, candidate);
}
}
const dependents = new Map<string, Set<string>>();
const indegrees = new Map<string, number>();
for (const moduleId of modulesById.keys()) {
dependents.set(moduleId, new Set());
indegrees.set(moduleId, 0);
}
for (const candidate of modulesById.values()) {
for (const dependencyKind of ["required", "optional"] as const) {
for (const [dependencyId, versionRange] of Object.entries(
candidate.manifest.dependencies.modules[dependencyKind],
)) {
const dependency = modulesById.get(dependencyId);
if (!dependency) {
if (dependencyKind === "required") {
issues.push({
code: "MODULE_DEPENDENCY_MISSING",
message: `Required module ${dependencyId} is not installed.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: `dependencies.modules.${dependencyKind}.${dependencyId}`,
});
}
continue;
}
if (!isVersionCompatible(dependency.manifest.version, versionRange)) {
issues.push({
code: "MODULE_DEPENDENCY_INCOMPATIBLE",
message: `${dependencyId} ${dependency.manifest.version} does not satisfy ${versionRange}.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: `dependencies.modules.${dependencyKind}.${dependencyId}`,
});
}
addGraphEdge(dependencyId, candidate.manifest.id, dependents, indegrees);
}
}
for (const dependencyKind of ["required", "optional"] as const) {
for (const [index, capability] of candidate.manifest.dependencies.capabilities[
dependencyKind
].entries()) {
const provider = capabilityProviders.get(capability);
if (!provider) {
if (dependencyKind === "required") {
issues.push({
code: "MODULE_CAPABILITY_MISSING",
message: `Required capability ${capability} is not installed.`,
moduleDirectory: candidate.directoryName,
moduleId: candidate.manifest.id,
path: `dependencies.capabilities.${dependencyKind}.${index}`,
});
}
continue;
}
addGraphEdge(provider.manifest.id, candidate.manifest.id, dependents, indegrees);
}
}
}
const orderedIds = topologicalModuleIds(modulesById, dependents, indegrees, issues);
if (issues.length > 0) {
throw new ModuleDiscoveryError(issues);
}
return Object.freeze(
orderedIds.map((moduleId) => {
const candidate = modulesById.get(moduleId);
if (!candidate) {
throw new Error(`Internal discovery error: ${moduleId} was not indexed.`);
}
const { directoryName: _directoryName, ...discoveredModule } = candidate;
return freezeRecursively(discoveredModule);
}),
);
}
+1
View File
@@ -0,0 +1 @@
export * from "./discovery.js";
+429
View File
@@ -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);
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../packages/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}