feat: add module contracts and discovery
This commit is contained in:
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Verify repository
|
- name: Verify repository
|
||||||
run: pnpm ci
|
run: pnpm ci:verify
|
||||||
|
|
||||||
infrastructure-smoke:
|
infrastructure-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
|
|||||||
+5
-2
@@ -9,6 +9,9 @@ Before opening a change, run:
|
|||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused,
|
||||||
|
path-aware discovery errors. It is included in `pnpm verify`.
|
||||||
|
|
||||||
If Docker is available, also validate the development infrastructure:
|
If Docker is available, also validate the development infrastructure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -28,5 +31,5 @@ pnpm infrastructure:config
|
|||||||
- Add or update tests when changing a contract or architectural rule.
|
- Add or update tests when changing a contract or architectural rule.
|
||||||
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
- Record major architectural changes in an ADR rather than burying them in an implementation.
|
||||||
|
|
||||||
Generated registries and schemas will be produced by later phases. Do not add a manually
|
Runtime registries and aggregate schemas will be produced by later phases. Do not add a manually
|
||||||
maintained feature-module registry.
|
maintained feature-module registry; discovery starts from validated `ekklesia.module.json` files.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ surfaces.
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 1 establishes the repository foundation only. It includes reproducible workspace
|
Phase 2 is underway. The repository now includes public module contracts, the Module SDK,
|
||||||
tooling, strict TypeScript defaults, executable architectural boundary checks, tests, CI, and
|
side-effect-free module manifests, deterministic automatic discovery, compatibility and dependency
|
||||||
local PostgreSQL/S3-compatible infrastructure. Core runtime, module discovery, identity,
|
validation, and a cloneable backend-only module template. Runtime registration, Drizzle, identity,
|
||||||
Module SDK, surfaces, and feature modules intentionally begin in later phases.
|
surfaces, and feature modules remain later vertical slices.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
pnpm infrastructure:up
|
pnpm infrastructure:up
|
||||||
|
pnpm modules:validate
|
||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ environment.
|
|||||||
- [Platform architecture](docs/architecture/platform-architecture.md)
|
- [Platform architecture](docs/architecture/platform-architecture.md)
|
||||||
- [Repository boundaries](docs/architecture/repository-boundaries.md)
|
- [Repository boundaries](docs/architecture/repository-boundaries.md)
|
||||||
- [Repository workflow](docs/development/repository-workflow.md)
|
- [Repository workflow](docs/development/repository-workflow.md)
|
||||||
|
- [Module development](docs/module-development/README.md)
|
||||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||||
|
|
||||||
No open-source license has been selected yet. A license should be added only after the project
|
No open-source license has been selected yet. A license should be added only after the project
|
||||||
|
|||||||
+8
-1
@@ -1,7 +1,14 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"]
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/.turbo",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!tests/fixtures",
|
||||||
|
"!pnpm-lock.yaml"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./modules/index.js";
|
||||||
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./discovery.js";
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../packages/typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 0005: Static module manifests and atomic discovery
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-12
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ekklesia modules must be installable by placing a package in `modules/` without editing Core or a
|
||||||
|
central registry. Core must validate identity, compatibility, dependencies, capabilities, storage
|
||||||
|
requirements, and entrypoints before it runs module behavior. Importing an executable TypeScript
|
||||||
|
manifest would allow top-level module code to run before validation completed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Every module uses one inert, versioned `ekklesia.module.json` manifest. `@ekklesia/contracts` owns
|
||||||
|
the authoritative Zod schema and serializable types. `@ekklesia/module-sdk` re-exports those public
|
||||||
|
contracts and provides `defineModule` for typed programmatic validation without becoming a Core
|
||||||
|
service locator.
|
||||||
|
|
||||||
|
Core scans immediate non-hidden directories under the configured modules directory. Discovery
|
||||||
|
validates all manifests and package metadata, resolves entrypoints without loading them, checks
|
||||||
|
platform and Module SDK compatibility, resolves module and capability dependencies, and produces a
|
||||||
|
deterministic dependency-first order. Any error rejects the entire discovery result with sorted,
|
||||||
|
structured issues; no module server code is partially loaded.
|
||||||
|
|
||||||
|
Storage requirements are declarative. A module may request one or more logical buckets in its
|
||||||
|
manifest, but it cannot create provider clients or select physical bucket names. The later Core
|
||||||
|
storage service will provision deployment-prefixed physical buckets and give the owning module
|
||||||
|
scoped handles. Buckets remain private unless a future explicit storage contract says otherwise.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- Executable `module.ts` manifests were rejected because discovery would cross the code-execution
|
||||||
|
boundary before validation.
|
||||||
|
- Embedding the full manifest in `package.json` was rejected because it couples the platform
|
||||||
|
contract to package-manager metadata and makes the manifest harder to version independently.
|
||||||
|
- A manually maintained import registry was rejected because every module installation would
|
||||||
|
require a central source edit.
|
||||||
|
- Letting modules configure SeaweedFS or S3 clients directly was rejected because Core owns
|
||||||
|
credentials, provider selection, isolation, and lifecycle.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
JSON manifests do not provide TypeScript inference by themselves, so validation commands and clear
|
||||||
|
path-aware errors are part of the authoring workflow. A JSON Schema can be generated from the
|
||||||
|
authoritative contract later for editor completion.
|
||||||
|
|
||||||
|
Discovery currently proves entrypoint existence but does not import server behavior, execute
|
||||||
|
lifecycle hooks, provision requested buckets, or register routes, jobs, schemas, and migrations.
|
||||||
|
Those operations begin only after a complete valid discovery result in later runtime slices.
|
||||||
|
|
||||||
|
## Migration implications
|
||||||
|
|
||||||
|
The manifest includes `manifestVersion: 1`. Future incompatible shapes require an explicit schema
|
||||||
|
version and migration path rather than silently changing existing module interpretation.
|
||||||
@@ -13,3 +13,4 @@ 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
|
||||||
|
|||||||
@@ -56,11 +56,14 @@ surfaces.
|
|||||||
- Private objects use signed access and namespaced storage paths.
|
- Private objects use signed access and namespaced storage paths.
|
||||||
- Every network service starts from least-privilege and local-only development defaults.
|
- Every network service starts from least-privilege and local-only development defaults.
|
||||||
|
|
||||||
## Phase 1 status
|
## Current implementation status
|
||||||
|
|
||||||
Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation,
|
Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts,
|
||||||
tests, base CI, PostgreSQL, and a development S3-compatible object store.
|
the Module SDK manifest API, static versioned module manifests, automatic filesystem discovery,
|
||||||
|
compatibility/dependency ordering, structured discovery failures, and a backend-only module
|
||||||
|
template. Core can validate zero or more modules without importing their server behavior.
|
||||||
|
|
||||||
Target architecture, not yet implemented: Core runtime, module discovery and lifecycle, Drizzle
|
Target architecture, not yet implemented: lifecycle execution and runtime registration, Drizzle
|
||||||
schema aggregation/migrations, Better Auth, permissions, storage service, Module SDK, Surface
|
schema aggregation/migrations, Better Auth, permissions, physical storage-bucket provisioning,
|
||||||
SDK, capability/resource/action registries, API server, dashboard, Builder, and reference modules.
|
Surface SDK, capability/resource/action registries, API server, dashboard, Builder, and reference
|
||||||
|
feature modules.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pnpm install --frozen-lockfile
|
|||||||
pnpm format:check # formatting without mutation
|
pnpm format:check # formatting without mutation
|
||||||
pnpm lint # Biome recommended rules
|
pnpm lint # Biome recommended rules
|
||||||
pnpm boundaries # architectural directions, UI ownership, and cycles
|
pnpm boundaries # architectural directions, UI ownership, and cycles
|
||||||
|
pnpm modules:validate # installed module manifests, compatibility, and dependency graph
|
||||||
pnpm typecheck # strict TypeScript
|
pnpm typecheck # strict TypeScript
|
||||||
pnpm test # Vitest
|
pnpm test # Vitest
|
||||||
pnpm build # Turborepo package build graph
|
pnpm build # Turborepo package build graph
|
||||||
@@ -28,8 +29,12 @@ 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.
|
||||||
|
|
||||||
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. A separate CI job
|
`pnpm modules:validate` scans `modules/*` without a central registry. A missing or empty modules
|
||||||
runs the live infrastructure smoke test so code-only local verification does not require Docker.
|
directory is valid and reports zero installed modules.
|
||||||
|
|
||||||
|
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
|
||||||
|
Docker. The explicit suffix avoids pnpm's built-in `ci` install command.
|
||||||
|
|
||||||
## Development infrastructure
|
## Development infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Module development
|
||||||
|
|
||||||
|
An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
|
||||||
|
it never owns components, styles, layouts, renderers, navigation, or other UI.
|
||||||
|
|
||||||
|
## Required package shape
|
||||||
|
|
||||||
|
Each immediate child of `modules/` must contain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
modules/example/
|
||||||
|
├── ekklesia.module.json
|
||||||
|
├── package.json
|
||||||
|
└── src/server/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` must use ESM, a strict semantic version, a unique package name, and:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Its version must match the module manifest version. Internal package dependencies use `workspace:`
|
||||||
|
ranges and supported package entrypoints.
|
||||||
|
|
||||||
|
## Manifest
|
||||||
|
|
||||||
|
The canonical manifest is inert JSON so discovery can validate a module without executing it:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manifestVersion": 1,
|
||||||
|
"id": "calendar",
|
||||||
|
"name": "Calendar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"compatibility": {
|
||||||
|
"platform": "^0.1.0",
|
||||||
|
"moduleSdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"modules": {
|
||||||
|
"required": {
|
||||||
|
"people": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"optional": ["media.items"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"capabilities": ["calendar.events"],
|
||||||
|
"permissions": ["calendar.events.read"],
|
||||||
|
"storage": {
|
||||||
|
"buckets": [
|
||||||
|
{
|
||||||
|
"id": "assets",
|
||||||
|
"description": "Private event assets owned by Calendar."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entrypoints": {
|
||||||
|
"server": "./src/server/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Module, capability, and permission identifiers are lowercase and namespaced. Entrypoints must begin
|
||||||
|
with `./`, stay inside the package, and exist. 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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Discovery behavior
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm modules:validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery checks every installed module atomically. 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 or escaping entrypoints.
|
||||||
|
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
|
||||||
|
identifier sorting.
|
||||||
|
|
||||||
|
Discovery does not load server code. Lifecycle execution, route/job registration, Drizzle schema
|
||||||
|
aggregation and migrations, and physical bucket provisioning are deliberately deferred.
|
||||||
|
|
||||||
|
## Start from the template
|
||||||
|
|
||||||
|
Copy `templates/module` to `modules/<your-module-id>` and follow its README. The template is part of
|
||||||
|
the workspace and is validated by the same contracts and discovery implementation as installed
|
||||||
|
modules.
|
||||||
+3
-2
@@ -12,7 +12,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"boundaries": "node scripts/check-boundaries.ts",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"ci": "pnpm verify && pnpm infrastructure:config",
|
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
||||||
@@ -22,12 +22,13 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
"typecheck": "turbo run typecheck typecheck:root",
|
"typecheck": "turbo run typecheck typecheck:root",
|
||||||
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
||||||
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
|
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm modules:validate && pnpm build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"zod": "4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const localIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||||
|
const namespacedIdentifierPattern =
|
||||||
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u;
|
||||||
|
|
||||||
|
export const moduleIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(localIdentifierPattern, "Must be a lowercase kebab-case module identifier.");
|
||||||
|
|
||||||
|
export const capabilityIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(160)
|
||||||
|
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
|
||||||
|
|
||||||
|
export const permissionIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(3)
|
||||||
|
.max(160)
|
||||||
|
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.");
|
||||||
|
|
||||||
|
export const logicalBucketIdSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(48)
|
||||||
|
.regex(localIdentifierPattern, "Must be a lowercase kebab-case logical bucket identifier.");
|
||||||
|
|
||||||
|
export const packageRelativePathSchema = z.string().superRefine((value, context) => {
|
||||||
|
if (!value.startsWith("./")) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must begin with ./ and remain inside the module package.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes("\\") || value.includes("\0") || value.includes("?") || value.includes("#")) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a portable package-relative path without URLs, queries, or fragments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = value.slice(2).split("/");
|
||||||
|
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must not contain empty, current-directory, or parent-directory segments.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModuleId = z.infer<typeof moduleIdSchema>;
|
||||||
|
export type CapabilityId = z.infer<typeof capabilityIdSchema>;
|
||||||
|
export type PermissionId = z.infer<typeof permissionIdSchema>;
|
||||||
|
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
|
||||||
|
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./identifiers.js";
|
||||||
|
export * from "./module-lifecycle.js";
|
||||||
|
export * from "./module-manifest.js";
|
||||||
|
export * from "./versions.js";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const MODULE_LIFECYCLE_STAGES = [
|
||||||
|
"discovered",
|
||||||
|
"validated",
|
||||||
|
"configured",
|
||||||
|
"registered",
|
||||||
|
"migrated",
|
||||||
|
"initialized",
|
||||||
|
"started",
|
||||||
|
"stopped",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const moduleLifecycleStageSchema = z.enum(MODULE_LIFECYCLE_STAGES);
|
||||||
|
|
||||||
|
export type ModuleLifecycleStage = z.infer<typeof moduleLifecycleStageSchema>;
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
capabilityIdSchema,
|
||||||
|
logicalBucketIdSchema,
|
||||||
|
moduleIdSchema,
|
||||||
|
packageRelativePathSchema,
|
||||||
|
permissionIdSchema,
|
||||||
|
} from "./identifiers.js";
|
||||||
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
|
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||||
|
|
||||||
|
const moduleDependenciesSchema = z
|
||||||
|
.object({
|
||||||
|
required: moduleVersionMapSchema.default(() => ({})),
|
||||||
|
optional: moduleVersionMapSchema.default(() => ({})),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default(() => ({ required: {}, optional: {} }));
|
||||||
|
|
||||||
|
const capabilityDependenciesSchema = z
|
||||||
|
.object({
|
||||||
|
required: z.array(capabilityIdSchema).default(() => []),
|
||||||
|
optional: z.array(capabilityIdSchema).default(() => []),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default(() => ({ required: [], optional: [] }));
|
||||||
|
|
||||||
|
const dependenciesSchema = z
|
||||||
|
.object({
|
||||||
|
modules: moduleDependenciesSchema,
|
||||||
|
capabilities: capabilityDependenciesSchema,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default(() => ({
|
||||||
|
modules: { required: {}, optional: {} },
|
||||||
|
capabilities: { required: [], optional: [] },
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const moduleBucketDeclarationSchema = z
|
||||||
|
.object({
|
||||||
|
id: logicalBucketIdSchema,
|
||||||
|
description: z.string().trim().min(1).max(240).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const moduleStorageSchema = z
|
||||||
|
.object({
|
||||||
|
buckets: z.array(moduleBucketDeclarationSchema).default(() => []),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default(() => ({ buckets: [] }));
|
||||||
|
|
||||||
|
const entrypointsSchema = z
|
||||||
|
.object({
|
||||||
|
server: packageRelativePathSchema,
|
||||||
|
schema: packageRelativePathSchema.optional(),
|
||||||
|
migrations: packageRelativePathSchema.optional(),
|
||||||
|
jobs: packageRelativePathSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
function reportDuplicates(
|
||||||
|
values: readonly string[],
|
||||||
|
path: readonly (string | number)[],
|
||||||
|
context: z.RefinementCtx,
|
||||||
|
): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `${value} is declared more than once.`,
|
||||||
|
path: [...path, index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moduleManifestSchema = z
|
||||||
|
.object({
|
||||||
|
manifestVersion: z.literal(1),
|
||||||
|
id: moduleIdSchema,
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
compatibility: z
|
||||||
|
.object({
|
||||||
|
platform: semanticVersionRangeSchema,
|
||||||
|
moduleSdk: semanticVersionRangeSchema,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
dependencies: dependenciesSchema,
|
||||||
|
capabilities: z.array(capabilityIdSchema).default(() => []),
|
||||||
|
permissions: z.array(permissionIdSchema).default(() => []),
|
||||||
|
storage: moduleStorageSchema,
|
||||||
|
entrypoints: entrypointsSchema,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((manifest, context) => {
|
||||||
|
reportDuplicates(manifest.capabilities, ["capabilities"], 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(
|
||||||
|
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
|
["storage", "buckets"],
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
manifest.capabilities.forEach((capability, index) => {
|
||||||
|
if (!capability.startsWith(`${manifest.id}.`)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
||||||
|
path: ["capabilities", index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manifest.permissions.forEach((permission, index) => {
|
||||||
|
if (!permission.startsWith(`${manifest.id}.`)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||||
|
path: ["permissions", index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const dependencyKind of ["required", "optional"] as const) {
|
||||||
|
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A module cannot depend on itself.",
|
||||||
|
path: ["dependencies", "modules", dependencyKind, manifest.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) {
|
||||||
|
if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `${dependencyId} cannot be both a required and optional module dependency.`,
|
||||||
|
path: ["dependencies", "modules", "optional", dependencyId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `${capability} cannot be both a required and optional capability dependency.`,
|
||||||
|
path: ["dependencies", "capabilities", "optional", index],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||||
|
return moduleManifestSchema.parse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParseModuleManifest(input: unknown) {
|
||||||
|
return moduleManifestSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||||
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { satisfies, valid, validRange } from "semver";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const semanticVersionSchema = z.string().refine((value) => valid(value) === value, {
|
||||||
|
message: "Must be a strict semantic version.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const semanticVersionRangeSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine((value) => validRange(value) !== null, {
|
||||||
|
message: "Must be a valid semantic version range.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isVersionCompatible(version: string, range: string): boolean {
|
||||||
|
return valid(version) === version && validRange(range) !== null && satisfies(version, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SemanticVersion = z.infer<typeof semanticVersionSchema>;
|
||||||
|
export type SemanticVersionRange = z.infer<typeof semanticVersionRangeSchema>;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
MODULE_LIFECYCLE_STAGES,
|
||||||
|
packageRelativePathSchema,
|
||||||
|
parseModuleManifest,
|
||||||
|
safeParseModuleManifest,
|
||||||
|
} from "../src/index.js";
|
||||||
|
|
||||||
|
function validManifest(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
manifestVersion: 1,
|
||||||
|
id: "calendar",
|
||||||
|
name: "Calendar",
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: {
|
||||||
|
platform: "^0.1.0",
|
||||||
|
moduleSdk: "^0.1.0",
|
||||||
|
},
|
||||||
|
capabilities: ["calendar.events"],
|
||||||
|
permissions: ["calendar.events.read"],
|
||||||
|
storage: {
|
||||||
|
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||||
|
},
|
||||||
|
entrypoints: {
|
||||||
|
server: "./src/server/index.ts",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("module manifest contract", () => {
|
||||||
|
it("normalizes a valid manifest with safe defaults", () => {
|
||||||
|
const manifest = parseModuleManifest(validManifest());
|
||||||
|
|
||||||
|
expect(manifest.id).toBe("calendar");
|
||||||
|
expect(manifest.dependencies).toEqual({
|
||||||
|
capabilities: { optional: [], required: [] },
|
||||||
|
modules: { optional: {}, required: {} },
|
||||||
|
});
|
||||||
|
expect(manifest.storage.buckets).toEqual([
|
||||||
|
{ id: "assets", description: "Calendar-owned event assets." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates isolated default collections for every parsed manifest", () => {
|
||||||
|
const input = validManifest();
|
||||||
|
delete input.storage;
|
||||||
|
const first = parseModuleManifest(input);
|
||||||
|
const second = parseModuleManifest(input);
|
||||||
|
|
||||||
|
first.dependencies.modules.required.people = "^0.1.0";
|
||||||
|
first.dependencies.capabilities.required.push("people.records");
|
||||||
|
first.storage.buckets.push({ id: "exports" });
|
||||||
|
|
||||||
|
expect(second.dependencies.modules.required).toEqual({});
|
||||||
|
expect(second.dependencies.capabilities.required).toEqual([]);
|
||||||
|
expect(second.storage.buckets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||||
|
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
|
||||||
|
const result = safeParseModuleManifest(manifest);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires module-owned capability and permission namespaces", () => {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
capabilities: ["media.items"],
|
||||||
|
permissions: ["media.items.read"],
|
||||||
|
};
|
||||||
|
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", "permissions.0"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate declarations and dependency overlap", () => {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
dependencies: {
|
||||||
|
modules: {
|
||||||
|
required: { people: "^1.0.0" },
|
||||||
|
optional: { people: "^1.0.0" },
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
required: ["people.records"],
|
||||||
|
optional: ["people.records"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
capabilities: ["calendar.events", "calendar.events"],
|
||||||
|
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
|
||||||
|
};
|
||||||
|
const result = safeParseModuleManifest(manifest);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues).toHaveLength(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects module and capability self-dependencies", () => {
|
||||||
|
const manifest = {
|
||||||
|
...validManifest(),
|
||||||
|
dependencies: {
|
||||||
|
modules: { required: { calendar: "^0.1.0" } },
|
||||||
|
capabilities: { required: ["calendar.events"] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"../outside.ts",
|
||||||
|
"/absolute.ts",
|
||||||
|
"file:///server.ts",
|
||||||
|
"./src/../server.ts",
|
||||||
|
"./src\\server.ts",
|
||||||
|
"./src/server.ts?raw",
|
||||||
|
])("rejects unsafe package entrypoint %s", (entrypoint) => {
|
||||||
|
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows package directories whose names merely begin with two dots", () => {
|
||||||
|
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses full semantic-version compatibility ranges", () => {
|
||||||
|
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
|
||||||
|
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
|
||||||
|
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes the complete lifecycle vocabulary", () => {
|
||||||
|
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
||||||
|
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-sdk",
|
||||||
|
"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": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {
|
||||||
|
type ModuleManifest,
|
||||||
|
type ModuleManifestInput,
|
||||||
|
parseModuleManifest,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export function defineModule(manifest: ModuleManifestInput): ModuleManifest {
|
||||||
|
return parseModuleManifest(manifest);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "@ekklesia/contracts";
|
||||||
|
export * from "./define-module.js";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
Generated
+102
@@ -27,8 +27,93 @@ importers:
|
|||||||
specifier: 2.9.0
|
specifier: 2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
|
||||||
|
core:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/contracts
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]))
|
||||||
|
|
||||||
|
packages/contracts:
|
||||||
|
dependencies:
|
||||||
|
semver:
|
||||||
|
specifier: 7.8.5
|
||||||
|
version: 7.8.5
|
||||||
|
zod:
|
||||||
|
specifier: 4.4.3
|
||||||
|
version: 4.4.3
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
'@types/semver':
|
||||||
|
specifier: 7.7.1
|
||||||
|
version: 7.7.1
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]))
|
||||||
|
|
||||||
|
packages/module-sdk:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../contracts
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]))
|
||||||
|
|
||||||
packages/typescript-config: {}
|
packages/typescript-config: {}
|
||||||
|
|
||||||
|
templates/module:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/module-sdk':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/module-sdk
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@ekklesia/typescript-config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/typescript-config
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected]))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@biomejs/[email protected]':
|
'@biomejs/[email protected]':
|
||||||
@@ -255,6 +340,9 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
@@ -548,6 +636,11 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -685,6 +778,9 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@biomejs/[email protected]':
|
'@biomejs/[email protected]':
|
||||||
@@ -838,6 +934,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
'@typescript/[email protected]':
|
'@typescript/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1052,6 +1150,8 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.1.5
|
'@rolldown/binding-win32-arm64-msvc': 1.1.5
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.1.5
|
'@rolldown/binding-win32-x64-msvc': 1.1.5
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -1153,3 +1253,5 @@ snapshots:
|
|||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Ekklesia module template
|
||||||
|
|
||||||
|
This is the smallest supported backend-only Ekklesia module. It proves the manifest, package,
|
||||||
|
Module SDK, discovery, capability, permission, and logical storage-bucket contracts without
|
||||||
|
pretending that the later runtime, API, or Drizzle registration layers already exist.
|
||||||
|
|
||||||
|
To create a module inside this repository:
|
||||||
|
|
||||||
|
1. Copy this directory to `modules/<module-id>`.
|
||||||
|
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
|
||||||
|
`ekklesia.module.json`.
|
||||||
|
4. Replace the example server entrypoint with backend behavior only.
|
||||||
|
5. Run `pnpm install`, `pnpm modules:validate`, and `pnpm verify` from the repository root.
|
||||||
|
|
||||||
|
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"manifestVersion": 1,
|
||||||
|
"id": "example",
|
||||||
|
"name": "Example Module",
|
||||||
|
"description": "A minimal backend-only Ekklesia module template.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"compatibility": {
|
||||||
|
"platform": "^0.1.0",
|
||||||
|
"moduleSdk": "^0.1.0"
|
||||||
|
},
|
||||||
|
"capabilities": ["example.records"],
|
||||||
|
"permissions": ["example.records.read"],
|
||||||
|
"storage": {
|
||||||
|
"buckets": [
|
||||||
|
{
|
||||||
|
"id": "records",
|
||||||
|
"description": "Private objects owned by this example module."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entrypoints": {
|
||||||
|
"server": "./src/server/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"src",
|
||||||
|
"ekklesia.module.json"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/module-sdk": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ExampleModuleServer {
|
||||||
|
readonly id: "example";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exampleModuleServer: ExampleModuleServer = {
|
||||||
|
id: "example",
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { defineModule, type ModuleManifestInput } from "@ekklesia/module-sdk";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("module template", () => {
|
||||||
|
it("ships a valid backend-only manifest", async () => {
|
||||||
|
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
|
||||||
|
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as ModuleManifestInput;
|
||||||
|
const manifest = defineModule(manifestInput);
|
||||||
|
|
||||||
|
expect(manifest.id).toBe("example");
|
||||||
|
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ describe("development infrastructure", () => {
|
|||||||
|
|
||||||
expect(workflow.jobs?.verify).toBeDefined();
|
expect(workflow.jobs?.verify).toBeDefined();
|
||||||
expect(commands).toContain("pnpm install --frozen-lockfile");
|
expect(commands).toContain("pnpm install --frozen-lockfile");
|
||||||
expect(commands).toContain("pnpm ci");
|
expect(commands).toContain("pnpm ci:verify");
|
||||||
expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh");
|
expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +84,14 @@ describe("development infrastructure", () => {
|
|||||||
const workspace = parse(source) as WorkspaceDocument;
|
const workspace = parse(source) as WorkspaceDocument;
|
||||||
|
|
||||||
expect(workspace.packages).toEqual(
|
expect(workspace.packages).toEqual(
|
||||||
expect.arrayContaining(["apps/*", "builders/*", "core", "modules/*", "surfaces/*"]),
|
expect.arrayContaining([
|
||||||
|
"apps/*",
|
||||||
|
"builders/*",
|
||||||
|
"core",
|
||||||
|
"modules/*",
|
||||||
|
"surfaces/*",
|
||||||
|
"templates/*",
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,6 +104,7 @@ 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?.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");
|
||||||
expect(turboDocument.tasks).toHaveProperty("//#typecheck:root");
|
expect(turboDocument.tasks).toHaveProperty("//#typecheck:root");
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^typecheck"],
|
"dependsOn": ["^build", "^typecheck"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"//#lint:root": {
|
"//#lint:root": {
|
||||||
|
|||||||
Reference in New Issue
Block a user