Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -498,6 +505,81 @@ registry
}
});

// ─── Snapshot commands ──────────────────────────────────────────────────

const snapshot = program
.command('snapshot')
.description('Save and restore graph database snapshots');

snapshot
.command('save <name>')
.description('Save a snapshot of the current graph database')
.option('-d, --db <path>', '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 <name>')
.description('Restore a snapshot over the current graph database')
.option('-d, --db <path>', '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>', '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 <name>')
.description('Delete a saved snapshot')
.option('-d, --db <path>', '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
Expand Down
9 changes: 9 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
149 changes: 149 additions & 0 deletions src/snapshot.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
Loading