/** * run command — full backup run. */ import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import { run as spawn, capture } 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 spawn('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 ──'); let changes = []; if (!dry) { const rsyncArgs = [ '-aP', '--itemize-changes', '--delete', trailingSlash(source), trailingSlash(pend), ]; const output = await capture('rsync', rsyncArgs); changes = parseItemize(output); console.log(` ${changes.length} file(s) changed`); for (const c of changes) console.log(` [${c.status}] ${c.path}`); } else { console.log(`$ rsync -aP --itemize-changes --delete ${trailingSlash(source)} ${trailingSlash(pend)}`); console.log('[dry-run] (change list will be determined at runtime)'); } // ── Phase 4: Generate per-file deltas into DELTAS/tmp/N/ ──── await setPhase(deltas, state, PHASES.GENERATING, dry); console.log('\n── Generate delta ──'); const tmpDir = join(deltas, 'tmp', String(seq)); const filesDir = join(tmpDir, 'files'); if (!dry) { await mkdir(filesDir, { recursive: true }); } else { console.log(`[dry-run] mkdir -p ${filesDir}`); } const manifestChanges = []; for (const change of changes) { const deltaFilename = change.path.replaceAll('/', '__') + backend.ext; const outFile = join(filesDir, deltaFilename); if (change.status === 'deleted') { manifestChanges.push({ path: change.path, status: 'deleted' }); continue; } 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: join('files', deltaFilename), }); } // ── 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, 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 spawn('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 + '/'; }