Skip to content

Commit 8d7416b

Browse files
authored
feat: add codegraph snapshot for DB backup and restore (#192)
Adds save/restore/list/delete subcommands using VACUUM INTO for atomic WAL-free snapshots. Enables orchestrators and CI to checkpoint before refactoring passes and instantly rollback without full rebuilds. Impact: 7 functions changed, 5 affected
1 parent 36c6fdb commit 8d7416b

File tree

4 files changed

+468
-0
lines changed

4 files changed

+468
-0
lines changed

src/cli.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
registerRepo,
4141
unregisterRepo,
4242
} from './registry.js';
43+
import { snapshotDelete, snapshotList, snapshotRestore, snapshotSave } from './snapshot.js';
4344
import { checkForUpdates, printUpdateNotification } from './update-check.js';
4445
import { watchProject } from './watcher.js';
4546

@@ -83,6 +84,12 @@ function resolveNoTests(opts) {
8384
return config.query?.excludeTests || false;
8485
}
8586

87+
function formatSize(bytes) {
88+
if (bytes < 1024) return `${bytes} B`;
89+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
90+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
91+
}
92+
8693
program
8794
.command('build [dir]')
8895
.description('Parse repo and build graph in .codegraph/graph.db')
@@ -498,6 +505,81 @@ registry
498505
}
499506
});
500507

508+
// ─── Snapshot commands ──────────────────────────────────────────────────
509+
510+
const snapshot = program
511+
.command('snapshot')
512+
.description('Save and restore graph database snapshots');
513+
514+
snapshot
515+
.command('save <name>')
516+
.description('Save a snapshot of the current graph database')
517+
.option('-d, --db <path>', 'Path to graph.db')
518+
.option('--force', 'Overwrite existing snapshot')
519+
.action((name, opts) => {
520+
try {
521+
const result = snapshotSave(name, { dbPath: opts.db, force: opts.force });
522+
console.log(`Snapshot saved: ${result.name} (${formatSize(result.size)})`);
523+
} catch (err) {
524+
console.error(err.message);
525+
process.exit(1);
526+
}
527+
});
528+
529+
snapshot
530+
.command('restore <name>')
531+
.description('Restore a snapshot over the current graph database')
532+
.option('-d, --db <path>', 'Path to graph.db')
533+
.action((name, opts) => {
534+
try {
535+
snapshotRestore(name, { dbPath: opts.db });
536+
console.log(`Snapshot "${name}" restored.`);
537+
} catch (err) {
538+
console.error(err.message);
539+
process.exit(1);
540+
}
541+
});
542+
543+
snapshot
544+
.command('list')
545+
.description('List all saved snapshots')
546+
.option('-d, --db <path>', 'Path to graph.db')
547+
.option('-j, --json', 'Output as JSON')
548+
.action((opts) => {
549+
try {
550+
const snapshots = snapshotList({ dbPath: opts.db });
551+
if (opts.json) {
552+
console.log(JSON.stringify(snapshots, null, 2));
553+
} else if (snapshots.length === 0) {
554+
console.log('No snapshots found.');
555+
} else {
556+
console.log(`Snapshots (${snapshots.length}):\n`);
557+
for (const s of snapshots) {
558+
console.log(
559+
` ${s.name.padEnd(30)} ${formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`,
560+
);
561+
}
562+
}
563+
} catch (err) {
564+
console.error(err.message);
565+
process.exit(1);
566+
}
567+
});
568+
569+
snapshot
570+
.command('delete <name>')
571+
.description('Delete a saved snapshot')
572+
.option('-d, --db <path>', 'Path to graph.db')
573+
.action((name, opts) => {
574+
try {
575+
snapshotDelete(name, { dbPath: opts.db });
576+
console.log(`Snapshot "${name}" deleted.`);
577+
} catch (err) {
578+
console.error(err.message);
579+
process.exit(1);
580+
}
581+
});
582+
501583
// ─── Embedding commands ─────────────────────────────────────────────────
502584

503585
program

