Merge pull request 'feat: add module schema discovery and validation' (#1) from phase-3/module-schema-discovery into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
@@ -6,11 +6,12 @@ surfaces.
|
|||||||
|
|
||||||
## Current status
|
## 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,
|
capabilities, static module manifests, deterministic automatic discovery, compiled server loading,
|
||||||
a staged capability registry, dependency-ordered lifecycle with rollback, and a cloneable
|
a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table
|
||||||
backend-only module template. Drizzle, identity, HTTP routing, jobs, physical storage provisioning,
|
contracts, deterministic schema aggregation, and a cloneable backend-only module template. Migration
|
||||||
surfaces, and feature modules remain later vertical slices.
|
execution, identity, HTTP routing, jobs, physical storage provisioning, surfaces, and feature modules
|
||||||
|
remain later vertical slices.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/contracts": "workspace:*"
|
"@ekklesia/contracts": "workspace:*",
|
||||||
|
"drizzle-orm": "0.45.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { discoverModules, ModuleDiscoveryError } from "../modules/index.js";
|
import {
|
||||||
|
discoverModuleSchemas,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
ModuleSchemaDiscoveryError,
|
||||||
|
} from "../modules/index.js";
|
||||||
|
|
||||||
interface PackageVersion {
|
interface PackageVersion {
|
||||||
version?: unknown;
|
version?: unknown;
|
||||||
@@ -20,18 +24,21 @@ async function main(): Promise<void> {
|
|||||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
]);
|
]);
|
||||||
const modules = await discoverModules({
|
const schemaAggregate = await discoverModuleSchemas({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
|
const modules = schemaAggregate.discoveredModules;
|
||||||
|
|
||||||
if (modules.length === 0) {
|
if (modules.length === 0) {
|
||||||
console.log("Module validation passed (0 modules installed).");
|
console.log("Module validation passed (0 modules installed).");
|
||||||
return;
|
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) {
|
for (const discoveredModule of modules) {
|
||||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||||
}
|
}
|
||||||
@@ -44,6 +51,12 @@ main().catch((error: unknown) => {
|
|||||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||||
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
|
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 {
|
} else {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,15 +238,15 @@ async function resolveEntrypoints(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
entrypointName === "server" &&
|
["schema", "server"].includes(entrypointName) &&
|
||||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||||
) {
|
) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "MODULE_ENTRYPOINT_INVALID",
|
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,
|
moduleDirectory: directoryName,
|
||||||
moduleId: manifest.id,
|
moduleId: manifest.id,
|
||||||
path: "entrypoints.server",
|
path: `entrypoints.${entrypointName}`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
export * from "./schema-discovery.js";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -397,12 +397,17 @@ describe("module discovery", () => {
|
|||||||
await createModule(modulesDirectory, "missing-server", {
|
await createModule(modulesDirectory, "missing-server", {
|
||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("missing-server"),
|
...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);
|
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" });
|
error = await discoveryError(modulesDirectory, { platformVersion: "invalid" });
|
||||||
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_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 () => {
|
it("requires executable entrypoints to be files and migrations to be a directory", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
|
await createModule(modulesDirectory, "wrong-entrypoint-kind", {
|
||||||
manifest: {
|
manifest: {
|
||||||
...manifestFor("wrong-entrypoint-kind"),
|
...manifestFor("wrong-entrypoint-kind"),
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server",
|
server: "./dist/server.js",
|
||||||
|
schema: "./dist/schema.js",
|
||||||
migrations: "./dist/server/index.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);
|
const error = await discoveryError(modulesDirectory);
|
||||||
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength(
|
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",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
- [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted
|
||||||
- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Superseded
|
- [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
|
- [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
|
||||||
|
|||||||
@@ -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
|
static versioned manifests, independent capability versions and dependency ranges, automatic
|
||||||
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
|
filesystem discovery, compiled server loading, runtime-definition verification, staged capability
|
||||||
registration, dependency-ordered startup and reverse shutdown, rollback, and a backend-only module
|
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
|
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
||||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
||||||
|
|||||||
@@ -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.
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||||
- Circular source and workspace-package dependencies are rejected.
|
- Circular source and workspace-package dependencies are rejected.
|
||||||
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
- 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
|
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
|
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
|
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
||||||
review remains required when adopting a new frontend technology.
|
review remains required when adopting a new frontend technology.
|
||||||
|
|
||||||
Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata
|
The Phase 3 schema validator complements source boundaries by checking table ownership, foreign-key
|
||||||
and are intentionally deferred to the Phase 3 schema validator.
|
targets, and physical table namespaces in compiled Drizzle metadata.
|
||||||
|
|
||||||
## Generated code
|
## Generated code
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
`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
|
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 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
|
CI job runs the live infrastructure smoke test so code-only local verification does not require
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ strict semantic version, and the module layer marker:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
modules/example/
|
modules/example/
|
||||||
|
├── dist/schema/index.js # when the module owns tables
|
||||||
├── dist/server/index.js
|
├── dist/server/index.js
|
||||||
├── ekklesia.module.json
|
├── ekklesia.module.json
|
||||||
├── package.json
|
├── package.json
|
||||||
|
├── src/schema/index.ts # when the module owns tables
|
||||||
└── src/server/index.ts
|
└── src/server/index.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The schema paths are optional. A tableless module omits them and omits `entrypoints.schema`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -74,7 +78,8 @@ without executing module code:
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"entrypoints": {
|
"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
|
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.
|
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
|
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files, begin with `./`,
|
||||||
package, and exist. Production never imports source TypeScript directly. Unknown manifest
|
remain inside the package, and exist. Production never imports source TypeScript directly. Modules
|
||||||
properties are rejected, including presentation fields such as components or renderers.
|
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.
|
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;
|
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
|
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
|
||||||
close them in `stop`.
|
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
|
## Discovery and lifecycle
|
||||||
|
|
||||||
From the repository root, run:
|
From the repository root, run:
|
||||||
@@ -140,8 +184,14 @@ pnpm verify
|
|||||||
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
|
`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
|
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
||||||
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
||||||
dependencies, cycles, and missing, escaping, or non-JavaScript server entrypoints. Missing optional
|
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
||||||
dependencies are allowed. Valid modules are ordered dependency-first with stable identifier sorting.
|
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
|
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
|
runtime definition before running hooks, registers all modules dependency-first into an unpublished
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
"semver": "7.8.5",
|
"semver": "7.8.5",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "./identifiers.js";
|
|||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
export * from "./module-runtime.js";
|
export * from "./module-runtime.js";
|
||||||
|
export * from "./module-schema.js";
|
||||||
export * from "./versions.js";
|
export * from "./versions.js";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,10 +24,14 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "module-sdk"
|
"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,2 +1,3 @@
|
|||||||
export * from "@ekklesia/contracts";
|
export * from "@ekklesia/contracts";
|
||||||
export * from "./define-module-runtime.js";
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+106
@@ -32,6 +32,9 @@ importers:
|
|||||||
'@ekklesia/contracts':
|
'@ekklesia/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/contracts
|
version: link:../packages/contracts
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
@@ -48,6 +51,9 @@ importers:
|
|||||||
|
|
||||||
packages/contracts:
|
packages/contracts:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2
|
||||||
semver:
|
semver:
|
||||||
specifier: 7.8.5
|
specifier: 7.8.5
|
||||||
version: 7.8.5
|
version: 7.8.5
|
||||||
@@ -83,6 +89,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.13.3
|
specifier: 24.13.3
|
||||||
version: 24.13.3
|
version: 24.13.3
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
@@ -97,6 +106,9 @@ importers:
|
|||||||
'@ekklesia/module-sdk':
|
'@ekklesia/module-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/module-sdk
|
version: link:../../packages/module-sdk
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: 0.45.2
|
||||||
|
version: 0.45.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.5.3
|
specifier: 2.5.3
|
||||||
@@ -507,6 +519,98 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
|
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
|
||||||
|
|
||||||
@@ -1045,6 +1149,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Ekklesia module template
|
# Ekklesia module template
|
||||||
|
|
||||||
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
|
||||||
compiled runtime entrypoint, capability registration, and logical storage-bucket declarations
|
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, and
|
||||||
without pretending that API, Drizzle, or storage provisioning already exist.
|
logical storage-bucket declarations without pretending that migrations, API, or storage
|
||||||
|
provisioning already exist.
|
||||||
|
|
||||||
To create a module inside this repository:
|
To create a module inside this repository:
|
||||||
|
|
||||||
@@ -14,9 +15,11 @@ To create a module inside this repository:
|
|||||||
module actually implements and consumes.
|
module actually implements and consumes.
|
||||||
5. Replace `src/server/index.ts` with backend behavior only. Its `defineModuleRuntime` ID and version
|
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.
|
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
|
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.
|
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`,
|
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"server": "./dist/server/index.js"
|
"server": "./dist/server/index.js",
|
||||||
|
"schema": "./dist/schema/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,13 @@
|
|||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ekklesia/module-sdk": "workspace:*"
|
"@ekklesia/module-sdk": "workspace:*",
|
||||||
|
"drizzle-orm": "0.45.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
type RegisteredCapability,
|
type RegisteredCapability,
|
||||||
} from "@ekklesia/module-sdk";
|
} from "@ekklesia/module-sdk";
|
||||||
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import moduleSchema, { records } from "../src/schema/index.js";
|
||||||
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
|
import moduleRuntime, { recordsCapability } from "../src/server/index.js";
|
||||||
|
|
||||||
describe("module template", () => {
|
describe("module template", () => {
|
||||||
@@ -16,6 +18,7 @@ describe("module template", () => {
|
|||||||
expect(manifest.id).toBe("example");
|
expect(manifest.id).toBe("example");
|
||||||
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
||||||
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
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 () => {
|
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(moduleRuntime).toMatchObject({ id: "example", version: "0.1.0" });
|
||||||
expect(provided.get("example.records")).toBe(recordsCapability);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("repository dependency boundaries", () => {
|
|||||||
["module-imports-surface", "modules-use-public-platform-contracts"],
|
["module-imports-surface", "modules-use-public-platform-contracts"],
|
||||||
["package-boundary-direction", "modules-use-public-platform-contracts"],
|
["package-boundary-direction", "modules-use-public-platform-contracts"],
|
||||||
["cross-module-import", "modules-do-not-import-other-modules"],
|
["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"],
|
["surface-imports-module", "surfaces-use-public-contracts"],
|
||||||
["cross-surface-import", "surfaces-are-independent"],
|
["cross-surface-import", "surfaces-are-independent"],
|
||||||
["builder-imports-module", "builder-composes-contracts"],
|
["builder-imports-module", "builder-composes-contracts"],
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { peopleRecords } from "../../../modules/people/src/schema.js";
|
||||||
|
|
||||||
|
export const invalidPrivateSchemaImport = peopleRecords;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const peopleRecords = "private people schema";
|
||||||
Reference in New Issue
Block a user