Files
autodoc/core/snapshots.js
T
Iisyourdad 3c51cf6f81 Add archive layer: zip, .sfgz share files, locks, snapshots
- Hand-rolled ZIP writer/reader (node:zlib only) with CRC verification,
  UTF-8 names, store/deflate, path-traversal validation; verified
  interoperable with system unzip
- .sfgz guide archives: export, copy-import (fresh ids, remapped
  substeps), linked-import with explicit write-back save
- Advisory .lock-sfgz sidecar locks with stale detection and force-steal
- Snapshot backups with pruning and undoable restore
- 7 more workflow tests (19 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:38:30 -05:00

70 lines
2.6 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { zipDirSync, extractZipSync } = require('./zip');
const { atomicWriteFileSync } = require('./util');
/**
* Snapshot backups: a zip of the guide directory (excluding history/) stored
* under <guide>/history/snapshots/. Used for automated backups and manual
* backup/restore.
*/
function snapshotsDir(store, guideId) {
return path.join(store.guideDir(guideId), 'history', 'snapshots');
}
function snapshotName(label) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
}
function createSnapshot(store, guideId, { label = '', keepLast = 0 } = {}) {
const guideDir = store.guideDir(guideId);
if (!fs.existsSync(path.join(guideDir, 'guide.json'))) throw new Error(`guide not found: ${guideId}`);
const buf = zipDirSync(guideDir, {
filter: (rel) => rel !== 'history' && !rel.startsWith('history/'),
});
const dir = snapshotsDir(store, guideId);
fs.mkdirSync(dir, { recursive: true });
const name = snapshotName(label);
atomicWriteFileSync(path.join(dir, name), buf);
if (keepLast > 0) pruneSnapshots(store, guideId, keepLast);
return name;
}
function listSnapshots(store, guideId) {
const dir = snapshotsDir(store, guideId);
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).sort().reverse();
}
function pruneSnapshots(store, guideId, keepLast) {
const all = listSnapshots(store, guideId);
for (const name of all.slice(keepLast)) {
fs.rmSync(path.join(snapshotsDir(store, guideId), name), { force: true });
}
}
/**
* Restore a snapshot: replaces the guide's current content (guide.json and
* steps/) with the snapshot's, keeping the history/ directory intact.
*/
function restoreSnapshot(store, guideId, name) {
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
const buf = fs.readFileSync(file);
const guideDir = store.guideDir(guideId);
// Safety: snapshot the pre-restore state too, so a restore is undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
for (const entry of fs.readdirSync(guideDir)) {
if (entry === 'history') continue;
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
}
extractZipSync(buf, guideDir);
return store.getGuide(guideId);
}
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };