Files

165 lines
5.8 KiB
JavaScript

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