/** * @file server/lib/data-transfer.js * @description Full-dataset export/import ("backup / restore") for the local * dashboard database. This is the round-trip counterpart to the transcript * importer (scripts/import-history.js): where that reconstructs sessions from * raw Claude Code JSONL, this serializes the dashboard's OWN captured data to a * single portable JSON bundle and restores it later — the workflow a user needs * to consolidate several machines into one dashboard. * * Design guarantees: * • Complete — the bundle carries every table that holds user-owned captured * data or portable configuration: sessions, agents, events, token_usage, * workflows, dashboard_runs, alert_rules, and model_pricing. Machine-bound * or secret-bearing tables (push_subscriptions, webhook_targets/deliveries, * alert_events audit log) are intentionally excluded. * • Idempotent + non-destructive — restore is session-atomic: a session that * already exists (matched by its stable UUID) is skipped WHOLE, together * with its agents/events/token_usage/workflows, so re-importing the same * bundle (or overlapping bundles from two machines) never duplicates rows * or clobbers live data. Independent config rows (dashboard_runs, * alert_rules, model_pricing) are inserted with INSERT OR IGNORE on their * natural primary key. * • Accurate — token_usage (including compaction baselines) is restored * verbatim for every new session, so cost/analytics match the source * machine exactly. events are re-inserted WITHOUT their source autoincrement * id (which is not portable across databases); SQLite assigns fresh ids. * • Schema-tolerant — inserts are built by intersecting each table's live * columns (PRAGMA table_info) with the keys present on each row, so older * or newer bundles import cleanly without a migration step. * * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const EXPORT_FORMAT = "ccam-export"; const EXPORT_VERSION = 1; // Tables serialized into the bundle. Order matters for restore (parents before // children); FK checks are deferred to COMMIT anyway (see importExportBundle). const SESSION_CHILD_TABLES = ["agents", "events", "token_usage", "workflows"]; /** * Build the full export bundle from the live database. * * @param {import("better-sqlite3").Database} db * @param {{ listPricing: { all: () => any[] } }} stmts - the prepared-statement * bag from server/db.js (used for the canonical model_pricing ordering). * @returns {object} A JSON-serializable bundle stamped with format/version. */ function buildExportBundle(db, stmts) { return { format: EXPORT_FORMAT, version: EXPORT_VERSION, exported_at: new Date().toISOString(), sessions: db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all(), agents: db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all(), events: db.prepare("SELECT * FROM events ORDER BY created_at DESC").all(), token_usage: db.prepare("SELECT * FROM token_usage").all(), workflows: db.prepare("SELECT * FROM workflows").all(), dashboard_runs: db.prepare("SELECT * FROM dashboard_runs ORDER BY started_at DESC").all(), alert_rules: db.prepare("SELECT * FROM alert_rules ORDER BY created_at ASC").all(), model_pricing: stmts.listPricing.all(), }; } /** Column names of a table, in definition order. */ function tableColumns(db, table) { return db .prepare(`PRAGMA table_info(${table})`) .all() .map((c) => c.name); } // better-sqlite3 only binds numbers/strings/bigints/buffers/null. A row parsed // from JSON never contains booleans/objects for these tables (SQLite stores // them as INTEGER/TEXT), but a missing key yields `undefined`, which throws — // normalize it to null so partial/legacy rows still bind. function bindable(v) { if (v === undefined) return null; if (typeof v === "boolean") return v ? 1 : 0; return v; } /** * Make a prepared INSERT OR IGNORE that only writes the columns a table * actually has AND the row actually provides. `omit` drops columns even if * present (used to strip the non-portable events.id). */ function makeInserter(db, table, { omit = [] } = {}) { const cols = tableColumns(db, table).filter((c) => !omit.includes(c)); const quoted = cols.map((c) => `"${c}"`).join(", "); const placeholders = cols.map(() => "?").join(", "); const stmt = db.prepare(`INSERT OR IGNORE INTO ${table} (${quoted}) VALUES (${placeholders})`); return (row) => stmt.run(cols.map((c) => bindable(row[c]))); } /** Group an array of rows by a key field into a Map. */ function groupBy(rows, key) { const map = new Map(); for (const r of Array.isArray(rows) ? rows : []) { if (!r || r[key] == null) continue; const k = r[key]; if (!map.has(k)) map.set(k, []); map.get(k).push(r); } return map; } class ImportFormatError extends Error { constructor(message) { super(message); this.name = "ImportFormatError"; this.code = "INVALID_FORMAT"; } } /** * Validate a parsed object looks like an export bundle. Accepts bundles stamped * with our format marker AND legacy exports (pre-versioning) that merely carry * a `sessions` array, so old backups remain importable. * * @throws {ImportFormatError} */ function assertBundle(bundle) { if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { throw new ImportFormatError("Not a valid export file (expected a JSON object)."); } if (bundle.format && bundle.format !== EXPORT_FORMAT) { throw new ImportFormatError( `Unrecognized export format "${bundle.format}" (expected "${EXPORT_FORMAT}").` ); } const hasAnyTable = Array.isArray(bundle.sessions) || Array.isArray(bundle.model_pricing) || Array.isArray(bundle.alert_rules) || Array.isArray(bundle.dashboard_runs); if (!bundle.format && !hasAnyTable) { throw new ImportFormatError( "Not a recognizable dashboard export (no sessions/pricing/rules arrays)." ); } } /** * Restore an export bundle into the live database. Idempotent and * non-destructive (see file header). Runs inside a single transaction with * deferred FK checks so agent parent/child ordering never trips a constraint. * * @param {import("better-sqlite3").Database} db * @param {object} bundle - parsed export JSON. * @returns {{sessions_imported:number, sessions_skipped:number, agents:number, * events:number, token_usage:number, workflows:number, dashboard_runs:number, * alert_rules:number, model_pricing:number, errors:number}} */ function importExportBundle(db, bundle) { assertBundle(bundle); const counters = { sessions_imported: 0, sessions_skipped: 0, agents: 0, events: 0, token_usage: 0, workflows: 0, dashboard_runs: 0, alert_rules: 0, model_pricing: 0, errors: 0, }; const sessionExists = db.prepare("SELECT 1 FROM sessions WHERE id = ?").pluck(); const insert = { sessions: makeInserter(db, "sessions"), agents: makeInserter(db, "agents"), events: makeInserter(db, "events", { omit: ["id"] }), token_usage: makeInserter(db, "token_usage"), workflows: makeInserter(db, "workflows"), dashboard_runs: makeInserter(db, "dashboard_runs"), alert_rules: makeInserter(db, "alert_rules"), model_pricing: makeInserter(db, "model_pricing"), }; const childRows = { agents: groupBy(bundle.agents, "session_id"), events: groupBy(bundle.events, "session_id"), token_usage: groupBy(bundle.token_usage, "session_id"), workflows: groupBy(bundle.workflows, "session_id"), }; const sessions = Array.isArray(bundle.sessions) ? bundle.sessions : []; const run = db.transaction(() => { // Defer FK enforcement to COMMIT: an agent's parent_agent_id may point to a // sibling that is inserted later in the same batch. Auto-resets at COMMIT. db.pragma("defer_foreign_keys = ON"); for (const s of sessions) { if (!s || !s.id) { counters.errors++; continue; } if (sessionExists.get(s.id)) { counters.sessions_skipped++; continue; } insert.sessions(s); counters.sessions_imported++; // Agents first so events/token_usage that reference them satisfy FKs. for (const a of childRows.agents.get(s.id) || []) { if (insert.agents(a).changes > 0) counters.agents++; } // Insert events oldest-first so fresh autoincrement ids stay chronological. const evs = (childRows.events.get(s.id) || []) .slice() .sort((a, b) => String(a.created_at || "").localeCompare(String(b.created_at || ""))); for (const e of evs) { if (insert.events(e).changes > 0) counters.events++; } for (const tu of childRows.token_usage.get(s.id) || []) { if (insert.token_usage(tu).changes > 0) counters.token_usage++; } for (const wf of childRows.workflows.get(s.id) || []) { if (insert.workflows(wf).changes > 0) counters.workflows++; } } // Session-independent, config-like tables: restore by natural PK, never // overwriting a row the target machine already has. for (const r of Array.isArray(bundle.dashboard_runs) ? bundle.dashboard_runs : []) { if (r && r.id && insert.dashboard_runs(r).changes > 0) counters.dashboard_runs++; } for (const r of Array.isArray(bundle.alert_rules) ? bundle.alert_rules : []) { if (r && r.id && insert.alert_rules(r).changes > 0) counters.alert_rules++; } for (const p of Array.isArray(bundle.model_pricing) ? bundle.model_pricing : []) { if (p && p.model_pattern && insert.model_pricing(p).changes > 0) counters.model_pricing++; } }); run(); return counters; } module.exports = { EXPORT_FORMAT, EXPORT_VERSION, SESSION_CHILD_TABLES, buildExportBundle, importExportBundle, ImportFormatError, };