From a109ccce0afca060c935cf973860a7221dc017cf Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:41:43 -0700 Subject: [PATCH] feat: add `codegraph snapshot` for DB backup and restore 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 --- src/cli.js | 82 +++++++++++++ src/index.js | 9 ++ src/snapshot.js | 149 +++++++++++++++++++++++ tests/unit/snapshot.test.js | 228 ++++++++++++++++++++++++++++++++++++ 4 files changed, 468 insertions(+) create mode 100644 src/snapshot.js create mode 100644 tests/unit/snapshot.test.js diff --git a/src/cli.js b/src/cli.js index f63f96bb..f8fdf28e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -40,6 +40,7 @@ import { registerRepo, unregisterRepo, } from './registry.js'; +import { snapshotDelete, snapshotList, snapshotRestore, snapshotSave } from './snapshot.js'; import { checkForUpdates, printUpdateNotification } from './update-check.js'; import { watchProject } from './watcher.js'; @@ -83,6 +84,12 @@ function resolveNoTests(opts) { return config.query?.excludeTests || false; } +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + program .command('build [dir]') .description('Parse repo and build graph in .codegraph/graph.db') @@ -498,6 +505,81 @@ registry } }); +// ─── Snapshot commands ────────────────────────────────────────────────── + +const snapshot = program + .command('snapshot') + .description('Save and restore graph database snapshots'); + +snapshot + .command('save ') + .description('Save a snapshot of the current graph database') + .option('-d, --db ', 'Path to graph.db') + .option('--force', 'Overwrite existing snapshot') + .action((name, opts) => { + try { + const result = snapshotSave(name, { dbPath: opts.db, force: opts.force }); + console.log(`Snapshot saved: ${result.name} (${formatSize(result.size)})`); + } catch (err) { + console.error(err.message); + process.exit(1); + } + }); + +snapshot + .command('restore ') + .description('Restore a snapshot over the current graph database') + .option('-d, --db ', 'Path to graph.db') + .action((name, opts) => { + try { + snapshotRestore(name, { dbPath: opts.db }); + console.log(`Snapshot "${name}" restored.`); + } catch (err) { + console.error(err.message); + process.exit(1); + } + }); + +snapshot + .command('list') + .description('List all saved snapshots') + .option('-d, --db ', 'Path to graph.db') + .option('-j, --json', 'Output as JSON') + .action((opts) => { + try { + const snapshots = snapshotList({ dbPath: opts.db }); + if (opts.json) { + console.log(JSON.stringify(snapshots, null, 2)); + } else if (snapshots.length === 0) { + console.log('No snapshots found.'); + } else { + console.log(`Snapshots (${snapshots.length}):\n`); + for (const s of snapshots) { + console.log( + ` ${s.name.padEnd(30)} ${formatSize(s.size).padStart(10)} ${s.createdAt.toISOString()}`, + ); + } + } + } catch (err) { + console.error(err.message); + process.exit(1); + } + }); + +snapshot + .command('delete ') + .description('Delete a saved snapshot') + .option('-d, --db ', 'Path to graph.db') + .action((name, opts) => { + try { + snapshotDelete(name, { dbPath: opts.db }); + console.log(`Snapshot "${name}" deleted.`); + } catch (err) { + console.error(err.message); + process.exit(1); + } + }); + // ─── Embedding commands ───────────────────────────────────────────────── program diff --git a/src/index.js b/src/index.js index 2b539e12..0e4c2113 100644 --- a/src/index.js +++ b/src/index.js @@ -108,6 +108,15 @@ export { saveRegistry, unregisterRepo, } from './registry.js'; +// Snapshot management +export { + snapshotDelete, + snapshotList, + snapshotRestore, + snapshotSave, + snapshotsDir, + validateSnapshotName, +} from './snapshot.js'; // Structure analysis export { buildStructure, diff --git a/src/snapshot.js b/src/snapshot.js new file mode 100644 index 00000000..43d46b09 --- /dev/null +++ b/src/snapshot.js @@ -0,0 +1,149 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { findDbPath } from './db.js'; +import { debug } from './logger.js'; + +const NAME_RE = /^[a-zA-Z0-9_-]+$/; + +/** + * Validate a snapshot name (alphanumeric, hyphens, underscores only). + * Throws on invalid input. + */ +export function validateSnapshotName(name) { + if (!name || !NAME_RE.test(name)) { + throw new Error( + `Invalid snapshot name "${name}". Use only letters, digits, hyphens, and underscores.`, + ); + } +} + +/** + * Return the snapshots directory for a given DB path. + */ +export function snapshotsDir(dbPath) { + return path.join(path.dirname(dbPath), 'snapshots'); +} + +/** + * Save a snapshot of the current graph database. + * Uses VACUUM INTO for an atomic, WAL-free copy. + * + * @param {string} name - Snapshot name + * @param {object} [options] + * @param {string} [options.dbPath] - Explicit path to graph.db + * @param {boolean} [options.force] - Overwrite existing snapshot + * @returns {{ name: string, path: string, size: number }} + */ +export function snapshotSave(name, options = {}) { + validateSnapshotName(name); + const dbPath = options.dbPath || findDbPath(); + if (!fs.existsSync(dbPath)) { + throw new Error(`Database not found: ${dbPath}`); + } + + const dir = snapshotsDir(dbPath); + const dest = path.join(dir, `${name}.db`); + + if (fs.existsSync(dest)) { + if (!options.force) { + throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`); + } + fs.unlinkSync(dest); + debug(`Deleted existing snapshot: ${dest}`); + } + + fs.mkdirSync(dir, { recursive: true }); + + const db = new Database(dbPath, { readonly: true }); + try { + db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`); + } finally { + db.close(); + } + + const stat = fs.statSync(dest); + debug(`Snapshot saved: ${dest} (${stat.size} bytes)`); + return { name, path: dest, size: stat.size }; +} + +/** + * Restore a snapshot over the current graph database. + * Removes WAL/SHM sidecar files before overwriting. + * + * @param {string} name - Snapshot name + * @param {object} [options] + * @param {string} [options.dbPath] - Explicit path to graph.db + */ +export function snapshotRestore(name, options = {}) { + validateSnapshotName(name); + const dbPath = options.dbPath || findDbPath(); + const dir = snapshotsDir(dbPath); + const src = path.join(dir, `${name}.db`); + + if (!fs.existsSync(src)) { + throw new Error(`Snapshot "${name}" not found at ${src}`); + } + + // Remove WAL/SHM sidecar files for a clean restore + for (const suffix of ['-wal', '-shm']) { + const sidecar = dbPath + suffix; + if (fs.existsSync(sidecar)) { + fs.unlinkSync(sidecar); + debug(`Removed sidecar: ${sidecar}`); + } + } + + fs.copyFileSync(src, dbPath); + debug(`Restored snapshot "${name}" → ${dbPath}`); +} + +/** + * List all saved snapshots. + * + * @param {object} [options] + * @param {string} [options.dbPath] - Explicit path to graph.db + * @returns {Array<{ name: string, path: string, size: number, createdAt: Date }>} + */ +export function snapshotList(options = {}) { + const dbPath = options.dbPath || findDbPath(); + const dir = snapshotsDir(dbPath); + + if (!fs.existsSync(dir)) return []; + + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.db')) + .map((f) => { + const filePath = path.join(dir, f); + const stat = fs.statSync(filePath); + return { + name: f.replace(/\.db$/, ''), + path: filePath, + size: stat.size, + createdAt: stat.birthtime, + }; + }) + .sort((a, b) => b.createdAt - a.createdAt); +} + +/** + * Delete a named snapshot. + * + * @param {string} name - Snapshot name + * @param {object} [options] + * @param {string} [options.dbPath] - Explicit path to graph.db + */ +export function snapshotDelete(name, options = {}) { + validateSnapshotName(name); + const dbPath = options.dbPath || findDbPath(); + const dir = snapshotsDir(dbPath); + const target = path.join(dir, `${name}.db`); + + if (!fs.existsSync(target)) { + throw new Error(`Snapshot "${name}" not found at ${target}`); + } + + fs.unlinkSync(target); + debug(`Deleted snapshot: ${target}`); +} diff --git a/tests/unit/snapshot.test.js b/tests/unit/snapshot.test.js new file mode 100644 index 00000000..950028f1 --- /dev/null +++ b/tests/unit/snapshot.test.js @@ -0,0 +1,228 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + snapshotDelete, + snapshotList, + snapshotRestore, + snapshotSave, + snapshotsDir, + validateSnapshotName, +} from '../../src/snapshot.js'; + +let tmpDir; +let dbPath; + +function createTestDb(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const db = new Database(filePath); + db.exec(` + CREATE TABLE nodes (id INTEGER PRIMARY KEY, name TEXT); + INSERT INTO nodes (name) VALUES ('hello'); + `); + db.close(); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-snap-')); + dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); + createTestDb(dbPath); +}); + +afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── validateSnapshotName ─────────────────────────────────────────────── + +describe('validateSnapshotName', () => { + it('accepts valid names', () => { + expect(() => validateSnapshotName('pre-refactor')).not.toThrow(); + expect(() => validateSnapshotName('v1_0')).not.toThrow(); + expect(() => validateSnapshotName('ABC123')).not.toThrow(); + }); + + it('rejects names with spaces', () => { + expect(() => validateSnapshotName('has space')).toThrow(/Invalid snapshot name/); + }); + + it('rejects names with dots', () => { + expect(() => validateSnapshotName('v1.0')).toThrow(/Invalid snapshot name/); + }); + + it('rejects names with slashes', () => { + expect(() => validateSnapshotName('a/b')).toThrow(/Invalid snapshot name/); + }); + + it('rejects empty string', () => { + expect(() => validateSnapshotName('')).toThrow(/Invalid snapshot name/); + }); + + it('rejects undefined', () => { + expect(() => validateSnapshotName(undefined)).toThrow(/Invalid snapshot name/); + }); +}); + +// ─── snapshotsDir ─────────────────────────────────────────────────────── + +describe('snapshotsDir', () => { + it('returns correct path relative to DB', () => { + const result = snapshotsDir('/project/.codegraph/graph.db'); + expect(result).toBe(path.join('/project/.codegraph', 'snapshots')); + }); + + it('works with the test DB path', () => { + const result = snapshotsDir(dbPath); + expect(result).toBe(path.join(tmpDir, '.codegraph', 'snapshots')); + }); +}); + +// ─── snapshotSave ─────────────────────────────────────────────────────── + +describe('snapshotSave', () => { + it('creates a snapshot file', () => { + const result = snapshotSave('test1', { dbPath }); + expect(result.name).toBe('test1'); + expect(fs.existsSync(result.path)).toBe(true); + expect(result.size).toBeGreaterThan(0); + }); + + it('creates snapshots directory if missing', () => { + const dir = snapshotsDir(dbPath); + expect(fs.existsSync(dir)).toBe(false); + snapshotSave('test1', { dbPath }); + expect(fs.existsSync(dir)).toBe(true); + }); + + it('returns correct metadata', () => { + const result = snapshotSave('meta-test', { dbPath }); + expect(result).toEqual({ + name: 'meta-test', + path: path.join(snapshotsDir(dbPath), 'meta-test.db'), + size: expect.any(Number), + }); + }); + + it('produces a valid SQLite file', () => { + const result = snapshotSave('valid-check', { dbPath }); + const db = new Database(result.path, { readonly: true }); + const rows = db.prepare('SELECT name FROM nodes').all(); + expect(rows).toEqual([{ name: 'hello' }]); + db.close(); + }); + + it('throws on missing database', () => { + const fakePath = path.join(tmpDir, 'nonexistent', 'graph.db'); + expect(() => snapshotSave('x', { dbPath: fakePath })).toThrow(/Database not found/); + }); + + it('throws on duplicate without force', () => { + snapshotSave('dup', { dbPath }); + expect(() => snapshotSave('dup', { dbPath })).toThrow(/already exists/); + }); + + it('overwrites with force', () => { + snapshotSave('dup', { dbPath }); + const result = snapshotSave('dup', { dbPath, force: true }); + expect(fs.existsSync(result.path)).toBe(true); + }); + + it('rejects invalid name', () => { + expect(() => snapshotSave('bad name', { dbPath })).toThrow(/Invalid snapshot name/); + }); +}); + +// ─── snapshotRestore ──────────────────────────────────────────────────── + +describe('snapshotRestore', () => { + it('restores data from a snapshot', () => { + snapshotSave('restore-test', { dbPath }); + + // Modify the live DB + const db = new Database(dbPath); + db.exec("INSERT INTO nodes (name) VALUES ('extra')"); + db.close(); + + // Restore — should get back to original state + snapshotRestore('restore-test', { dbPath }); + const restored = new Database(dbPath, { readonly: true }); + const rows = restored.prepare('SELECT name FROM nodes').all(); + expect(rows).toEqual([{ name: 'hello' }]); + restored.close(); + }); + + it('removes WAL and SHM sidecar files', () => { + snapshotSave('wal-test', { dbPath }); + + // Create fake sidecar files + fs.writeFileSync(`${dbPath}-wal`, 'fake-wal'); + fs.writeFileSync(`${dbPath}-shm`, 'fake-shm'); + + snapshotRestore('wal-test', { dbPath }); + expect(fs.existsSync(`${dbPath}-wal`)).toBe(false); + expect(fs.existsSync(`${dbPath}-shm`)).toBe(false); + }); + + it('throws on missing snapshot', () => { + expect(() => snapshotRestore('nonexistent', { dbPath })).toThrow(/not found/); + }); + + it('rejects invalid name', () => { + expect(() => snapshotRestore('bad.name', { dbPath })).toThrow(/Invalid snapshot name/); + }); +}); + +// ─── snapshotList ─────────────────────────────────────────────────────── + +describe('snapshotList', () => { + it('returns empty array when no snapshots dir exists', () => { + const result = snapshotList({ dbPath }); + expect(result).toEqual([]); + }); + + it('returns snapshot metadata sorted by date descending', () => { + snapshotSave('alpha', { dbPath }); + snapshotSave('beta', { dbPath }); + const result = snapshotList({ dbPath }); + expect(result).toHaveLength(2); + expect(result[0].name).toBeDefined(); + expect(result[1].name).toBeDefined(); + expect(new Set([result[0].name, result[1].name])).toEqual(new Set(['alpha', 'beta'])); + for (const s of result) { + expect(s.size).toBeGreaterThan(0); + expect(s.createdAt).toBeInstanceOf(Date); + expect(s.path).toContain('.db'); + } + }); + + it('filters non-.db files', () => { + snapshotSave('real', { dbPath }); + const dir = snapshotsDir(dbPath); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'not a snapshot'); + + const result = snapshotList({ dbPath }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('real'); + }); +}); + +// ─── snapshotDelete ───────────────────────────────────────────────────── + +describe('snapshotDelete', () => { + it('deletes a snapshot file', () => { + const { path: snapPath } = snapshotSave('del-me', { dbPath }); + expect(fs.existsSync(snapPath)).toBe(true); + snapshotDelete('del-me', { dbPath }); + expect(fs.existsSync(snapPath)).toBe(false); + }); + + it('throws on missing snapshot', () => { + expect(() => snapshotDelete('ghost', { dbPath })).toThrow(/not found/); + }); + + it('rejects invalid name', () => { + expect(() => snapshotDelete('bad/name', { dbPath })).toThrow(/Invalid snapshot name/); + }); +});