feat: add module schema discovery and validation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user