- spawn.js: rsync() wrapper handles exit codes 0/24 as OK, 23 as fatal - spawn.js: capture() accepts allowedExitCodes option - run.js: all rsync calls go through rsync() wrapper - PLAN.md: document planned operation abstraction refactor
148 lines
5.2 KiB
JavaScript
148 lines
5.2 KiB
JavaScript
/**
|
|
* run command — full backup run.
|
|
*/
|
|
import { mkdir, 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/ ────
|
|
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 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 + '/';
|
|
}
|