Files
delta-backup/lib/commands/run.js

154 lines
5.4 KiB
JavaScript

/**
* 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 + '/';
}