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>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user