src/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ export {
110110
saveRegistry,
111111
unregisterRepo,
112112
} from './registry.js';
113+
// Snapshot management
114+
export {
115+
snapshotDelete,
116+
snapshotList,
117+
snapshotRestore,
118+
snapshotSave,
119+
snapshotsDir,
120+
validateSnapshotName,
121+
} from './snapshot.js';
113122
// Structure analysis
114123
export {
115124
buildStructure,

src/snapshot.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import Database from 'better-sqlite3';
4+
import { findDbPath } from './db.js';
5+
import { debug } from './logger.js';
6+
7+
const NAME_RE = /^[a-zA-Z0-9_-]+$/;
8+
9+
/**
10+
* Validate a snapshot name (alphanumeric, hyphens, underscores only).
11+
* Throws on invalid input.
12+
*/
13+
export function validateSnapshotName(name) {
14+
if (!name || !NAME_RE.test(name)) {
15+
throw new Error(
16+
`Invalid snapshot name "${name}". Use only letters, digits, hyphens, and underscores.`,
17+
);
18+
}
19+
}
20+
21+
/**
22+
* Return the snapshots directory for a given DB path.
23+
*/
24+
export function snapshotsDir(dbPath) {
25+
return path.join(path.dirname(dbPath), 'snapshots');
26+
}
27+
28+
/**
29+
* Save a snapshot of the current graph database.
30+
* Uses VACUUM INTO for an atomic, WAL-free copy.
31+
*
32+
* @param {string} name - Snapshot name
33+
* @param {object} [options]
34+
* @param {string} [options.dbPath] - Explicit path to graph.db
35+
* @param {boolean} [options.force] - Overwrite existing snapshot
36+
* @returns {{ name: string, path: string, size: number }}
37+
*/
38+
export function snapshotSave(name, options = {}) {
39+
validateSnapshotName(name);
40+
const dbPath = options.dbPath || findDbPath();
41+
if (!fs.existsSync(dbPath)) {
42+
throw new Error(`Database not found: ${dbPath}`);
43+
}
44+
45+
const dir = snapshotsDir(dbPath);
46+
const dest = path.join(dir, `${name}.db`);
47+
48+
if (fs.existsSync(dest)) {
49+
if (!options.force) {
50+
throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`);
51+
}
52+
fs.unlinkSync(dest);
53+
debug(`Deleted existing snapshot: ${dest}`);
54+
}
55+
56+
fs.mkdirSync(dir, { recursive: true });
57+
58+
const db = new Database(dbPath, { readonly: true });
59+
try {
60+
db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
61+
} finally {
62+
db.close();
63+
}
64+
65+
const stat = fs.statSync(dest);
66+
debug(`Snapshot saved: ${dest} (${stat.size} bytes)`);
67+
return { name, path: dest, size: stat.size };
68+
}
69+
70+
/**
71+
* Restore a snapshot over the current graph database.
72+
* Removes WAL/SHM sidecar files before overwriting.
73+
*
74+
* @param {string} name - Snapshot name
75+
* @param {object} [options]
76+
* @param {string} [options.dbPath] - Explicit path to graph.db
77+
*/
78+
export function snapshotRestore(name, options = {}) {
79+
validateSnapshotName(name);
80+
const dbPath = options.dbPath || findDbPath();
81+
const dir = snapshotsDir(dbPath);
82+
const src = path.join(dir, `${name}.db`);
83+
84+
if (!fs.existsSync(src)) {
85+
throw new Error(`Snapshot "${name}" not found at ${src}`);
86+
}
87+
88+
// Remove WAL/SHM sidecar files for a clean restore
89+
for (const suffix of ['-wal', '-shm']) {
90+
const sidecar = dbPath + suffix;
91+
if (fs.existsSync(sidecar)) {
92+
fs.unlinkSync(sidecar);
93+
debug(`Removed sidecar: ${sidecar}`);
94+
}
95+
}
96+
97+
fs.copyFileSync(src, dbPath);
98+
debug(`Restored snapshot "${name}" → ${dbPath}`);
99+
}
100+
101+
/**
102+
* List all saved snapshots.
103+
*
104+
* @param {object} [options]
105+
* @param {string} [options.dbPath] - Explicit path to graph.db
106+
* @returns {Array<{ name: string, path: string, size: number, createdAt: Date }>}
107+
*/
108+
export function snapshotList(options = {}) {
109+
const dbPath = options.dbPath || findDbPath();
110+
const dir = snapshotsDir(dbPath);
111+
112+
if (!fs.existsSync(dir)) return [];
113+
114+
return fs
115+
.readdirSync(dir)
116+
.filter((f) => f.endsWith('.db'))
117+
.map((f) => {
118+
const filePath = path.join(dir, f);
119+
const stat = fs.statSync(filePath);
120+
return {
121+
name: f.replace(/\.db$/, ''),
122+
path: filePath,
123+
size: stat.size,
124+
createdAt: stat.birthtime,
125+
};
126+
})
127+
.sort((a, b) => b.createdAt - a.createdAt);
128+
}
129+
130+
/**
131+
* Delete a named snapshot.
132+
*
133+
* @param {string} name - Snapshot name
134+
* @param {object} [options]
135+
* @param {string} [options.dbPath] - Explicit path to graph.db
136+
*/
137+
export function snapshotDelete(name, options = {}) {
138+
validateSnapshotName(name);
139+
const dbPath = options.dbPath || findDbPath();
140+
const dir = snapshotsDir(dbPath);
141+
const target = path.join(dir, `${name}.db`);
142+
143+
if (!fs.existsSync(target)) {
144+
throw new Error(`Snapshot "${name}" not found at ${target}`);
145+
}
146+
147+
fs.unlinkSync(target);
148+
debug(`Deleted snapshot: ${target}`);
149+
}

0 commit comments

Comments
 (0)