Initial project outline

- package.json (ESM, bin entry)
- bin/delta-backup.js — entrypoint
- lib/args.js — CLI arg parsing via Node parseArgs
- lib/config.js — config file merging + required path guards
- lib/spawn.js — safe process spawning (no shell strings)
- lib/state.js — sequence number + phase state management
- lib/backends/zstd.js — zstd delta backend
- lib/backends/index.js — backend registry
- lib/commands/run.js — full run skeleton (phases 1-3 wired, 4-6 stubbed)
- lib/commands/status.js — status command
This commit is contained in:
2026-03-07 01:05:46 +00:00
parent 2fbdde541a
commit 33bd288f9e
11 changed files with 350 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

22
bin/delta-backup.js Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { parseArgs } from '../lib/args.js';
import { loadConfig } from '../lib/config.js';
import { runCommand } from '../lib/commands/run.js';
import { statusCommand } from '../lib/commands/status.js';
const args = parseArgs(process.argv.slice(2));
const config = await loadConfig(args);
const commands = {
run: runCommand,
status: statusCommand,
};
const cmd = commands[config.command];
if (!cmd) {
console.error(`Unknown command: ${config.command}`);
console.error('Available commands: run, status');
process.exit(1);
}
await cmd(config);

53
lib/args.js Normal file
View File

@@ -0,0 +1,53 @@
/**
* CLI argument parsing — no external deps, uses Node's parseArgs.
*/
import { parseArgs as nodeParseArgs } from 'util';
const HELP = `
Usage: delta-backup [options] <command>
Commands:
run Full backup run (clear PEND, rsync, delta, commit, promote)
status Show current state
Options:
--source <path> SOURCE directory (required)
--prev <path> PREV directory (required)
--pend <path> PEND directory (required)
--deltas <path> DELTAS directory (required)
--backend <name> Delta backend: zstd (default), xdelta3
--config <file> Load options from JSON config file (flags override)
--dry-run Print what would happen, execute nothing
--help Show this help
`.trim();
export function parseArgs(argv) {
const { values, positionals } = nodeParseArgs({
args: argv,
options: {
source: { type: 'string' },
prev: { type: 'string' },
pend: { type: 'string' },
deltas: { type: 'string' },
backend: { type: 'string' },
config: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
allowPositionals: true,
});
if (values.help) {
console.log(HELP);
process.exit(0);
}
const command = positionals[0];
if (!command) {
console.error('Error: command required (run, status)\n');
console.error(HELP);
process.exit(1);
}
return { ...values, command, dryRun: values['dry-run'] };
}

12
lib/backends/index.js Normal file
View File

@@ -0,0 +1,12 @@
import * as zstd from './zstd.js';
const BACKENDS = { zstd };
export function getBackend(name) {
const backend = BACKENDS[name];
if (!backend) {
console.error(`Unknown backend: ${name}. Available: ${Object.keys(BACKENDS).join(', ')}`);
process.exit(1);
}
return backend;
}

35
lib/backends/zstd.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* zstd delta backend.
*
* Modified files: zstd --patch-from=prev new -o out.zst
* New files: zstd new -o out.zst (no base, just compress)
* Deleted files: no delta file, manifest entry only
*/
import { run } from '../spawn.js';
export const name = 'zstd';
export const ext = '.zst';
/**
* Create a delta from prevFile to newFile, output to outFile.
* If prevFile is null, newFile is new — compress without a base.
*/
export async function createDelta(prevFile, newFile, outFile, { dryRun } = {}) {
if (prevFile) {
await run('zstd', ['--patch-from', prevFile, newFile, '-o', outFile, '-f'], { dryRun });
} else {
await run('zstd', [newFile, '-o', outFile, '-f'], { dryRun });
}
}
/**
* Apply a delta on top of prevFile to produce outFile.
* If prevFile is null, the delta is a plain compressed file.
*/
export async function applyDelta(prevFile, deltaFile, outFile, { dryRun } = {}) {
if (prevFile) {
await run('zstd', ['-d', '--patch-from', prevFile, deltaFile, '-o', outFile, '-f'], { dryRun });
} else {
await run('zstd', ['-d', deltaFile, '-o', outFile, '-f'], { dryRun });
}
}

76
lib/commands/run.js Normal file
View File

@@ -0,0 +1,76 @@
/**
* run command — full backup run.
*/
import { rm, mkdir } from 'fs/promises';
import { join } from 'path';
import { run as spawn } from '../spawn.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 } = config;
const backend = getBackend(backendName);
const dry = dryRun;
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 handle partially-committed previous run
// ── Phase 1: Clear PEND ─────────────────────────────────────
await setPhase(deltas, state, PHASES.CLEARING_PEND, dry);
console.log('\n── Clear PEND ──');
if (!dry) {
await rm(pend, { recursive: true, force: true });
await mkdir(pend, { recursive: true });
} else {
console.log(`[dry-run] rm -rf ${pend} && mkdir -p ${pend}`);
}
// ── Phase 2: rsync PREV → PEND (local seed) ─────────────────
await setPhase(deltas, state, PHASES.RSYNC_LOCAL, dry);
console.log('\n── rsync PREV → PEND (local seed) ──');
await spawn('rsync', ['-aP', trailingSlash(prev), pend], { dryRun: dry });
// ── Phase 3: rsync SOURCE → PEND (remote changes) ───────────
await setPhase(deltas, state, PHASES.RSYNC_REMOTE, dry);
console.log('\n── rsync SOURCE → PEND ──');
await spawn('rsync', ['-aP', trailingSlash(source), pend], { dryRun: dry });
// ── Phase 4: Generate delta ──────────────────────────────────
await setPhase(deltas, state, PHASES.GENERATING, dry);
console.log('\n── Generate delta ──');
// TODO: walk PREV and PEND, diff per file, build manifest
// ── Phase 5: Commit delta ────────────────────────────────────
await setPhase(deltas, state, PHASES.COMMITTING, dry);
console.log('\n── Commit delta ──');
// TODO: atomic rename DELTAS/tmp/N → DELTAS/N
// ── Phase 6: Promote PEND → PREV ────────────────────────────
await setPhase(deltas, state, PHASES.PROMOTING, dry);
console.log('\n── Promote PEND → PREV ──');
// TODO: mv PEND PREV (swap)
// ── 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.`);
}
async function setPhase(deltas, state, phase, dry) {
state.phase = phase;
if (!dry) await writeState(deltas, state);
}
function trailingSlash(p) {
return p.endsWith('/') ? p : p + '/';
}

