Files
cfw-autumn/scripts/db/commands/rebase.ts
2026-05-18 20:48:51 +01:00

181 lines
5.8 KiB
TypeScript

import { mkdtempSync, copyFileSync, existsSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { run } from "../helpers/spawn.ts";
import { REPO_ROOT, MIGRATIONS_DIR, META_DIR, JOURNAL_PATH } from "../helpers/paths.ts";
type JournalEntry = {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
};
type Journal = {
version: string;
dialect: string;
entries: JournalEntry[];
};
const REMOTE_REF = "origin/dev";
export async function cmdRebase(): Promise<void> {
// 1. Bail on unresolved schema conflicts.
const unmerged = await run("git", ["diff", "--name-only", "--diff-filter=U"], {
cwd: REPO_ROOT,
stdio: "pipe",
});
if (unmerged.code !== 0) {
console.error(`git diff failed:\n${unmerged.stderr}`);
process.exit(1);
}
const schemaConflicts = unmerged.stdout
.split("\n")
.filter((line) => line.startsWith("shared/db/"));
if (schemaConflicts.length > 0) {
console.error(
`unresolved schema conflicts — resolve these first:\n${schemaConflicts.map((f) => ` ${f}`).join("\n")}`,
);
process.exit(1);
}
// 2. Read local + remote journals.
const localJournal = readLocalJournal();
const remoteJournal = await readRemoteJournal();
if (!remoteJournal) {
console.log(`nothing to rebase — could not read ${REMOTE_REF}:shared/drizzle/meta/_journal.json (no remote yet?)`);
return;
}
// 3. Find orphaned local entries — same idx as remote, but different tag/when.
const remoteByIdx = new Map<number, JournalEntry>();
for (const entry of remoteJournal.entries) remoteByIdx.set(entry.idx, entry);
const orphans: JournalEntry[] = [];
for (const entry of localJournal.entries) {
const remoteEntry = remoteByIdx.get(entry.idx);
if (!remoteEntry) continue;
if (remoteEntry.tag !== entry.tag || remoteEntry.when !== entry.when) {
orphans.push(entry);
}
}
if (orphans.length === 0) {
console.log("no migration conflicts — nothing to rebase");
return;
}
console.log(`found ${orphans.length} orphaned local migration(s):`);
for (const orphan of orphans) {
console.log(` idx=${orphan.idx} tag=${orphan.tag}`);
}
// 4. Back up the orphaned files.
const backupDir = mkdtempSync(join(tmpdir(), "autumn-db-rebase-"));
console.log(`backup dir: ${backupDir}`);
const backups: Array<{ src: string; backup: string }> = [];
for (const orphan of orphans) {
const sql = join(MIGRATIONS_DIR, `${orphan.tag}.sql`);
const snapshot = join(META_DIR, `${formatIdx(orphan.idx)}_snapshot.json`);
for (const src of [sql, snapshot]) {
if (!existsSync(src)) continue;
const backup = join(backupDir, src.replace(/[/\\]/g, "_"));
copyFileSync(src, backup);
backups.push({ src, backup });
}
}
// 5. Delete orphans, then restore canonical journal + snapshots from origin/dev.
for (const orphan of orphans) {
const sql = join(MIGRATIONS_DIR, `${orphan.tag}.sql`);
const snapshot = join(META_DIR, `${formatIdx(orphan.idx)}_snapshot.json`);
if (existsSync(sql)) rmSync(sql);
if (existsSync(snapshot)) rmSync(snapshot);
}
const remoteJournalRaw = await gitShow(`${REMOTE_REF}:shared/drizzle/meta/_journal.json`);
if (remoteJournalRaw === null) {
restoreBackups(backups);
console.error("failed to fetch remote journal — aborted, backups restored");
process.exit(1);
}
writeFileSync(JOURNAL_PATH, remoteJournalRaw);
for (const orphan of orphans) {
const snapshotPath = `${REMOTE_REF}:shared/drizzle/meta/${formatIdx(orphan.idx)}_snapshot.json`;
const remoteEntry = remoteByIdx.get(orphan.idx);
if (!remoteEntry) continue;
const snapshotRaw = await gitShow(snapshotPath);
if (snapshotRaw === null) {
restoreBackups(backups);
console.error(`failed to fetch ${snapshotPath} — aborted, backups restored`);
process.exit(1);
}
writeFileSync(join(META_DIR, `${formatIdx(orphan.idx)}_snapshot.json`), snapshotRaw);
}
// 6. Regenerate non-interactively. drizzle-kit reads stdin for rename prompts;
// closing stdin makes it fail fast rather than hang.
const generate = await run("bun", ["-F", "@autumn/shared", "db:generate"], {
cwd: REPO_ROOT,
stdio: "pipe",
});
if (generate.code !== 0) {
restoreBackups(backups);
console.error("`db:generate` failed during rebase — backups restored.");
console.error("");
console.error("Likely cause: drizzle-kit needs interactive input (e.g. column rename detection).");
console.error("Resolve manually:");
console.error(" 1. Run `bun db:generate` directly in a terminal and answer prompts.");
console.error(" 2. If a duplicate-idx file is produced, delete it and re-run.");
console.error("");
console.error("drizzle-kit stderr:");
console.error(generate.stderr);
process.exit(1);
}
console.log(generate.stdout);
// 7. Stage everything for the engineer.
await run("git", ["add", "shared/drizzle/"], { cwd: REPO_ROOT });
console.log("");
console.log("rebase complete — review the regenerated migration and commit.");
console.log(`backup retained at: ${backupDir}`);
}
function readLocalJournal(): Journal {
const raw = readFileSync(JOURNAL_PATH, "utf8");
return JSON.parse(raw) as Journal;
}
async function readRemoteJournal(): Promise<Journal | null> {
const raw = await gitShow(`${REMOTE_REF}:shared/drizzle/meta/_journal.json`);
if (raw === null) return null;
return JSON.parse(raw) as Journal;
}
async function gitShow(ref: string): Promise<string | null> {
const result = await run("git", ["show", ref], { cwd: REPO_ROOT, stdio: "pipe" });
if (result.code !== 0) return null;
return result.stdout;
}
function restoreBackups(backups: Array<{ src: string; backup: string }>): void {
for (const { src, backup } of backups) {
try {
copyFileSync(backup, src);
} catch {
// best-effort restore
}
}
}
function formatIdx(idx: number): string {
return idx.toString().padStart(4, "0");
}