Files
Ekklesia/core/src/modules/schema-discovery.ts
T
ekklesia 80b56b72b1
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s
feat: add module schema discovery and validation
2026-07-13 21:35:21 -05:00

481 lines
14 KiB
TypeScript

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);
}