import { readFile, readdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; const mode = process.argv[2]; const root = resolve(import.meta.dirname, "../../.."); const migrationsDirectory = resolve(root, "packages/database/migrations"); const generatedRegistryPath = resolve( root, "packages/database/src/migration-artifacts.generated.ts", ); if (mode !== "write" && mode !== "check") { throw new Error("Usage: tsx packages/database/scripts/migration-artifacts.ts "); } const sourceArtifacts = await readMigrationSourceArtifacts(); const expectedRegistry = renderGeneratedRegistry(sourceArtifacts); if (mode === "write") { // SQL files are the immutable migration sources. Never regenerate or overwrite them from the // current schema catalog; only refresh the registry embedded in the production bundle. await writeFile(generatedRegistryPath, expectedRegistry, "utf8"); } else { const currentRegistry = await readFile(generatedRegistryPath, "utf8").catch(() => undefined); if (currentRegistry !== expectedRegistry) { throw new Error("Database migration registry is out of date. Run pnpm db:migrations:write."); } } interface SourceMigrationArtifact { readonly content: string; readonly filename: string; readonly migrationId: string; readonly path: string; } async function readMigrationSourceArtifacts(): Promise { const filenames = (await readdir(migrationsDirectory)) .filter((filename) => filename.endsWith(".sql")) .sort((left, right) => left.localeCompare(right)); const artifacts = await Promise.all( filenames.map(async (filename): Promise => { const match = /^(\d{4}_[a-z0-9_]+)\.(postgres|tidb)\.sql$/u.exec(filename); if (!match) { throw new Error(`Invalid database migration filename: ${filename}`); } const migrationId = match[1]; const dialect = match[2]; if (!migrationId || !dialect) { throw new Error(`Invalid database migration filename: ${filename}`); } const content = await readFile(resolve(migrationsDirectory, filename), "utf8"); if (!content.includes(`-- Migration id: ${migrationId}\n`)) { throw new Error(`Migration ${filename} has a mismatched migration id header`); } if (!content.includes(`-- Dialect: ${dialect}\n`)) { throw new Error(`Migration ${filename} has a mismatched dialect header`); } return { content, filename, migrationId, path: `packages/database/migrations/${filename}`, }; }), ); const dialectsByMigration = new Map>(); for (const artifact of artifacts) { const dialect = artifact.filename.split(".").at(-2); const dialects = dialectsByMigration.get(artifact.migrationId) ?? new Set(); dialects.add(dialect ?? ""); dialectsByMigration.set(artifact.migrationId, dialects); } for (const [migrationId, dialects] of dialectsByMigration) { if (!dialects.has("postgres") || !dialects.has("tidb") || dialects.size !== 2) { throw new Error(`Migration ${migrationId} must provide exactly postgres and tidb artifacts`); } } return artifacts; } function renderGeneratedRegistry(artifacts: readonly SourceMigrationArtifact[]): string { const rows = artifacts.map( (artifact) => ` { content: ${JSON.stringify(artifact.content)}, path: ${JSON.stringify(artifact.path)} },`, ); return [ "// Generated by packages/database/scripts/migration-artifacts.ts. Do not edit manually.", 'import type { MigrationArtifact } from "./migration-file";', "", "// biome-ignore format: one deterministic row per immutable SQL artifact keeps generation cheap.", "export const migrationArtifacts = [", ...rows, "] as const satisfies readonly MigrationArtifact[];", "", ].join("\n"); }