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>; 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["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 { 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)[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 { 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(); 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(); 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; 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(); 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 { 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); }