28
lib/commands/status.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* status command — show current state.
*/
import { readState, PHASES } from '../state.js';
export async function statusCommand(config) {
const { deltas } = config;
if (!deltas) {
console.error('Error: --deltas required for status command');
process.exit(1);
}
const state = await readState(deltas);
console.log('\ndelta-backup status');
console.log('='.repeat(40));
console.log(` Last complete seq: ${state.last_complete}`);
console.log(` Next seq: ${state.next_seq}`);
console.log(` Current phase: ${state.phase}`);
if (state.phase !== PHASES.IDLE) {
console.log('\n ⚠ Previous run did not complete cleanly.');
console.log(' Run with same options to recover.');
}
console.log('');
}

43
lib/config.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* Config loading — merges JSON config file with CLI args.
* CLI args always win. Required paths are validated here.
*/
import { readFile } from 'fs/promises';
const REQUIRED_PATHS = ['source', 'prev', 'pend', 'deltas'];
const DEFAULTS = {
backend: 'zstd',
};
export async function loadConfig(args) {
let fileConfig = {};
if (args.config) {
try {
const raw = await readFile(args.config, 'utf8');
fileConfig = JSON.parse(raw);
} catch (err) {
console.error(`Error reading config file ${args.config}: ${err.message}`);
process.exit(1);
}
}
// CLI args override file config, file config overrides defaults
const config = { ...DEFAULTS, ...fileConfig, ...filterDefined(args) };
// Guard: refuse to run if any required path is missing
if (config.command === 'run') {
const missing = REQUIRED_PATHS.filter(k => !config[k]);
if (missing.length > 0) {
console.error(`Error: missing required options: ${missing.map(k => `--${k}`).join(', ')}`);
console.error('Provide them as CLI flags or in a --config JSON file.');
process.exit(1);
}
}
return config;
}
function filterDefined(obj) {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
}

28
lib/spawn.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* Process spawning — always explicit argument arrays, never shell strings.
*/
import { spawn } from 'child_process';
/**
* Spawn a process and stream its output.
* @param {string} cmd
* @param {string[]} args
* @param {{ dryRun?: boolean, label?: string }} opts
* @returns {Promise<void>}
*/
export async function run(cmd, args, { dryRun = false, label } = {}) {
const display = [cmd, ...args].join(' ');
if (label) console.log(`[${label}] ${display}`);
else console.log(`$ ${display}`);
if (dryRun) return;
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: 'inherit' });
child.on('error', reject);
child.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`${cmd} exited with code ${code}`));
});
});
}

40
lib/state.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* State management — tracks sequence numbers and run phases in DELTAS/state.json.
* The state file is a recoverable cache; source of truth is the DELTAS directory.
*/
import { readFile, writeFile } from 'fs/promises';
import { join } from 'path';
const STATE_FILE = 'state.json';
export async function readState(deltasDir) {
const path = join(deltasDir, STATE_FILE);
try {
const raw = await readFile(path, 'utf8');
return JSON.parse(raw);
} catch {
// Missing or unreadable — reconstruct from directory scan
return reconstructState(deltasDir);
}
}
export async function writeState(deltasDir, state) {
const path = join(deltasDir, STATE_FILE);
await writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8');
}
async function reconstructState(deltasDir) {
// TODO: scan DELTAS for highest committed sequence number
// For now, start fresh
return { next_seq: 1, last_complete: 0, phase: 'idle' };
}
export const PHASES = {
IDLE: 'idle',
CLEARING_PEND: 'clearing_pend',
RSYNC_LOCAL: 'rsync_local',
RSYNC_REMOTE: 'rsync_remote',
GENERATING: 'generating_delta',
COMMITTING: 'committing',
PROMOTING: 'promoting_prev',
};

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "delta-backup",
"version": "0.1.0",
"type": "module",
"description": "Space-efficient directory backups using binary deltas",
"bin": {
"delta-backup": "./bin/delta-backup.js"
},
"scripts": {
"start": "node bin/delta-backup.js"
}
}