/** * run command — full backup run. */ import { mkdir, rename, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { run as spawn, rsync } from '../spawn.js'; import { parseItemize } from '../itemize.js'; import { getBackend } from '../backends/index.js'; import { readState, writeState, PHASES } from '../state.js'; export async function runCommand(config) { const { source, prev, pend, deltas, backend: backendName, dryRun: dry } = config; const backend = getBackend(backendName); if (dry) console.log('[dry-run] No changes will be made.\n'); // ── Load state ────────────────────────────────────────────── const state = await readState(deltas); const seq = state.next_seq; console.log(`Starting run — seq ${seq} (last complete: ${state.last_complete})`); // TODO: detect and recover from partially-committed previous run // ── Phase 1: Ensure PEND exists ───────────────────────────── await setPhase(deltas, state, PHASES.CLEARING_PEND, dry); if (!dry) { await mkdir(pend, { recursive: true }); } else { console.log(`[dry-run] mkdir -p ${pend}`); } // ── Phase 2: rsync PREV → PEND (local seed, with delete) ──── await setPhase(deltas, state, PHASES.RSYNC_LOCAL, dry); console.log('\n── rsync PREV → PEND (local seed) ──'); await rsync(['-aP', '--delete', trailingSlash(prev), trailingSlash(pend)], { dryRun: dry }); // ── Phase 3: rsync SOURCE → PEND, capture change list ─────── await setPhase(deltas, state, PHASES.RSYNC_REMOTE, dry); console.log('\n── rsync SOURCE → PEND ──'); const output = await rsync( ['-aP', '--itemize-changes', '--delete', trailingSlash(source), trailingSlash(pend)], { dryRun: dry, capture: true }, ); const changes = dry ? [] : parseItemize(output); if (!dry) { console.log(` ${changes.length} file(s) changed`); for (const c of changes) console.log(` [${c.status}] ${c.path}`); } else { console.log(' [dry-run] change list determined at runtime'); } // ── Phase 4: Generate per-file deltas into DELTAS/tmp/N/files/ await setPhase(deltas, state, PHASES.GENERATING, dry); console.log('\n── Generate delta ──'); const tmpDir = join(deltas, 'tmp', String(seq)); const filesDir = join(tmpDir, 'files'); const tarFile = join(tmpDir, 'delta.tar'); const bundleFile = join(tmpDir, 'delta.tar.zst'); if (!dry) { await mkdir(filesDir, { recursive: true }); } else { console.log(`[dry-run] mkdir -p ${filesDir}`); } const manifestChanges = []; let fileIndex = 0; for (const change of changes) { if (change.status === 'deleted') { manifestChanges.push({ path: change.path, status: 'deleted' }); continue; } const deltaFilename = `${fileIndex}${backend.ext}`; const outFile = join(filesDir, deltaFilename); const prevFile = join(prev, change.path); const newFile = join(pend, change.path); console.log(` [${change.status}] ${change.path}`); if (!dry) { await backend.createDelta( change.status === 'modified' ? prevFile : null, newFile, outFile, ); } else { console.log(`[dry-run] ${change.status === 'modified' ? `zstd --patch-from ${prevFile} ${newFile} -o ${outFile}` : `zstd ${newFile} -o ${outFile}`}`); } manifestChanges.push({ path: change.path, status: change.status, delta: deltaFilename, }); fileIndex++; } // ── Bundle: tar files/ → delta.tar → delta.tar.zst ────────── console.log('\n── Bundle deltas ──'); // tar with -C so paths inside the archive are relative (just filenames) await spawn('tar', ['cf', tarFile, '-C', filesDir, '.'], { dryRun: dry }); await spawn('zstd', [tarFile, '-o', bundleFile, '-f'], { dryRun: dry }); if (!dry) { await rm(filesDir, { recursive: true }); await rm(tarFile); } else { console.log(`[dry-run] rm -rf ${filesDir} ${tarFile}`); } // ── Phase 5: Write manifest + atomic commit ────────────────── await setPhase(deltas, state, PHASES.COMMITTING, dry); console.log('\n── Commit delta ──'); const manifest = { seq, timestamp: new Date().toISOString(), prev_seq: state.last_complete, backend: backendName, bundle: 'delta.tar.zst', changes: manifestChanges, }; const seqDir = join(deltas, String(seq)); if (!dry) { await writeFile(join(tmpDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); // Atomic rename: tmp/N → N await rename(tmpDir, seqDir); console.log(` Committed to ${seqDir}`); } else { console.log(`[dry-run] write manifest to ${tmpDir}/manifest.json`); console.log(`[dry-run] rename ${tmpDir} → ${seqDir}`); } // ── Phase 6: Promote PEND → PREV ──────────────────────────── await setPhase(deltas, state, PHASES.PROMOTING, dry); console.log('\n── Promote PEND → PREV ──'); await rsync(['-aP', '--delete', trailingSlash(pend), trailingSlash(prev)], { dryRun: dry }); // ── Done ───────────────────────────────────────────────────── state.last_complete = seq; state.next_seq = seq + 1; state.phase = PHASES.IDLE; if (!dry) await writeState(deltas, state); console.log(`\nRun complete — seq ${seq} committed. ${manifestChanges.length} file(s) in delta.`); } async function setPhase(deltas, state, phase, dry) { state.phase = phase; if (!dry) await writeState(deltas, state); } function trailingSlash(p) { return p.endsWith('/') ? p : p + '/'; }