feat: add module schema discovery and validation
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-13 21:35:21 -05:00
parent 2465d9c1f7
commit 80b56b72b1
31 changed files with 1584 additions and 34 deletions
+5 -4
View File
@@ -6,11 +6,12 @@ surfaces.
## Current status
Phase 2 is underway. The repository now includes public module contracts, independently versioned
Phase 3 is underway. The repository now includes public module contracts, independently versioned
capabilities, static module manifests, deterministic automatic discovery, compiled server loading,
a staged capability registry, dependency-ordered lifecycle with rollback, and a cloneable
backend-only module template. Drizzle, identity, HTTP routing, jobs, physical storage provisioning,
surfaces, and feature modules remain later vertical slices.
a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table
contracts, deterministic schema aggregation, and a cloneable backend-only module template. Migration
execution, identity, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules
remain later vertical slices.
## Quick start
+2 -1
View File
@@ -23,7 +23,8 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
"@ekklesia/contracts": "workspace:*",
"drizzle-orm": "0.45.2"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
+16 -3
View File
@@ -1,6 +1,10 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { discoverModules, ModuleDiscoveryError } from "../modules/index.js";
import {
discoverModuleSchemas,
ModuleDiscoveryError,
ModuleSchemaDiscoveryError,
} from "../modules/index.js";
interface PackageVersion {
version?: unknown;
@@ -20,18 +24,21 @@ async function main(): Promise<void> {
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const modules = await discoverModules({
const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
const modules = schemaAggregate.discoveredModules;
if (modules.length === 0) {
console.log("Module validation passed (0 modules installed).");
return;
}
console.log(`Module validation passed (${modules.length} modules):`);
console.log(
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
);
for (const discoveredModule of modules) {
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
}
@@ -44,6 +51,12 @@ main().catch((error: unknown) => {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
} else if (error instanceof ModuleSchemaDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
}
} else {
console.error(error instanceof Error ? error.message : String(error));
}
+3 -3
View File
@@ -238,15 +238,15 @@ async function resolveEntrypoints(
continue;
}
if (
entrypointName === "server" &&
["schema", "server"].includes(entrypointName) &&
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
) {
issues.push({
code: "MODULE_ENTRYPOINT_INVALID",
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "entrypoints.server",
path: `entrypoints.${entrypointName}`,
});
continue;
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./discovery.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
+480
View File
@@ -0,0 +1,480 @@
import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import { is } from "drizzle-orm";
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
import {
type DiscoveredModule,
discoverModules,
type ModuleDiscoveryOptions,
} from "./discovery.js";
export type ModuleSchemaDiscoveryIssueCode =
| "MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY"
| "MODULE_SCHEMA_EXPORT_INVALID"
| "MODULE_SCHEMA_FOREIGN_KEY_INVALID"
| "MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING"
| "MODULE_SCHEMA_IMPORT_FAILED"
| "MODULE_SCHEMA_MODULE_ID_MISMATCH"
| "MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED"
| "MODULE_SCHEMA_TABLE_DUPLICATE"
| "MODULE_SCHEMA_TABLE_INVALID"
| "MODULE_SCHEMA_TABLE_NAME_INVALID"
| "MODULE_SCHEMA_TABLE_NAMESPACE_INVALID";
export interface ModuleSchemaDiscoveryIssue {
readonly code: ModuleSchemaDiscoveryIssueCode;
readonly message: string;
readonly moduleId: ModuleId;
readonly path?: string;
readonly tableName?: string;
}
export interface DiscoveredModuleTable {
readonly exportName: string;
readonly moduleId: ModuleId;
readonly name: string;
readonly table: AnyPgTable;
}
export interface DiscoveredModuleSchema {
readonly moduleId: ModuleId;
readonly tables: readonly DiscoveredModuleTable[];
}
export interface ModuleSchemaAggregate {
readonly discoveredModules: readonly DiscoveredModule[];
readonly drizzleSchema: Readonly<Record<string, AnyPgTable>>;
readonly modules: readonly DiscoveredModuleSchema[];
readonly tables: readonly DiscoveredModuleTable[];
}
interface LoadedModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
}
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface ModuleTableCandidate extends DiscoveredModuleTable {
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
}
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error {
readonly issues: readonly ModuleSchemaDiscoveryIssue[];
constructor(issues: readonly ModuleSchemaDiscoveryIssue[]) {
const sortedIssues = [...issues].sort(compareIssues);
super(`Module schema discovery failed with ${sortedIssues.length} issue(s).`);
this.name = "ModuleSchemaDiscoveryError";
this.stack = `${this.name}: ${this.message}`;
this.issues = Object.freeze(sortedIssues.map((issue) => Object.freeze({ ...issue })));
}
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function compareIssues(
left: ModuleSchemaDiscoveryIssue,
right: ModuleSchemaDiscoveryIssue,
): number {
return (
compareText(left.moduleId, right.moduleId) ||
compareText(left.tableName ?? "", right.tableName ?? "") ||
compareText(left.path ?? "", right.path ?? "") ||
compareText(left.code, right.code) ||
compareText(left.message, right.message)
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUnaliasedPgTable(value: unknown): value is AnyPgTable {
try {
return (
is(value, PgTable) &&
(value as unknown as Record<PropertyKey, unknown>)[drizzleTableIsAlias] === false
);
} catch {
return false;
}
}
function defaultExport(moduleNamespace: unknown): unknown {
return isRecord(moduleNamespace) ? moduleNamespace.default : undefined;
}
function issue(
code: ModuleSchemaDiscoveryIssueCode,
moduleId: ModuleId,
message: string,
details: Pick<ModuleSchemaDiscoveryIssue, "path" | "tableName"> = {},
): ModuleSchemaDiscoveryIssue {
return { code, message, moduleId, ...details };
}
async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModule[]): Promise<{
readonly issues: readonly ModuleSchemaDiscoveryIssue[];
readonly loaded: readonly LoadedModuleSchema[];
}> {
const issues: ModuleSchemaDiscoveryIssue[] = [];
const loaded: LoadedModuleSchema[] = [];
for (const discovered of discoveredModules) {
const schemaEntrypoint = discovered.entrypoints.schema;
if (!schemaEntrypoint) {
continue;
}
let moduleNamespace: unknown;
try {
moduleNamespace = (await import(pathToFileURL(schemaEntrypoint).href)) as unknown;
} catch {
issues.push(
issue(
"MODULE_SCHEMA_IMPORT_FAILED",
discovered.manifest.id,
`Module ${discovered.manifest.id} schema entrypoint could not be imported.`,
),
);
continue;
}
let definition: ModuleSchemaDefinition;
try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch {
issues.push(
issue(
"MODULE_SCHEMA_EXPORT_INVALID",
discovered.manifest.id,
`Module ${discovered.manifest.id} schema entrypoint has an invalid default export.`,
),
);
continue;
}
if (definition.moduleId !== discovered.manifest.id) {
issues.push(
issue(
"MODULE_SCHEMA_MODULE_ID_MISMATCH",
discovered.manifest.id,
`Module ${discovered.manifest.id} schema definition does not match its manifest ID.`,
{ path: "moduleId" },
),
);
continue;
}
loaded.push({ definition, discovered });
}
return { issues, loaded };
}
function inspectTables(
loadedSchemas: readonly LoadedModuleSchema[],
issues: ModuleSchemaDiscoveryIssue[],
): readonly InspectedModuleSchema[] {
const inspectedModules: InspectedModuleSchema[] = [];
const firstTableByName = new Map<string, ModuleTableCandidate>();
for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id;
const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
compareText(left, right),
);
entries.forEach(([exportName, table], index) => {
const path = `tables.${index}`;
if (!isUnaliasedPgTable(table)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_INVALID",
moduleId,
`Module ${moduleId} schema contains a non-PostgreSQL table.`,
{ path },
),
);
return;
}
if (seenTables.has(table)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Module ${moduleId} schema exports the same table more than once.`,
{ path },
),
);
return;
}
seenTables.add(table);
let config: ReturnType<typeof getTableConfig>;
try {
config = getTableConfig(table);
} catch {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_INVALID",
moduleId,
`Module ${moduleId} table metadata could not be inspected.`,
{ path },
),
);
return;
}
if (config.schema !== undefined && config.schema !== "public") {
issues.push(
issue(
"MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED",
moduleId,
`Module ${moduleId} tables must use the platform-managed public PostgreSQL schema.`,
{ path },
),
);
return;
}
if (!physicalModuleTableNameSchema.safeParse(config.name).success) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid PostgreSQL table name.`,
{ path },
),
);
return;
}
const tablePrefix = moduleTablePrefix(moduleId);
if (!config.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${config.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: config.name },
),
);
return;
}
if (!moduleTableLocalNameSchema.safeParse(config.name.slice(tablePrefix.length)).success) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
return;
}
const candidate: ModuleTableCandidate = {
exportName,
foreignKeys: config.foreignKeys,
moduleId,
name: config.name,
path,
table,
};
const existing = firstTableByName.get(config.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${config.name} is declared more than once.`,
{ path, tableName: config.name },
),
);
} else {
firstTableByName.set(config.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
}
return inspectedModules;
}
function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate,
): AnyPgTable | undefined {
try {
const reference = foreignKey.reference();
const foreignTable = reference.foreignTable;
if (
foreignKey.table !== candidate.table ||
!Array.isArray(reference.columns) ||
!Array.isArray(reference.foreignColumns) ||
reference.columns.length === 0 ||
reference.columns.length !== reference.foreignColumns.length ||
!isUnaliasedPgTable(foreignTable) ||
!reference.columns.every(
(column) => is(column, PgColumn) && column.table === candidate.table,
) ||
!reference.foreignColumns.every(
(column) => is(column, PgColumn) && column.table === foreignTable,
)
) {
return undefined;
}
return foreignTable;
} catch {
return undefined;
}
}
function inspectForeignKeys(
inspectedModules: readonly InspectedModuleSchema[],
issues: ModuleSchemaDiscoveryIssue[],
): void {
const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) {
ownerByTable.set(candidate.table, candidate.moduleId);
}
}
for (const inspected of inspectedModules) {
const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) {
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
if (!foreignTable) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_INVALID",
moduleId,
`Table ${candidate.name} has a foreign key that could not be inspected.`,
{ path, tableName: candidate.name },
),
);
return;
}
if (ownedTables.has(foreignTable)) {
return;
}
const knownOwner = ownerByTable.get(foreignTable);
if (knownOwner && knownOwner !== moduleId) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
return;
}
try {
const targetName = getTableConfig(foreignTable).name;
if (
physicalModuleTableNameSchema.safeParse(targetName).success &&
!targetName.startsWith(moduleTablePrefix(moduleId))
) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
return;
}
} catch {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_INVALID",
moduleId,
`Table ${candidate.name} has a foreign key that could not be inspected.`,
{ path, tableName: candidate.name },
),
);
return;
}
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
});
}
}
}
function publishAggregate(
discoveredModules: readonly DiscoveredModule[],
inspectedModules: readonly InspectedModuleSchema[],
): ModuleSchemaAggregate {
const modules = Object.freeze(
inspectedModules.map((inspected) =>
Object.freeze({
moduleId: inspected.discovered.manifest.id,
tables: Object.freeze(
inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
.sort((left, right) => compareText(left.exportName, right.exportName)),
),
}),
),
);
const tables = Object.freeze(
modules
.flatMap((module) => module.tables)
.sort((left, right) => compareText(left.name, right.name)),
);
const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
);
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
}
export async function discoverModuleSchemas(
options: ModuleDiscoveryOptions,
): Promise<ModuleSchemaAggregate> {
const discoveredModules = await discoverModules(options);
const loaded = await loadSchemaDefinitions(discoveredModules);
const issues = [...loaded.issues];
const inspectedModules = inspectTables(loaded.loaded, issues);
inspectForeignKeys(inspectedModules, issues);
if (issues.length > 0) {
throw new ModuleSchemaDiscoveryError(issues);
}
return publishAggregate(discoveredModules, inspectedModules);
}
+88 -4
View File
@@ -397,12 +397,17 @@ describe("module discovery", () => {
await createModule(modulesDirectory, "missing-server", {
manifest: {
...manifestFor("missing-server"),
entrypoints: { server: "./dist/server/missing.js" },
entrypoints: {
server: "./dist/server/missing.js",
schema: "./dist/schema/missing.js",
},
},
});
let error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_MISSING")).toHaveLength(
2,
);
error = await discoveryError(modulesDirectory, { platformVersion: "invalid" });
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID");
@@ -427,21 +432,100 @@ describe("module discovery", () => {
);
});
it.each([
"./src/schema/index.ts",
"./dist/schema/index.cjs",
])("rejects non-ESM schema entrypoint %s", async (schemaEntrypoint) => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "schema-source", {
manifest: {
...manifestFor("schema-source"),
entrypoints: {
server: "./dist/server/index.js",
schema: schemaEntrypoint,
},
},
});
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
message: "schema must reference compiled ESM JavaScript with a .js or .mjs extension.",
path: "entrypoints.schema",
}),
);
});
it("accepts an optional compiled ESM schema entrypoint", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "compiled-schema", {
manifest: {
...manifestFor("compiled-schema"),
entrypoints: {
server: "./dist/server/index.js",
schema: "./dist/schema/index.mjs",
},
},
});
const schemaDirectory = path.join(modulesDirectory, "compiled-schema", "dist/schema");
await mkdir(schemaDirectory, { recursive: true });
await writeFile(path.join(schemaDirectory, "index.mjs"), "export default {};\n");
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(modules[0]?.entrypoints.schema).toBe(path.join(schemaDirectory, "index.mjs"));
});
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: "./dist/server",
server: "./dist/server.js",
schema: "./dist/schema.js",
migrations: "./dist/server/index.js",
},
},
});
await Promise.all([
mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/server.js")),
mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/schema.js")),
]);
const error = await discoveryError(modulesDirectory);
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength(
2,
3,
);
});
it("rejects schema entrypoint links that escape the module directory", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "linked-schema", {
manifest: {
...manifestFor("linked-schema"),
entrypoints: {
server: "./dist/server/index.js",
schema: "./dist/schema/index.js",
},
},
});
const schemaDirectory = path.join(modulesDirectory, "linked-schema", "dist/schema");
const externalSchema = path.join(modulesDirectory, "external-schema.js");
await mkdir(schemaDirectory, { recursive: true });
await writeFile(externalSchema, "export default {};\n");
await symlink("../../../external-schema.js", path.join(schemaDirectory, "index.js"));
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "entrypoints.schema",
}),
);
});
+489
View File
@@ -0,0 +1,489 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModuleSchemas,
ModuleDiscoveryError,
ModuleSchemaDiscoveryError,
type ModuleSchemaDiscoveryIssueCode,
} from "../src/modules/index.js";
interface SchemaModuleFixture {
readonly manifest?: Record<string, unknown>;
readonly packageJson?: Record<string, unknown>;
readonly schemaSource?: string;
readonly serverSource?: string;
}
const drizzlePgCoreUrl = import.meta.resolve("drizzle-orm/pg-core");
const temporaryDirectories: string[] = [];
const eventSymbols: symbol[] = [];
afterEach(async () => {
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
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-schema-"));
temporaryDirectories.push(directory);
return directory;
}
function manifestFor(
id: string,
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> {
return {
manifestVersion: 2,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
},
};
}
async function createSchemaModule(
modulesDirectory: string,
id: string,
fixture: SchemaModuleFixture = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
if (fixture.schemaSource !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
}
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify(
fixture.packageJson ?? {
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
},
),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify(fixture.manifest ?? manifestFor(id)),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
...(fixture.schemaSource === undefined
? []
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
]);
}
function createGlobalEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.schema.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
};
}
async function captureSchemaError(modulesDirectory: string): Promise<ModuleSchemaDiscoveryError> {
try {
await discoverModuleSchemas(discoveryOptions(modulesDirectory));
} catch (error) {
if (error instanceof ModuleSchemaDiscoveryError) {
return error;
}
throw error;
}
throw new Error("Expected module schema discovery to fail.");
}
function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIssueCode[] {
return error.issues.map((issue) => issue.code);
}
describe("module schema discovery", () => {
it("returns a frozen empty aggregate without importing module servers", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
await createSchemaModule(modulesDirectory, "server-only", {
manifest: manifestFor("server-only", { schema: false }),
serverSource: `${recorder.expression}.push("server-imported"); export default { id: "server-only", version: "0.1.0" };`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(aggregate.discoveredModules).toHaveLength(1);
expect(aggregate.modules).toEqual([]);
expect(aggregate.tables).toEqual([]);
expect(aggregate.drizzleSchema).toEqual({});
expect(recorder.events).toEqual([]);
expect(Object.isFrozen(aggregate)).toBe(true);
expect(Object.isFrozen(aggregate.tables)).toBe(true);
});
it("completes all static module discovery before importing any schema code", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
await createSchemaModule(modulesDirectory, "valid", {
schemaSource: `${recorder.expression}.push("schema-imported"); export default {};`,
});
await createSchemaModule(modulesDirectory, "missing-schema");
let discoveryError: ModuleDiscoveryError | undefined;
try {
await discoverModuleSchemas(discoveryOptions(modulesDirectory));
} catch (error) {
if (error instanceof ModuleDiscoveryError) {
discoveryError = error;
} else {
throw error;
}
}
expect(discoveryError?.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_MISSING",
moduleId: "missing-schema",
path: "entrypoints.schema",
}),
);
expect(recorder.events).toEqual([]);
});
it("loads and aggregates module tables deterministically", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", {
dependencies: { modules: { required: { people: "^0.1.0" } } },
}),
schemaSource: `
import { integer, pgTable, text } from ${JSON.stringify(drizzlePgCoreUrl)};
${recorder.expression}.push("calendar:schema");
const events = pgTable("calendar_events", { id: integer("id").primaryKey() });
const locations = pgTable("calendar_locations", {
id: integer("id").primaryKey(),
eventId: integer("event_id").references(() => events.id)
});
export default { moduleId: "calendar", tables: { locations, events } };
`,
serverSource: 'throw new Error("server entrypoint must remain unloaded");',
});
await createSchemaModule(modulesDirectory, "people", {
schemaSource: `
import { integer, pgTable, text } from ${JSON.stringify(drizzlePgCoreUrl)};
${recorder.expression}.push("people:schema");
const records = pgTable("people_records", { id: integer("id").primaryKey() });
const notes = pgTable("people_notes", {
id: integer("id").primaryKey(),
recordId: integer("record_id").references(() => records.id),
body: text("body")
});
export default { moduleId: "people", tables: { records, notes } };
`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(recorder.events).toEqual(["people:schema", "calendar:schema"]);
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["people", "calendar"]);
expect(aggregate.modules[0]?.tables.map((table) => table.exportName)).toEqual([
"notes",
"records",
]);
expect(aggregate.modules[1]?.tables.map((table) => table.exportName)).toEqual([
"events",
"locations",
]);
expect(aggregate.tables.map((table) => table.name)).toEqual([
"calendar_events",
"calendar_locations",
"people_notes",
"people_records",
]);
expect(Object.keys(aggregate.drizzleSchema)).toEqual(
aggregate.tables.map((table) => table.name),
);
expect(Object.isFrozen(aggregate.modules)).toBe(true);
expect(Object.isFrozen(aggregate.drizzleSchema)).toBe(true);
expect(Object.isFrozen(aggregate.modules[0])).toBe(true);
expect(Object.isFrozen(aggregate.modules[0]?.tables)).toBe(true);
expect(Object.isFrozen(aggregate.tables[0])).toBe(true);
});
it("aggregates import, export, and ownership-marker failures without leaking details", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alpha", {
schemaSource: 'throw new Error("database-password at /machine/private/path");',
});
await createSchemaModule(modulesDirectory, "beta", {
schemaSource: 'export default { moduleId: "beta", tables: { rows: {} } };',
});
await createSchemaModule(modulesDirectory, "gamma", {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const rows = pgTable("gamma_rows", { id: integer("id") });
export default { moduleId: "wrong", tables: { rows } };
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual([
"MODULE_SCHEMA_IMPORT_FAILED",
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_MODULE_ID_MISMATCH",
]);
const report = `${error.message}\n${error.stack}\n${JSON.stringify(error.issues)}`;
expect(report).not.toContain("database-password");
expect(report).not.toContain("/machine/private/path");
expect(report).not.toContain(modulesDirectory);
});
it("rejects malformed, unnamespaced, duplicate, and custom-schema tables", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "example", {
schemaSource: `
import { integer, pgSchema, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const duplicateA = pgTable("example_records", { id: integer("id") });
const duplicateB = pgTable("example_records", { id: integer("id") });
const invalid = pgTable("secret at /machine/path", { id: integer("id") });
const invalidLocal = pgTable("example_1records", { id: integer("id") });
const other = pgTable("other_records", { id: integer("id") });
const privateTable = pgSchema("private").table("example_private", { id: integer("id") });
export default {
moduleId: "example",
tables: {
repeated: duplicateA,
privateTable,
other,
invalid,
invalidLocal,
duplicateB,
duplicateA
}
};
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_SCHEMA_POSTGRES_SCHEMA_UNSUPPORTED",
"MODULE_SCHEMA_TABLE_DUPLICATE",
"MODULE_SCHEMA_TABLE_NAME_INVALID",
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
]),
);
expect(
issueCodes(error).filter((code) => code === "MODULE_SCHEMA_TABLE_DUPLICATE"),
).toHaveLength(2);
expect(
issueCodes(error).filter((code) => code === "MODULE_SCHEMA_TABLE_NAME_INVALID"),
).toHaveLength(2);
expect(JSON.stringify(error.issues)).not.toContain("secret at /machine/path");
});
it("rejects duplicate physical names across module namespaces", async () => {
const modulesDirectory = await createModulesDirectory();
for (const [moduleId, exportName] of [
["foo", "barRows"],
["foo-bar", "rows"],
] as const) {
await createSchemaModule(modulesDirectory, moduleId, {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const table = pgTable("foo_bar_rows", { id: integer("id") });
export default { moduleId: ${JSON.stringify(moduleId)}, tables: { ${exportName}: table } };
`,
});
}
const error = await captureSchemaError(modulesDirectory);
expect(error.issues).toEqual([
expect.objectContaining({
code: "MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId: "foo-bar",
tableName: "foo_bar_rows",
}),
]);
});
it("rejects cross-module and omitted local foreign-key targets", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "people", {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
export const records = pgTable("people_records", { id: integer("id").primaryKey() });
export default { moduleId: "people", tables: { records } };
`,
});
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", {
dependencies: { modules: { required: { people: "^0.1.0" } } },
}),
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
import { records } from "../../../people/dist/schema/index.js";
const privateTable = pgTable("calendar_private", { id: integer("id").primaryKey() });
const events = pgTable("calendar_events", {
id: integer("id"),
personId: integer("person_id").references(() => records.id)
});
const attendees = pgTable("calendar_attendees", {
id: integer("id"),
privateId: integer("private_id").references(() => privateTable.id)
});
export default { moduleId: "calendar", tables: { events, attendees } };
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual([
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
]);
});
it("rejects compound foreign keys that mix target-table ownership", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "people", {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
export const records = pgTable("people_records", {
id: integer("id").notNull(),
alternateId: integer("alternate_id").notNull()
});
export default { moduleId: "people", tables: { records } };
`,
});
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", {
dependencies: { modules: { required: { people: "^0.1.0" } } },
}),
schemaSource: `
import { foreignKey, integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
import { records } from "../../../people/dist/schema/index.js";
const targets = pgTable("calendar_targets", {
id: integer("id").notNull(),
alternateId: integer("alternate_id").notNull()
});
const links = pgTable("calendar_links", {
targetId: integer("target_id").notNull(),
alternateId: integer("alternate_id").notNull()
}, (table) => [
foreignKey({
columns: [table.targetId, table.alternateId],
foreignColumns: [targets.id, records.alternateId]
})
]);
export default { moduleId: "calendar", tables: { links, targets } };
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(["MODULE_SCHEMA_FOREIGN_KEY_INVALID"]);
});
it("rejects table aliases and sanitizes hostile metadata proxies", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alias-table", {
schemaSource: `
import { alias, integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const base = pgTable("private_records", { id: integer("id") });
const records = alias(base, "alias_table_records");
export default { moduleId: "alias-table", tables: { records } };
`,
});
await createSchemaModule(modulesDirectory, "proxy-table", {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const base = pgTable("proxy_table_records", { id: integer("id") });
const aliasSymbol = Symbol.for("drizzle:IsAlias");
let aliasReads = 0;
const records = new Proxy(base, {
get(target, key, receiver) {
if (key === aliasSymbol && ++aliasReads > 1) {
throw new Error("proxy-secret at /machine/private/schema");
}
return Reflect.get(target, key, receiver);
}
});
export default { moduleId: "proxy-table", tables: { records } };
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual([
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_TABLE_INVALID",
]);
const report = `${error.stack}\n${JSON.stringify(error.issues)}`;
expect(report).not.toContain("proxy-secret");
expect(report).not.toContain("/machine/private/schema");
});
it("sanitizes errors thrown while resolving lazy foreign-key metadata", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "unsafe", {
schemaSource: `
import { integer, pgTable } from ${JSON.stringify(drizzlePgCoreUrl)};
const rows = pgTable("unsafe_rows", {
id: integer("id"),
ownerId: integer("owner_id").references(() => {
throw new Error("foreign-key-secret at /machine/schema/path");
})
});
export default { moduleId: "unsafe", tables: { rows } };
`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(["MODULE_SCHEMA_FOREIGN_KEY_INVALID"]);
expect(JSON.stringify(error.issues)).not.toContain("foreign-key-secret");
expect(JSON.stringify(error.issues)).not.toContain("/machine/schema/path");
});
it("produces schema diagnostics independent of the absolute modules path", async () => {
const firstModulesDirectory = await createModulesDirectory();
const secondModulesDirectory = await createModulesDirectory();
for (const modulesDirectory of [firstModulesDirectory, secondModulesDirectory]) {
await createSchemaModule(modulesDirectory, "broken", {
schemaSource: 'throw new Error("secret at /machine/schema/path");',
});
}
const [firstError, secondError] = await Promise.all([
captureSchemaError(firstModulesDirectory),
captureSchemaError(secondModulesDirectory),
]);
expect(firstError.issues).toEqual(secondError.issues);
expect(firstError.stack).toBe(secondError.stack);
});
});
@@ -0,0 +1,88 @@
# 0007: Module-owned Drizzle schemas and table isolation
- Status: Accepted
- Date: 2026-07-13
## Context
Ekklesia needs to collect database declarations from independently installed modules without
giving Core feature-specific knowledge or allowing modules to couple through private database
structures. Schema declarations must be inspectable before any future migration or database
startup work, and their physical names must remain globally collision-resistant in a shared
PostgreSQL deployment.
The module manifest already has an optional `entrypoints.schema` field. Production module loading
already requires compiled ESM artifacts and complete static discovery before executable code is
imported.
## Decision
A module that owns tables declares a compiled ESM `.js` or `.mjs` schema entrypoint in
`ekklesia.module.json`, normally `./dist/schema/index.js`. The entrypoint default-exports a
`defineModuleSchema` definition containing only a module ID ownership marker and a table record.
The ID must match the already-discovered manifest. The schema entrypoint contains declarations
only, performs no I/O, opens no database connection, and has no top-level side effects beyond
constructing Drizzle metadata.
All module tables use Drizzle's PostgreSQL dialect in the platform-managed default/public
PostgreSQL schema. Modules cannot select custom PostgreSQL schemas. A module owns only the table
objects exported by its own schema definition. Schema definitions contain physical base tables,
not query aliases that could disguise another table under an owned name.
Physical table names use this mandatory namespace:
```text
<module ID with hyphens converted to underscores>_<lowercase snake-case local name>
```
For example, module `care-team` creates local table `members` as `care_team_members`.
Identifiers must be lowercase ASCII snake case and no longer than PostgreSQL's 63-character
identifier limit. `createModuleTable(moduleId)` applies this transformation for authors, while Core
validates the resulting physical names independently. Every table object and every physical table
name may appear only once in the aggregate.
Foreign keys may target self-referential or other exported tables owned by the same module. Every
source column must belong to the declaring table, and every target column in a simple or compound
key must belong to one exported target table. A foreign key may not target a table owned by another
module, even when a module or capability dependency exists. A target with the same namespace that
is omitted from the owning module's schema definition is also invalid.
Modules may not import another module's schema entrypoint, tables, columns, relations, or other
private code. Cross-module data relationships use opaque identifiers and public capabilities,
events, or APIs. The repository boundary checker enforces literal source/package imports, and Core
schema validation independently rejects cross-module foreign-key metadata.
Core schema discovery first completes ordinary module discovery for the entire installed graph. If
that fails, no schema entrypoint is imported. It then imports optional schema entrypoints in
dependency-first module order, validates every definition and table, and publishes a frozen
aggregate only if all modules succeed. Module contributions and table records are explicitly
sorted, and failures are structured, deterministic, and stripped of caught exception messages,
stacks, URLs, causes, and machine-specific paths.
Atomicity here means no schema code runs before complete static discovery and no partial aggregate
is published. ESM evaluation itself cannot be undone, which is why schema entrypoints must remain
side-effect-free.
## Alternatives
- Source TypeScript entrypoints were rejected because production schema discovery must consume the
same built artifacts that deployment uses.
- PostgreSQL schema-per-module isolation was rejected for this slice because table prefixes already
provide explicit ownership without adding schema search-path and migration-filtering complexity.
- Cross-module foreign keys were rejected because they couple module installation, upgrades,
deletion, and migration ordering through private storage details.
- Inferring tables from all named module exports was rejected because an explicit default
definition provides one reviewable ownership boundary and deterministic aggregation.
- A central table registry was rejected because cloning a module into `modules/` must remain enough
to install its declarations.
## Consequences
Module authors must use names short enough for the complete namespaced identifier to fit within 63
characters. Modules with no tables omit `entrypoints.schema`. Custom PostgreSQL schemas, views,
enums, sequences, and richer relation metadata can receive explicit contracts later rather than
being accepted implicitly now.
This decision discovers and validates Drizzle table metadata only. It does not create a database,
execute migrations, compare live schema state, or define upgrade, downgrade, uninstall, or
destructive-change policy. Those migration decisions remain a later Phase 3 slice.
+1
View File
@@ -15,3 +15,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
- [0006: Versioned capabilities and compiled module runtime](0006-versioned-capabilities-and-compiled-module-runtime.md) — Accepted
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
+3 -2
View File
@@ -62,8 +62,9 @@ Implemented now: the Phase 1 repository and infrastructure foundation plus publi
static versioned manifests, independent capability versions and dependency ranges, automatic
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
template.
template. Optional compiled schema entrypoints now contribute validated, namespaced module-owned
Drizzle tables to a deterministic aggregate without connecting to a database.
Target architecture, not yet implemented: Drizzle schema aggregation and migrations, Better Auth,
Target architecture, not yet implemented: migration generation or execution, Better Auth,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+4 -3
View File
@@ -32,7 +32,8 @@ These rules map platform ownership to filesystem and package boundaries. The exe
- Workspace package names are unique and shared packages declare a recognized architectural role.
- Circular source and workspace-package dependencies are rejected.
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
direct table imports and foreign keys.
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
columns, relations, schema entrypoint, or migration internals.
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
rejects every currently machine-checkable repository rule even before production packages fill
@@ -40,8 +41,8 @@ every directory. The UI guard rejects component, view, template, stylesheet, sha
common UI-framework dependencies inside modules. This deny list is defense in depth; package
review remains required when adopting a new frontend technology.
Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata
and are intentionally deferred to the Phase 3 schema validator.
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
targets, and physical table namespaces in compiled Drizzle metadata.
## Generated code
+3 -1
View File
@@ -31,7 +31,9 @@ the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests.
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
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
+56 -6
View File
@@ -10,12 +10,16 @@ strict semantic version, and the module layer marker:
```text
modules/example/
├── dist/schema/index.js # when the module owns tables
├── dist/server/index.js
├── ekklesia.module.json
├── package.json
├── src/schema/index.ts # when the module owns tables
└── src/server/index.ts
```
The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`.
```json
{
"type": "module",
@@ -74,7 +78,8 @@ without executing module code:
]
},
"entrypoints": {
"server": "./dist/server/index.js"
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js"
}
}
```
@@ -84,9 +89,10 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
Server entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`, remain inside the
package, and exist. Production never imports source TypeScript directly. Unknown manifest
properties are rejected, including presentation fields such as components or renderers.
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`,
remain inside the package, and exist. Production never imports source TypeScript directly. Modules
with no tables omit `entrypoints.schema`. 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;
@@ -127,6 +133,44 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
close them in `stop`.
## Drizzle schema definition
A module with tables exports a declaration-only schema definition from `src/schema/index.ts`:
```ts
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
import { integer, text } from "drizzle-orm/pg-core";
const moduleTable = createModuleTable("calendar");
export const events = moduleTable("events", {
id: integer("id").primaryKey(),
title: text("title").notNull()
});
export default defineModuleSchema({
moduleId: "calendar",
tables: { events }
});
```
`createModuleTable` converts the module ID to a snake-case namespace and prefixes every local table
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
must not exceed 63 characters. Core validates the built metadata even when a module does not use
the helper.
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
The `moduleId` is an ownership marker and must match the static manifest. Export physical base
tables, never query aliases.
Foreign keys may reference self or another exported table from the same module. They may not target
another module or an omitted private table. Every source column must belong to the declaring table,
and all columns in a compound target must belong to the same exported target table. Modules must
never import another module's tables, columns, relations, schema entrypoint, or migration files.
Store opaque identifiers and integrate through public capabilities, events, or APIs instead.
## Discovery and lifecycle
From the repository root, run:
@@ -140,8 +184,14 @@ pnpm verify
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
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, escaping, or non-JavaScript server entrypoints. Missing optional
dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting.
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable
identifier sorting.
After complete static discovery succeeds, validation loads optional schema entrypoints
dependency-first and rejects invalid definitions, unnamespaced or duplicate tables, custom
PostgreSQL schemas, and foreign keys outside the owning module. The aggregate is published only when
every schema is valid. This slice does not connect to PostgreSQL or execute migrations.
Core completes discovery before importing any server entrypoint. It then loads and verifies every
runtime definition before running hooks, registers all modules dependency-first into an unpublished
+1
View File
@@ -19,6 +19,7 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"semver": "7.8.5",
"zod": "4.4.3"
},
+1
View File
@@ -2,4 +2,5 @@ export * from "./identifiers.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
export * from "./versions.js";
+94
View File
@@ -0,0 +1,94 @@
import { is } from "drizzle-orm";
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
import { z } from "zod";
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
export const moduleTableLocalNameSchema = z
.string()
.min(1)
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
POSTGRES_IDENTIFIER_MAX_LENGTH,
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
);
export interface ModuleSchemaDefinition<
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
> {
readonly moduleId: ModuleId;
readonly tables: Tables;
}
const schemaDefinitionKeys = new Set(["moduleId", "tables"]);
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): boolean {
return Reflect.ownKeys(value).every(
(key) => typeof key === "string" && (!allowedKeys || allowedKeys.has(key)),
);
}
export function moduleTableNamespace(moduleId: ModuleId): string {
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
}
export function moduleTablePrefix(moduleId: ModuleId): string {
return `${moduleTableNamespace(moduleId)}_`;
}
export function moduleTableName(moduleId: ModuleId, localName: string): string {
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
}
return tableName;
}
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
try {
if (
!isRecord(value) ||
!hasOnlyStringKeys(value, schemaDefinitionKeys) ||
!moduleIdSchema.safeParse(value.moduleId).success ||
!isRecord(value.tables) ||
!hasOnlyStringKeys(value.tables)
) {
return false;
}
const tables = Object.values(value.tables);
return (
tables.length > 0 &&
tables.every(
(table) =>
is(table, PgTable) &&
(table as unknown as Record<PropertyKey, unknown>)[drizzleTableIsAlias] === false,
)
);
} catch {
return false;
}
}
export function parseModuleSchemaDefinition(value: unknown): ModuleSchemaDefinition {
if (!isModuleSchemaDefinition(value)) {
throw new TypeError("Invalid Ekklesia module schema definition.");
}
const tables = Object.freeze(
Object.fromEntries(
Object.entries(value.tables).sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
) as Record<string, AnyPgTable>,
);
return Object.freeze({ moduleId: value.moduleId, tables });
}
@@ -0,0 +1,54 @@
import { alias, getTableConfig, integer } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
moduleTableName,
parseModuleSchemaDefinition,
physicalModuleTableNameSchema,
} from "../src/index.js";
import { pgTable } from "drizzle-orm/pg-core";
describe("module schema contract", () => {
it("creates deterministic physical names from module and local identifiers", () => {
expect(moduleTableName("calendar", "events")).toBe("calendar_events");
expect(moduleTableName("care-team", "member_notes")).toBe("care_team_member_notes");
});
it("rejects unsafe logical names and PostgreSQL identifiers beyond 63 characters", () => {
expect(() => moduleTableName("calendar", "EventItems")).toThrow();
expect(() => moduleTableName("calendar", "event-items")).toThrow();
expect(physicalModuleTableNameSchema.safeParse("a".repeat(63)).success).toBe(true);
expect(physicalModuleTableNameSchema.safeParse("a".repeat(64)).success).toBe(false);
expect(() => moduleTableName("a".repeat(60), "rows")).toThrow(
"The namespaced PostgreSQL table name is invalid or too long.",
);
});
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
const records = pgTable("example_records", { id: integer().primaryKey() });
const definition = parseModuleSchemaDefinition({
moduleId: "example",
tables: { records },
});
expect(definition.tables.records).toBe(records);
expect(getTableConfig(records).name).toBe("example_records");
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(records)).toBe(false);
});
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
const records = pgTable("example_records", { id: integer() });
const disguisedRecords = alias(records, "example_disguised_records");
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
).toThrow();
});
});
+4
View File
@@ -24,10 +24,14 @@
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"drizzle-orm": "0.45.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-sdk"
},
"peerDependencies": {
"drizzle-orm": "0.45.2"
}
}
@@ -0,0 +1,19 @@
import {
type ModuleId,
type ModuleSchemaDefinition,
moduleTableName,
moduleTablePrefix,
parseModuleSchemaDefinition,
} from "@ekklesia/contracts";
import { pgTableCreator } from "drizzle-orm/pg-core";
export function createModuleTable(moduleId: ModuleId) {
moduleTablePrefix(moduleId);
return pgTableCreator((localName) => moduleTableName(moduleId, localName));
}
export function defineModuleSchema<const Definition extends ModuleSchemaDefinition>(
definition: Definition,
): Readonly<Definition> {
return parseModuleSchemaDefinition(definition) as Readonly<Definition>;
}
+1
View File
@@ -1,2 +1,3 @@
export * from "@ekklesia/contracts";
export * from "./define-module-runtime.js";
export * from "./define-module-schema.js";
@@ -0,0 +1,25 @@
import { getTableConfig, integer } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { createModuleTable, defineModuleSchema } from "../src/index.js";
describe("module schema authoring", () => {
it("creates namespaced PostgreSQL tables and a typed frozen definition", () => {
const moduleTable = createModuleTable("care-team");
const members = moduleTable("members", { id: integer().primaryKey() });
const definition = defineModuleSchema({
moduleId: "care-team",
tables: { members },
});
expect(getTableConfig(members).name).toBe("care_team_members");
expect(definition.tables.members).toBe(members);
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
});
it("rejects invalid module and logical table names", () => {
expect(() => createModuleTable("Not Valid")).toThrow();
const moduleTable = createModuleTable("example");
expect(() => moduleTable("Not Valid", { id: integer() })).toThrow();
});
});
+106
View File
@@ -32,6 +32,9 @@ importers:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../packages/contracts
drizzle-orm:
specifier: 0.45.2
version: 0.45.2
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -48,6 +51,9 @@ importers:
packages/contracts:
dependencies:
drizzle-orm:
specifier: 0.45.2
version: 0.45.2
semver:
specifier: 7.8.5
version: 7.8.5
@@ -83,6 +89,9 @@ importers:
'@types/node':
specifier: 24.13.3
version: 24.13.3
drizzle-orm:
specifier: 0.45.2
version: 0.45.2
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -97,6 +106,9 @@ importers:
'@ekklesia/module-sdk':
specifier: workspace:*
version: link:../../packages/module-sdk
drizzle-orm:
specifier: 0.45.2
version: 0.45.2
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
@@ -507,6 +519,98 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
'@electric-sql/pglite': '>=0.2.0'
'@libsql/client': '>=0.10.0'
'@libsql/client-wasm': '>=0.10.0'
'@neondatabase/serverless': '>=0.10.0'
'@op-engineering/op-sqlite': '>=2'
'@opentelemetry/api': ^1.4.1
'@planetscale/database': '>=1.13'
'@prisma/client': '*'
'@tidbcloud/serverless': '*'
'@types/better-sqlite3': '*'
'@types/pg': '*'
'@types/sql.js': '*'
'@upstash/redis': '>=1.34.7'
'@vercel/postgres': '>=0.8.0'
'@xata.io/client': '*'
better-sqlite3: '>=7'
bun-types: '*'
expo-sqlite: '>=14.0.0'
gel: '>=2'
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: '>=8'
postgres: '>=3'
prisma: '*'
sql.js: '>=1'
sqlite3: '>=5'
peerDependenciesMeta:
'@aws-sdk/client-rds-data':
optional: true
'@cloudflare/workers-types':
optional: true
'@electric-sql/pglite':
optional: true
'@libsql/client':
optional: true
'@libsql/client-wasm':
optional: true
'@neondatabase/serverless':
optional: true
'@op-engineering/op-sqlite':
optional: true
'@opentelemetry/api':
optional: true
'@planetscale/database':
optional: true
'@prisma/client':
optional: true
'@tidbcloud/serverless':
optional: true
'@types/better-sqlite3':
optional: true
'@types/pg':
optional: true
'@types/sql.js':
optional: true
'@upstash/redis':
optional: true
'@vercel/postgres':
optional: true
'@xata.io/client':
optional: true
better-sqlite3:
optional: true
bun-types:
optional: true
expo-sqlite:
optional: true
gel:
optional: true
knex:
optional: true
kysely:
optional: true
mysql2:
optional: true
pg:
optional: true
postgres:
optional: true
prisma:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
[email protected]:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
@@ -1045,6 +1149,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
+7 -4
View File
@@ -1,8 +1,9 @@
# Ekklesia module template
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
compiled runtime entrypoint, capability registration, and logical storage-bucket declarations
without pretending that API, Drizzle, or storage provisioning already exist.
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, and
logical storage-bucket declarations without pretending that migrations, API, or storage
provisioning already exist.
To create a module inside this repository:
@@ -14,9 +15,11 @@ To create a module inside this repository:
module actually implements and consumes.
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
must match the manifest; do not duplicate the rest of the manifest in TypeScript.
6. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository
6. Replace `src/schema/index.ts` with declaration-only Drizzle tables. Keep `createModuleTable` and
`defineModuleSchema` aligned with the module ID, and never import another module's schema.
7. Run `pnpm install`, `pnpm build`, `pnpm modules:validate`, and `pnpm verify` from the repository
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
TypeScript.
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
+2 -1
View File
@@ -24,6 +24,7 @@
]
},
"entrypoints": {
"server": "./dist/server/index.js"
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js"
}
}
+3 -2
View File
@@ -25,12 +25,13 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/module-sdk": "workspace:*"
"@ekklesia/module-sdk": "workspace:*",
"drizzle-orm": "0.45.2"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+14
View File
@@ -0,0 +1,14 @@
import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
import { text, uuid } from "drizzle-orm/pg-core";
const moduleTable = createModuleTable("example");
export const records = moduleTable("records", {
id: uuid("id").primaryKey(),
value: text("value").notNull(),
});
export default defineModuleSchema({
moduleId: "example",
tables: { records },
});
+9
View File
@@ -4,7 +4,9 @@ import {
parseModuleManifest,
type RegisteredCapability,
} from "@ekklesia/module-sdk";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import moduleSchema, { records } from "../src/schema/index.js";
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
describe("module template", () => {
@@ -16,6 +18,7 @@ describe("module template", () => {
expect(manifest.id).toBe("example");
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
});
it("exports the typed runtime definition used by the compiled server entrypoint", async () => {
@@ -35,4 +38,10 @@ describe("module template", () => {
expect(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
expect(provided.get("example.records")).toBe(recordsCapability);
});
it("exports a namespaced module-owned Drizzle schema", () => {
expect(moduleSchema.moduleId).toBe("example");
expect(moduleSchema.tables.records).toBe(records);
expect(getTableConfig(records)).toMatchObject({ name: "example_records", schema: undefined });
});
});
+1
View File
@@ -29,6 +29,7 @@ describe("repository dependency boundaries", () => {
["module-imports-surface", "modules-use-public-platform-contracts"],
["package-boundary-direction", "modules-use-public-platform-contracts"],
["cross-module-import", "modules-do-not-import-other-modules"],
["module-schema-imports-module", "modules-do-not-import-other-modules"],
["surface-imports-module", "surfaces-use-public-contracts"],
["cross-surface-import", "surfaces-are-independent"],
["builder-imports-module", "builder-composes-contracts"],
@@ -0,0 +1,3 @@
import { peopleRecords } from "../../../modules/people/src/schema.js";
export const invalidPrivateSchemaImport = peopleRecords;
@@ -0,0 +1 @@
export const peopleRecords = "private people schema";