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>; 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["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 { 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; 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(); for (const loaded of loadedSchemas) { const moduleId = loaded.discovered.manifest.id; 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, 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(); 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 { 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); }