Files
Ekklesia/core/src/modules/schema-discovery.ts
T
ekklesiaandClaude Opus 5.5 89d5d0f2da
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s
feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:28:54 -05:00

601 lines
19 KiB
TypeScript

import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import {
type ModuleSchemaDefinition,
parseModuleSchemaDefinition,
} from "./legacy-schema-definition.js";
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;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
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 LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "legacy";
}
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
readonly table: AnyPgTable;
}
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
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 | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try {
definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: 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(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
}
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;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
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,
mode: "legacy",
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: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate,
): 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) {
if (candidate.mode === "legacy") {
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) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.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 },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
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 },
),
);
}
});
continue;
}
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, mode: _mode, 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.flatMap((table) =>
table.table === undefined ? [] : [[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);
}