feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
This commit is contained in:
@@ -2,9 +2,11 @@ import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type ModuleId,
|
||||
type ModuleSchemaDefinition,
|
||||
type ModuleSchemaMetadataDefinition,
|
||||
moduleTableLocalNameSchema,
|
||||
moduleTablePrefix,
|
||||
parseModuleSchemaDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
physicalModuleTableNameSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { is } from "drizzle-orm";
|
||||
@@ -40,7 +42,9 @@ export interface DiscoveredModuleTable {
|
||||
readonly exportName: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly name: string;
|
||||
readonly table: AnyPgTable;
|
||||
/** 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 {
|
||||
@@ -55,21 +59,40 @@ export interface ModuleSchemaAggregate {
|
||||
readonly tables: readonly DiscoveredModuleTable[];
|
||||
}
|
||||
|
||||
interface LoadedModuleSchema {
|
||||
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 ModuleTableCandidate extends DiscoveredModuleTable {
|
||||
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 {
|
||||
@@ -156,9 +179,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
let definition: ModuleSchemaDefinition;
|
||||
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
|
||||
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
|
||||
try {
|
||||
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
definition =
|
||||
mode === "abi-v1"
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
@@ -182,7 +209,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
loaded.push({ definition, discovered });
|
||||
loaded.push(
|
||||
mode === "abi-v1"
|
||||
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
|
||||
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
|
||||
);
|
||||
}
|
||||
|
||||
return { issues, loaded };
|
||||
@@ -197,6 +228,58 @@ function inspectTables(
|
||||
|
||||
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]) =>
|
||||
@@ -293,6 +376,7 @@ function inspectTables(
|
||||
const candidate: ModuleTableCandidate = {
|
||||
exportName,
|
||||
foreignKeys: config.foreignKeys,
|
||||
mode: "legacy",
|
||||
moduleId,
|
||||
name: config.name,
|
||||
path,
|
||||
@@ -321,8 +405,8 @@ function inspectTables(
|
||||
}
|
||||
|
||||
function validForeignKeyTarget(
|
||||
foreignKey: ModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: ModuleTableCandidate,
|
||||
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: LegacyModuleTableCandidate,
|
||||
): AnyPgTable | undefined {
|
||||
try {
|
||||
const reference = foreignKey.reference();
|
||||
@@ -356,7 +440,9 @@ function inspectForeignKeys(
|
||||
const ownerByTable = new Map<AnyPgTable, ModuleId>();
|
||||
for (const inspected of inspectedModules) {
|
||||
for (const candidate of inspected.tables) {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
if (candidate.mode === "legacy") {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +450,32 @@ function inspectForeignKeys(
|
||||
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);
|
||||
@@ -446,7 +558,9 @@ function publishAggregate(
|
||||
moduleId: inspected.discovered.manifest.id,
|
||||
tables: Object.freeze(
|
||||
inspected.tables
|
||||
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
|
||||
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
|
||||
Object.freeze(table),
|
||||
)
|
||||
.sort((left, right) => compareText(left.exportName, right.exportName)),
|
||||
),
|
||||
}),
|
||||
@@ -458,7 +572,11 @@ function publishAggregate(
|
||||
.sort((left, right) => compareText(left.name, right.name)),
|
||||
);
|
||||
const drizzleSchema = Object.freeze(
|
||||
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
|
||||
Object.fromEntries(
|
||||
tables.flatMap((table) =>
|
||||
table.table === undefined ? [] : [[table.name, table.table] as const],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
|
||||
|
||||
Reference in New Issue
Block a user