feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s

This commit is contained in:
ekklesia
2026-07-21 08:09:30 -05:00
parent 37f54edd89
commit 88c28d75c9
30 changed files with 2947 additions and 48 deletions
+28
View File
@@ -0,0 +1,28 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
}
export function formatMigrationValidationReport(
aggregate: ModuleMigrationAggregate,
): readonly string[] {
const moduleCount = aggregate.modules.length;
const migrationCount = aggregate.migrations.length;
const header = `Migration validation passed (${moduleCount} ${plural(moduleCount, "module")}, ${migrationCount} ${plural(migrationCount, "migration")})${moduleCount === 0 ? "." : ":"}`;
const lines = [header];
for (const module of aggregate.modules) {
if (module.migrations.length === 0) {
lines.push(`- ${module.moduleId} (no migrations)`);
continue;
}
for (const migration of module.migrations) {
lines.push(
`- ${migration.identity} sha256=${migration.checksum} destructive=${migration.destructive.mode}`,
);
}
}
return Object.freeze(lines);
}
+56
View File
@@ -0,0 +1,56 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { formatMigrationValidationReport } from "./migration-report.js";
import {
discoverModuleMigrations,
ModuleDiscoveryError,
ModuleMigrationDiscoveryError,
} from "../modules/index.js";
interface PackageVersion {
version?: unknown;
}
async function readPackageVersion(packagePath: string): Promise<string> {
const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion;
if (typeof packageData.version !== "string") {
throw new TypeError("A required workspace package does not declare a version.");
}
return packageData.version;
}
async function main(): Promise<void> {
const repositoryRoot = process.cwd();
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
const aggregate = await discoverModuleMigrations({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
});
for (const line of formatMigrationValidationReport(aggregate)) {
console.log(line);
}
}
main().catch((error: unknown) => {
if (error instanceof ModuleDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`);
}
} else if (error instanceof ModuleMigrationDiscoveryError) {
console.error(error.message);
for (const issue of error.issues) {
const issuePath = issue.path ? ` (${issue.path})` : "";
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
}
} else {
console.error("Migration validation failed unexpectedly.");
}
process.exitCode = 1;
});
+1
View File
@@ -1,3 +1,4 @@
export * from "./discovery.js";
export * from "./migration-discovery.js";
export * from "./runtime.js";
export * from "./schema-discovery.js";
+760
View File
@@ -0,0 +1,760 @@
import { createHash } from "node:crypto";
import type { Dirent } from "node:fs";
import { lstat, readFile, readdir, realpath } from "node:fs/promises";
import path from "node:path";
import {
DRIZZLE_MIGRATION_JOURNAL_FILENAME,
DRIZZLE_MIGRATION_META_DIRECTORY,
DRIZZLE_MIGRATION_ORIGIN_UUID,
EKKLESIA_MIGRATION_POLICY_FILENAME,
type DrizzleMigrationJournal,
type DrizzleMigrationSnapshotHeader,
type ModuleId,
type ModuleMigrationDestructiveDeclaration,
type ModuleMigrationPolicy,
moduleMigrationTagSchema,
parseDrizzleMigrationJournal,
parseDrizzleMigrationSnapshotHeader,
parseModuleMigrationPolicy,
} from "@ekklesia/contracts";
import {
type DiscoveredModule,
discoverModules,
type ModuleDiscoveryOptions,
} from "./discovery.js";
export type ModuleMigrationDiscoveryIssueCode =
| "MODULE_MIGRATION_CHECKSUM_INVALID"
| "MODULE_MIGRATION_DIRECTORY_INVALID"
| "MODULE_MIGRATION_EMPTY"
| "MODULE_MIGRATION_FILE_INVALID"
| "MODULE_MIGRATION_FILE_MISSING"
| "MODULE_MIGRATION_FILE_UNDECLARED"
| "MODULE_MIGRATION_ID_DUPLICATE"
| "MODULE_MIGRATION_ID_INVALID"
| "MODULE_MIGRATION_JOURNAL_INVALID"
| "MODULE_MIGRATION_METADATA_INCONSISTENT"
| "MODULE_MIGRATION_METADATA_INVALID"
| "MODULE_MIGRATION_ORDER_INVALID"
| "MODULE_MIGRATION_PATH_UNSAFE"
| "MODULE_MIGRATION_SNAPSHOT_INVALID"
| "MODULE_MIGRATION_UNSUPPORTED_FILE";
export interface ModuleMigrationDiscoveryIssue {
readonly code: ModuleMigrationDiscoveryIssueCode;
readonly message: string;
readonly moduleId: ModuleId;
readonly path?: string;
}
export interface DiscoveredModuleMigration {
readonly approvalRequired: boolean;
readonly breakpoints: boolean;
readonly checksum: string;
readonly destructive: ModuleMigrationDestructiveDeclaration;
readonly file: string;
readonly identity: string;
readonly index: number;
readonly moduleId: ModuleId;
readonly sql: string;
readonly tag: string;
readonly timestamp: number;
}
export interface DiscoveredModuleMigrations {
readonly migrations: readonly DiscoveredModuleMigration[];
readonly moduleId: ModuleId;
}
export interface ModuleMigrationAggregate {
readonly discoveredModules: readonly DiscoveredModule[];
readonly migrations: readonly DiscoveredModuleMigration[];
readonly modules: readonly DiscoveredModuleMigrations[];
}
interface DirectoryInventory {
readonly metaEntries: ReadonlySet<string>;
readonly rootEntries: ReadonlySet<string>;
}
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const sqlFilenamePattern = /^\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.sql$/u;
const snapshotFilenamePattern = /^\d{4,}_snapshot\.json$/u;
export class ModuleMigrationDiscoveryError extends Error {
readonly issues: readonly ModuleMigrationDiscoveryIssue[];
constructor(issues: readonly ModuleMigrationDiscoveryIssue[]) {
const sortedIssues = [...issues].sort(compareIssues);
super(`Module migration discovery failed with ${sortedIssues.length} issue(s).`);
this.name = "ModuleMigrationDiscoveryError";
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: ModuleMigrationDiscoveryIssue,
right: ModuleMigrationDiscoveryIssue,
): number {
return (
compareText(left.moduleId, right.moduleId) ||
compareText(left.path ?? "", right.path ?? "") ||
compareText(left.code, right.code) ||
compareText(left.message, right.message)
);
}
function issue(
code: ModuleMigrationDiscoveryIssueCode,
moduleId: ModuleId,
message: string,
pathValue?: string,
): ModuleMigrationDiscoveryIssue {
return { code, message, moduleId, ...(pathValue ? { path: pathValue } : {}) };
}
function pathIsInside(parent: string, candidate: string): boolean {
const relative = path.relative(parent, candidate);
return (
relative !== "" &&
!relative.startsWith(`..${path.sep}`) &&
relative !== ".." &&
!path.isAbsolute(relative)
);
}
async function readSafeFile(
root: string,
relativePath: string,
moduleId: ModuleId,
issues: ModuleMigrationDiscoveryIssue[],
logicalPath: string,
): Promise<Uint8Array | undefined> {
const absolutePath = path.join(root, relativePath);
if (!pathIsInside(root, absolutePath)) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration metadata contains an unsafe path.`,
logicalPath,
),
);
return undefined;
}
try {
const fileStats = await lstat(absolutePath);
if (fileStats.isSymbolicLink()) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration files must not be symbolic links.`,
logicalPath,
),
);
return undefined;
}
if (!fileStats.isFile()) {
issues.push(
issue(
"MODULE_MIGRATION_FILE_INVALID",
moduleId,
`Module ${moduleId} migration inventory contains a non-file entry.`,
logicalPath,
),
);
return undefined;
}
const canonicalPath = await realpath(absolutePath);
if (!pathIsInside(root, canonicalPath)) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration files must remain inside the migration directory.`,
logicalPath,
),
);
return undefined;
}
return await readFile(canonicalPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
issues.push(
issue(
code === "ENOENT" ? "MODULE_MIGRATION_FILE_MISSING" : "MODULE_MIGRATION_FILE_INVALID",
moduleId,
code === "ENOENT"
? `Module ${moduleId} is missing a required migration file.`
: `Module ${moduleId} migration file could not be read.`,
logicalPath,
),
);
return undefined;
}
}
async function readInventory(
root: string,
moduleId: ModuleId,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<DirectoryInventory | undefined> {
let rootEntries: Dirent[];
try {
rootEntries = (await readdir(root, { withFileTypes: true })).sort((left, right) =>
compareText(left.name, right.name),
);
} catch {
issues.push(
issue(
"MODULE_MIGRATION_DIRECTORY_INVALID",
moduleId,
`Module ${moduleId} migration directory could not be inspected.`,
),
);
return undefined;
}
const rootNames = new Set(rootEntries.map((entry) => entry.name));
rootEntries.forEach((entry, index) => {
if (entry.name === DRIZZLE_MIGRATION_META_DIRECTORY) {
return;
}
if (entry.isSymbolicLink()) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration files must not be symbolic links.`,
`files.${index}`,
),
);
return;
}
if (sqlFilenamePattern.test(entry.name)) {
if (!entry.isFile()) {
issues.push(
issue(
"MODULE_MIGRATION_FILE_INVALID",
moduleId,
`Module ${moduleId} migration inventory contains a non-file entry.`,
`files.${index}`,
),
);
}
return;
}
issues.push(
issue(
"MODULE_MIGRATION_UNSUPPORTED_FILE",
moduleId,
`Module ${moduleId} migration directory contains an unsupported entry.`,
`files.${index}`,
),
);
});
const metaPath = path.join(root, DRIZZLE_MIGRATION_META_DIRECTORY);
try {
const metaStats = await lstat(metaPath);
if (metaStats.isSymbolicLink()) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration metadata directory must not be a symbolic link.`,
"meta",
),
);
return { metaEntries: new Set(), rootEntries: rootNames };
}
if (!metaStats.isDirectory()) {
issues.push(
issue(
"MODULE_MIGRATION_DIRECTORY_INVALID",
moduleId,
`Module ${moduleId} migration metadata entry must be a directory.`,
"meta",
),
);
return { metaEntries: new Set(), rootEntries: rootNames };
}
const canonicalMetaPath = await realpath(metaPath);
if (!pathIsInside(root, canonicalMetaPath)) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration metadata must remain inside the migration directory.`,
"meta",
),
);
return { metaEntries: new Set(), rootEntries: rootNames };
}
const metaEntries = (await readdir(canonicalMetaPath, { withFileTypes: true })).sort(
(left, right) => compareText(left.name, right.name),
);
const metaNames = new Set(metaEntries.map((entry) => entry.name));
metaEntries.forEach((entry, index) => {
if (entry.isSymbolicLink()) {
issues.push(
issue(
"MODULE_MIGRATION_PATH_UNSAFE",
moduleId,
`Module ${moduleId} migration metadata files must not be symbolic links.`,
`meta.files.${index}`,
),
);
return;
}
if (
entry.name === DRIZZLE_MIGRATION_JOURNAL_FILENAME ||
entry.name === EKKLESIA_MIGRATION_POLICY_FILENAME ||
snapshotFilenamePattern.test(entry.name)
) {
if (!entry.isFile()) {
issues.push(
issue(
"MODULE_MIGRATION_FILE_INVALID",
moduleId,
`Module ${moduleId} migration metadata contains a non-file entry.`,
`meta.files.${index}`,
),
);
}
return;
}
issues.push(
issue(
"MODULE_MIGRATION_UNSUPPORTED_FILE",
moduleId,
`Module ${moduleId} migration metadata contains an unsupported entry.`,
`meta.files.${index}`,
),
);
});
return { metaEntries: metaNames, rootEntries: rootNames };
} catch {
issues.push(
issue(
"MODULE_MIGRATION_DIRECTORY_INVALID",
moduleId,
`Module ${moduleId} migration metadata directory could not be inspected.`,
"meta",
),
);
return { metaEntries: new Set(), rootEntries: rootNames };
}
}
function parseJson(bytes: Uint8Array): unknown {
return JSON.parse(utf8Decoder.decode(bytes)) as unknown;
}
function validateJournalOrdering(
moduleId: ModuleId,
journal: DrizzleMigrationJournal,
issues: ModuleMigrationDiscoveryIssue[],
): void {
const seenTags = new Set<string>();
let previousTimestamp = -1;
journal.entries.forEach((entry, position) => {
const entryPath = `journal.entries.${position}`;
if (entry.idx !== position) {
issues.push(
issue(
"MODULE_MIGRATION_ORDER_INVALID",
moduleId,
`Module ${moduleId} migration indices must be contiguous and journal-ordered.`,
`${entryPath}.idx`,
),
);
}
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
issues.push(
issue(
"MODULE_MIGRATION_ID_INVALID",
moduleId,
`Module ${moduleId} contains an invalid migration identity.`,
`${entryPath}.tag`,
),
);
} else if (!entry.tag.startsWith(`${entry.idx.toString().padStart(4, "0")}_`)) {
issues.push(
issue(
"MODULE_MIGRATION_ID_INVALID",
moduleId,
`Module ${moduleId} migration identities must match their journal indices.`,
`${entryPath}.tag`,
),
);
}
if (seenTags.has(entry.tag)) {
issues.push(
issue(
"MODULE_MIGRATION_ID_DUPLICATE",
moduleId,
`Module ${moduleId} declares the same migration identity more than once.`,
`${entryPath}.tag`,
),
);
}
seenTags.add(entry.tag);
if (entry.when <= previousTimestamp) {
issues.push(
issue(
"MODULE_MIGRATION_ORDER_INVALID",
moduleId,
`Module ${moduleId} migration timestamps must increase with journal order.`,
`${entryPath}.when`,
),
);
}
previousTimestamp = entry.when;
});
}
function validatePolicy(
moduleId: ModuleId,
journal: DrizzleMigrationJournal,
policy: ModuleMigrationPolicy,
issues: ModuleMigrationDiscoveryIssue[],
): void {
if (policy.moduleId !== moduleId) {
issues.push(
issue(
"MODULE_MIGRATION_METADATA_INCONSISTENT",
moduleId,
`Module ${moduleId} migration policy does not match its manifest ID.`,
"policy.moduleId",
),
);
}
if (policy.entries.length !== journal.entries.length) {
issues.push(
issue(
"MODULE_MIGRATION_METADATA_INCONSISTENT",
moduleId,
`Module ${moduleId} migration policy must match the journal inventory.`,
"policy.entries",
),
);
}
const seenTags = new Set<string>();
policy.entries.forEach((entry, index) => {
if (seenTags.has(entry.tag)) {
issues.push(
issue(
"MODULE_MIGRATION_METADATA_INCONSISTENT",
moduleId,
`Module ${moduleId} migration policy contains a duplicate entry.`,
`policy.entries.${index}.tag`,
),
);
}
seenTags.add(entry.tag);
if (entry.tag !== journal.entries[index]?.tag) {
issues.push(
issue(
"MODULE_MIGRATION_METADATA_INCONSISTENT",
moduleId,
`Module ${moduleId} migration policy must preserve journal identity and order.`,
`policy.entries.${index}.tag`,
),
);
}
});
}
async function validateSnapshots(
root: string,
moduleId: ModuleId,
journal: DrizzleMigrationJournal,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<void> {
const seenIds = new Set<string>();
let previousId: string | undefined = DRIZZLE_MIGRATION_ORIGIN_UUID;
for (const [position, entry] of journal.entries.entries()) {
const prefix = entry.idx.toString().padStart(4, "0");
const bytes = await readSafeFile(
root,
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${prefix}_snapshot.json`,
moduleId,
issues,
`snapshots.${position}`,
);
if (!bytes) {
previousId = undefined;
continue;
}
let snapshot: DrizzleMigrationSnapshotHeader;
try {
snapshot = parseDrizzleMigrationSnapshotHeader(parseJson(bytes));
} catch {
issues.push(
issue(
"MODULE_MIGRATION_SNAPSHOT_INVALID",
moduleId,
`Module ${moduleId} contains invalid Drizzle snapshot metadata.`,
`snapshots.${position}`,
),
);
previousId = undefined;
continue;
}
if (
snapshot.id === DRIZZLE_MIGRATION_ORIGIN_UUID ||
seenIds.has(snapshot.id) ||
(previousId !== undefined && snapshot.prevId !== previousId)
) {
issues.push(
issue(
"MODULE_MIGRATION_SNAPSHOT_INVALID",
moduleId,
`Module ${moduleId} Drizzle snapshots must form one ordered identity chain.`,
`snapshots.${position}`,
),
);
}
seenIds.add(snapshot.id);
previousId = snapshot.id;
}
}
function validateInventory(
moduleId: ModuleId,
inventory: DirectoryInventory,
journal: DrizzleMigrationJournal,
issues: ModuleMigrationDiscoveryIssue[],
): void {
const expectedSql = new Set(
journal.entries
.filter((entry) => moduleMigrationTagSchema.safeParse(entry.tag).success)
.map((entry) => `${entry.tag}.sql`),
);
const expectedSnapshots = new Set(
journal.entries.map((entry) => `${entry.idx.toString().padStart(4, "0")}_snapshot.json`),
);
const undeclaredSql = [...inventory.rootEntries]
.filter((name) => sqlFilenamePattern.test(name) && !expectedSql.has(name))
.sort(compareText);
undeclaredSql.forEach((_name, index) => {
issues.push(
issue(
"MODULE_MIGRATION_FILE_UNDECLARED",
moduleId,
`Module ${moduleId} contains a SQL migration absent from its journal.`,
`undeclaredSql.${index}`,
),
);
});
const undeclaredSnapshots = [...inventory.metaEntries]
.filter((name) => snapshotFilenamePattern.test(name) && !expectedSnapshots.has(name))
.sort(compareText);
undeclaredSnapshots.forEach((_name, index) => {
issues.push(
issue(
"MODULE_MIGRATION_FILE_UNDECLARED",
moduleId,
`Module ${moduleId} contains a snapshot absent from its journal.`,
`undeclaredSnapshots.${index}`,
),
);
});
}
async function inspectModuleMigrations(
discovered: DiscoveredModule,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<readonly DiscoveredModuleMigration[]> {
const moduleId = discovered.manifest.id;
const migrationRoot = discovered.entrypoints.migrations;
if (!migrationRoot) {
return Object.freeze([]);
}
const issueCountBeforeModule = issues.length;
const inventory = await readInventory(migrationRoot, moduleId, issues);
const [journalBytes, policyBytes] = await Promise.all([
readSafeFile(
migrationRoot,
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${DRIZZLE_MIGRATION_JOURNAL_FILENAME}`,
moduleId,
issues,
"journal",
),
readSafeFile(
migrationRoot,
`${DRIZZLE_MIGRATION_META_DIRECTORY}/${EKKLESIA_MIGRATION_POLICY_FILENAME}`,
moduleId,
issues,
"policy",
),
]);
let journal: DrizzleMigrationJournal | undefined;
if (journalBytes) {
try {
journal = parseDrizzleMigrationJournal(parseJson(journalBytes));
} catch {
issues.push(
issue(
"MODULE_MIGRATION_JOURNAL_INVALID",
moduleId,
`Module ${moduleId} has invalid Drizzle migration journal metadata.`,
"journal",
),
);
}
}
let policy: ModuleMigrationPolicy | undefined;
if (policyBytes) {
try {
policy = parseModuleMigrationPolicy(parseJson(policyBytes));
} catch {
issues.push(
issue(
"MODULE_MIGRATION_METADATA_INVALID",
moduleId,
`Module ${moduleId} has invalid migration policy metadata.`,
"policy",
),
);
}
}
if (!journal) {
return Object.freeze([]);
}
validateJournalOrdering(moduleId, journal, issues);
if (policy) {
validatePolicy(moduleId, journal, policy, issues);
}
if (inventory) {
validateInventory(moduleId, inventory, journal, issues);
}
await validateSnapshots(migrationRoot, moduleId, journal, issues);
const migrations: DiscoveredModuleMigration[] = [];
for (const [position, entry] of journal.entries.entries()) {
if (!moduleMigrationTagSchema.safeParse(entry.tag).success) {
continue;
}
const sqlBytes = await readSafeFile(
migrationRoot,
`${entry.tag}.sql`,
moduleId,
issues,
`migrations.${position}.sql`,
);
if (!sqlBytes) {
continue;
}
const checksum = createHash("sha256").update(sqlBytes).digest("hex");
if (!/^[a-f0-9]{64}$/u.test(checksum)) {
issues.push(
issue(
"MODULE_MIGRATION_CHECKSUM_INVALID",
moduleId,
`Module ${moduleId} migration checksum could not be calculated.`,
`migrations.${position}.checksum`,
),
);
continue;
}
let sql: string;
try {
sql = utf8Decoder.decode(sqlBytes);
} catch {
issues.push(
issue(
"MODULE_MIGRATION_FILE_INVALID",
moduleId,
`Module ${moduleId} SQL migrations must be valid UTF-8 text.`,
`migrations.${position}.sql`,
),
);
continue;
}
if (sql.trim().length === 0) {
issues.push(
issue(
"MODULE_MIGRATION_EMPTY",
moduleId,
`Module ${moduleId} SQL migrations must not be empty.`,
`migrations.${position}.sql`,
),
);
continue;
}
const policyEntry = policy?.entries[position];
if (!policyEntry || policyEntry.tag !== entry.tag) {
continue;
}
const { destructive } = policyEntry;
migrations.push(
Object.freeze({
approvalRequired: destructive.mode === "approval-required",
breakpoints: entry.breakpoints,
checksum,
destructive: Object.freeze({ ...destructive }),
file: `${entry.tag}.sql`,
identity: `${moduleId}:${entry.tag}`,
index: entry.idx,
moduleId,
sql,
tag: entry.tag,
timestamp: entry.when,
}),
);
}
if (issues.length > issueCountBeforeModule) {
return Object.freeze([]);
}
return Object.freeze(migrations);
}
export async function discoverModuleMigrations(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
const discoveredModules = await discoverModules(options);
const issues: ModuleMigrationDiscoveryIssue[] = [];
const modules: DiscoveredModuleMigrations[] = [];
for (const discovered of discoveredModules) {
modules.push(
Object.freeze({
migrations: await inspectModuleMigrations(discovered, issues),
moduleId: discovered.manifest.id,
}),
);
}
if (issues.length > 0) {
throw new ModuleMigrationDiscoveryError(issues);
}
const frozenModules = Object.freeze(modules);
const migrations = Object.freeze(frozenModules.flatMap((module) => module.migrations));
return Object.freeze({ discoveredModules, migrations, modules: frozenModules });
}