diff --git a/plugins/mcp-recall/dist/cli.js b/plugins/mcp-recall/dist/cli.js index 049617e..0829dab 100644 --- a/plugins/mcp-recall/dist/cli.js +++ b/plugins/mcp-recall/dist/cli.js @@ -9360,8 +9360,167 @@ Next steps:`); } } +// src/import/index.ts +import { readFileSync as readFileSync9, statSync as statSync2, existsSync } from "fs"; +import { resolve } from "path"; +import { Database as Database2 } from "bun:sqlite"; +var LARGE_FILE_BYTES = 50 * 1024 * 1024; +var EMPTY_EXPORT_SENTINEL = "[recall: no items to export]"; +var StoredOutputSchema = exports_external.object({ + id: exports_external.string().min(1), + project_key: exports_external.string().min(1), + session_id: exports_external.string().min(1), + tool_name: exports_external.string().min(1), + summary: exports_external.string(), + full_content: exports_external.string(), + original_size: exports_external.number().int().nonnegative(), + summary_size: exports_external.number().int().nonnegative(), + created_at: exports_external.number().int().positive(), + pinned: exports_external.number().int().min(0).max(1), + access_count: exports_external.number().int().nonnegative(), + last_accessed: exports_external.number().int().nullable(), + input_hash: exports_external.string().nullable() +}); +var ExportSchema = exports_external.array(StoredOutputSchema); +function dryRunCount(dbPath, items, overwrite) { + if (dbPath === ":memory:" || !existsSync(dbPath)) { + return { imported: items.length, skipped: 0, overwritten: 0 }; + } + let db = null; + try { + db = new Database2(dbPath, { readonly: true }); + const hasTable = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='stored_outputs' LIMIT 1`).get(); + if (!hasTable) + return { imported: items.length, skipped: 0, overwritten: 0 }; + const result = { imported: 0, skipped: 0, overwritten: 0 }; + const check = db.prepare(`SELECT id FROM stored_outputs WHERE id = ? LIMIT 1`); + for (const item of items) { + const existing = check.get(item.id); + if (existing) { + if (overwrite) + result.overwritten++; + else + result.skipped++; + } else { + result.imported++; + } + } + return result; + } catch { + return { imported: items.length, skipped: 0, overwritten: 0 }; + } finally { + db?.close(); + } +} +function importItems(dbPath, items, opts) { + const db = getDb(dbPath); + const result = { imported: 0, skipped: 0, overwritten: 0 }; + const chunkStmt = db.prepare(`INSERT INTO content_chunks (output_id, chunk_index, content) VALUES (?, ?, ?)`); + const insertItem = db.transaction((item) => { + const projectKey = opts.targetProjectKey ?? item.project_key; + const existing = db.prepare(`SELECT id FROM stored_outputs WHERE id = ? LIMIT 1`).get(item.id); + if (existing) { + if (!opts.overwrite) + return "skipped"; + db.prepare(`DELETE FROM stored_outputs WHERE id = ?`).run(item.id); + } + db.prepare(` + INSERT INTO stored_outputs + (id, project_key, session_id, tool_name, summary, full_content, + original_size, summary_size, created_at, pinned, access_count, + last_accessed, input_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(item.id, projectKey, item.session_id, item.tool_name, item.summary, item.full_content, item.original_size, item.summary_size, item.created_at, item.pinned, item.access_count, item.last_accessed, item.input_hash); + const chunks = chunkText(item.full_content); + for (let i = 0;i < chunks.length; i++) { + chunkStmt.run(item.id, i, chunks[i]); + } + return existing ? "overwritten" : "imported"; + }); + for (const item of items) { + const action = insertItem(item); + result[action]++; + } + return result; +} +async function handleImportCommand(args) { + const overwrite = args.includes("--overwrite"); + const keepProjectKey = args.includes("--keep-project-key"); + const dryRun = args.includes("--dry-run"); + const rawPath = args.find((a) => !a.startsWith("--")); + const filePath = rawPath ? resolve(rawPath) : null; + let raw; + if (filePath) { + try { + const size = statSync2(filePath).size; + if (size > LARGE_FILE_BYTES) { + console.error(`Warning: file is ${Math.round(size / 1024 / 1024)} MB \u2014 this may take a while.`); + } + raw = readFileSync9(filePath, "utf8"); + } catch { + console.error(`Cannot read file: ${filePath}`); + process.exit(1); + } + } else { + try { + raw = readFileSync9("/dev/stdin", "utf8"); + } catch { + console.error("No file specified and stdin is not readable."); + console.error("Usage: mcp-recall import [--overwrite] [--keep-project-key] [--dry-run]"); + process.exit(1); + } + } + if (raw.trimStart().startsWith(EMPTY_EXPORT_SENTINEL)) { + console.log("Nothing to import (empty export)."); + return; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + console.error("Invalid JSON input."); + process.exit(1); + } + const validation = ExportSchema.safeParse(parsed); + if (!validation.success) { + console.error("Input does not look like a recall__export dump:"); + for (const issue of validation.error.issues.slice(0, 5)) { + console.error(` [${issue.path.join(".")}] ${issue.message}`); + } + process.exit(1); + } + const items = validation.data; + if (items.length === 0) { + console.log("Nothing to import (empty export)."); + return; + } + const projectKey = getProjectKey(process.cwd()); + const dbPath = defaultDbPath(projectKey); + const targetProjectKey = keepProjectKey ? null : projectKey; + console.log(` +Importing ${items.length} item(s) into ${dbPath}`); + if (dryRun) + console.log(`(dry run \u2014 nothing will be written) +`); + const result = dryRun ? dryRunCount(dbPath, items, overwrite) : importItems(dbPath, items, { overwrite, targetProjectKey }); + const parts = []; + if (result.imported > 0) + parts.push(`${result.imported} imported`); + if (result.overwritten > 0) + parts.push(`${result.overwritten} overwritten`); + if (result.skipped > 0) + parts.push(`${result.skipped} skipped (already exist \u2014 use --overwrite to replace)`); + console.log(parts.length > 0 ? parts.join(", ") + "." : "Nothing imported."); + if (!dryRun && result.imported + result.overwritten > 0) { + console.log(` +Next steps:`); + console.log(" recall__search \u2014 verify content is searchable"); + console.log(" recall__list_stored \u2014 browse imported items"); + } +} + // src/install/index.ts -import { existsSync } from "fs"; +import { existsSync as existsSync2 } from "fs"; import { mkdir, rename, readFile } from "fs/promises"; import path from "path"; import os from "os"; @@ -9530,7 +9689,7 @@ async function installCommand(opts = {}) { claudeMdPath = defaultClaudeMdPath() } = opts; const paths = detectPaths(); - if (!existsSync(paths.serverJs) || !existsSync(paths.cliJs)) { + if (!existsSync2(paths.serverJs) || !existsSync2(paths.cliJs)) { console.error(`${RED}\u2717 Build artifacts not found.${RESET}`); console.error(` Expected: ${DIM}${paths.serverJs}${RESET}`); console.error(` Run ${BOLD}bun run build${RESET} first.`); @@ -9715,8 +9874,8 @@ async function statusCommand(opts = {}) { claudeMdContent = await readFile(claudeMdPath, "utf8"); } catch {} const claudeMdOk = isClaudeMdInjected(claudeMdContent); - const serverExists = existsSync(recallPaths.serverJs); - const cliExists = existsSync(recallPaths.cliJs); + const serverExists = existsSync2(recallPaths.serverJs); + const cliExists = existsSync2(recallPaths.cliJs); const fullyInstalled = serverRegistered && ssRegistered && ptuRegistered && claudeMdOk && serverExists && cliExists; const label = fullyInstalled ? `${GREEN}installed${RESET}` : serverRegistered || ssRegistered || ptuRegistered ? `${YELLOW}partial / stale${RESET}` : `${RED}not installed${RESET}`; console.log(` @@ -9783,6 +9942,7 @@ Commands: retrain Suggest profile improvements from stored data test Test a profile against real input learn Generate profile suggestions from session data + import Restore items from a recall__export JSON dump completions Print shell completion script (bash, zsh, fish) Options: @@ -10020,6 +10180,10 @@ async function main() { await handleLearnCommand(process.argv.slice(3)); process.exit(0); } + if (subcommand === "import") { + await handleImportCommand(process.argv.slice(3)); + process.exit(0); + } if (subcommand === "install") { const dryRun = process.argv.includes("--dry-run"); await installCommand({ dryRun }); diff --git a/src/cli.ts b/src/cli.ts index eced22a..d02c0df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { handleSessionStart } from "./hooks/session-start"; import { handlePostToolUse } from "./hooks/post-tool-use"; import { handleProfilesCommand } from "./profiles/commands"; import { handleLearnCommand } from "./learn/index"; +import { handleImportCommand } from "./import/index"; import { installCommand, uninstallCommand, statusCommand } from "./install/index"; import { log } from "./log"; @@ -49,6 +50,7 @@ Commands: retrain Suggest profile improvements from stored data test Test a profile against real input learn Generate profile suggestions from session data + import Restore items from a recall__export JSON dump completions Print shell completion script (bash, zsh, fish) Options: @@ -301,6 +303,11 @@ async function main(): Promise { process.exit(0); } + if (subcommand === "import") { + await handleImportCommand(process.argv.slice(3)); + process.exit(0); + } + if (subcommand === "install") { const dryRun = process.argv.includes("--dry-run"); await installCommand({ dryRun }); diff --git a/src/import/index.ts b/src/import/index.ts new file mode 100644 index 0000000..d2d19e6 --- /dev/null +++ b/src/import/index.ts @@ -0,0 +1,252 @@ +/** + * `mcp-recall import` — restores items from a `recall__export` JSON dump into + * the current project's SQLite database. + * + * Usage: + * mcp-recall import dump.json # restore from file + * mcp-recall import < dump.json # restore from stdin + * mcp-recall import dump.json --overwrite # replace existing items + * mcp-recall import dump.json --keep-project-key # preserve original project key + * mcp-recall import dump.json --dry-run # preview without writing + */ + +import { readFileSync, statSync, existsSync } from "fs"; +import { resolve } from "path"; +import { Database } from "bun:sqlite"; +import { z } from "zod"; +import { getDb, defaultDbPath } from "../db/schema"; +import { chunkText } from "../db/chunking"; +import { getProjectKey } from "../project-key"; + +// 50 MB — warn before loading a very large file synchronously +const LARGE_FILE_BYTES = 50 * 1024 * 1024; + +// Sentinel emitted by toolExport when the project has no items +const EMPTY_EXPORT_SENTINEL = "[recall: no items to export]"; + +// ── Validation schema ───────────────────────────────────────────────────────── + +const StoredOutputSchema = z.object({ + id: z.string().min(1), + project_key: z.string().min(1), + session_id: z.string().min(1), + tool_name: z.string().min(1), + summary: z.string(), + full_content: z.string(), + original_size: z.number().int().nonnegative(), + summary_size: z.number().int().nonnegative(), + created_at: z.number().int().positive(), + pinned: z.number().int().min(0).max(1), + access_count: z.number().int().nonnegative(), + last_accessed: z.number().int().nullable(), + input_hash: z.string().nullable(), +}); + +type StoredOutputRow = z.infer; + +const ExportSchema = z.array(StoredOutputSchema); + +// ── Core import logic ───────────────────────────────────────────────────────── + +interface ImportResult { + imported: number; + skipped: number; + overwritten: number; +} + +/** + * Counts how many items would be imported, overwritten, or skipped without + * writing anything. Opens the target DB read-only if it exists. + */ +function dryRunCount( + dbPath: string, + items: StoredOutputRow[], + overwrite: boolean +): ImportResult { + if (dbPath === ":memory:" || !existsSync(dbPath)) { + return { imported: items.length, skipped: 0, overwritten: 0 }; + } + + let db: Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const hasTable = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='stored_outputs' LIMIT 1`) + .get(); + if (!hasTable) return { imported: items.length, skipped: 0, overwritten: 0 }; + + const result: ImportResult = { imported: 0, skipped: 0, overwritten: 0 }; + const check = db.prepare(`SELECT id FROM stored_outputs WHERE id = ? LIMIT 1`); + for (const item of items) { + const existing = check.get(item.id); + if (existing) { + if (overwrite) result.overwritten++; + else result.skipped++; + } else { + result.imported++; + } + } + return result; + } catch { + return { imported: items.length, skipped: 0, overwritten: 0 }; + } finally { + db?.close(); + } +} + +function importItems( + dbPath: string, + items: StoredOutputRow[], + opts: { overwrite: boolean; targetProjectKey: string | null } +): ImportResult { + const db = getDb(dbPath); + + const result: ImportResult = { imported: 0, skipped: 0, overwritten: 0 }; + + // Prepare chunk statement once outside the per-item loop + const chunkStmt = db.prepare( + `INSERT INTO content_chunks (output_id, chunk_index, content) VALUES (?, ?, ?)` + ); + + const insertItem = db.transaction((item: StoredOutputRow) => { + const projectKey = opts.targetProjectKey ?? item.project_key; + + const existing = db + .prepare(`SELECT id FROM stored_outputs WHERE id = ? LIMIT 1`) + .get(item.id) as { id: string } | null; + + if (existing) { + if (!opts.overwrite) return "skipped" as const; + // Delete existing row — triggers handle FTS + chunk cleanup automatically + db.prepare(`DELETE FROM stored_outputs WHERE id = ?`).run(item.id); + } + + db.prepare(` + INSERT INTO stored_outputs + (id, project_key, session_id, tool_name, summary, full_content, + original_size, summary_size, created_at, pinned, access_count, + last_accessed, input_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + item.id, + projectKey, + item.session_id, + item.tool_name, + item.summary, + item.full_content, + item.original_size, + item.summary_size, + item.created_at, + item.pinned, + item.access_count, + item.last_accessed, + item.input_hash + ); + + // Re-index chunks (FTS trigger covers stored_outputs but not content_chunks) + const chunks = chunkText(item.full_content); + for (let i = 0; i < chunks.length; i++) { + chunkStmt.run(item.id, i, chunks[i]!); + } + + return existing ? "overwritten" as const : "imported" as const; + }); + + // Accumulate counts only after each transaction commits successfully + for (const item of items) { + const action = insertItem(item); + result[action]++; + } + + return result; +} + +// ── CLI handler ─────────────────────────────────────────────────────────────── + +export async function handleImportCommand(args: string[]): Promise { + const overwrite = args.includes("--overwrite"); + const keepProjectKey = args.includes("--keep-project-key"); + const dryRun = args.includes("--dry-run"); + const rawPath = args.find((a) => !a.startsWith("--")); + const filePath = rawPath ? resolve(rawPath) : null; + + // Read input + let raw: string; + if (filePath) { + try { + const size = statSync(filePath).size; + if (size > LARGE_FILE_BYTES) { + console.error(`Warning: file is ${Math.round(size / 1024 / 1024)} MB — this may take a while.`); + } + raw = readFileSync(filePath, "utf8"); + } catch { + console.error(`Cannot read file: ${filePath}`); + process.exit(1); + } + } else { + try { + raw = readFileSync("/dev/stdin", "utf8"); + } catch { + console.error("No file specified and stdin is not readable."); + console.error("Usage: mcp-recall import [--overwrite] [--keep-project-key] [--dry-run]"); + process.exit(1); + } + } + + // Detect the sentinel emitted by recall__export when the project is empty + if (raw.trimStart().startsWith(EMPTY_EXPORT_SENTINEL)) { + console.log("Nothing to import (empty export)."); + return; + } + + // Parse JSON + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.error("Invalid JSON input."); + process.exit(1); + } + + // Validate schema + const validation = ExportSchema.safeParse(parsed); + if (!validation.success) { + console.error("Input does not look like a recall__export dump:"); + for (const issue of validation.error.issues.slice(0, 5)) { + console.error(` [${issue.path.join(".")}] ${issue.message}`); + } + process.exit(1); + } + + const items = validation.data; + + if (items.length === 0) { + console.log("Nothing to import (empty export)."); + return; + } + + // Resolve target DB + const projectKey = getProjectKey(process.cwd()); + const dbPath = defaultDbPath(projectKey); + const targetProjectKey = keepProjectKey ? null : projectKey; + + console.log(`\nImporting ${items.length} item(s) into ${dbPath}`); + if (dryRun) console.log("(dry run — nothing will be written)\n"); + + const result = dryRun + ? dryRunCount(dbPath, items, overwrite) + : importItems(dbPath, items, { overwrite, targetProjectKey }); + + const parts: string[] = []; + if (result.imported > 0) parts.push(`${result.imported} imported`); + if (result.overwritten > 0) parts.push(`${result.overwritten} overwritten`); + if (result.skipped > 0) parts.push(`${result.skipped} skipped (already exist — use --overwrite to replace)`); + + console.log(parts.length > 0 ? parts.join(", ") + "." : "Nothing imported."); + + if (!dryRun && result.imported + result.overwritten > 0) { + console.log("\nNext steps:"); + console.log(" recall__search — verify content is searchable"); + console.log(" recall__list_stored — browse imported items"); + } +} diff --git a/tests/import.test.ts b/tests/import.test.ts new file mode 100644 index 0000000..5b62e82 --- /dev/null +++ b/tests/import.test.ts @@ -0,0 +1,361 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { Database } from "bun:sqlite"; +import { writeFileSync, unlinkSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { storeOutput, pinOutput } from "../src/db/index"; +import { initSchema, closeDb } from "../src/db/schema"; +import { toolExport } from "../src/tools"; +import { handleImportCommand } from "../src/import/index"; +import type { StoreInput } from "../src/db/types"; + +const PROJECT_ROOT = import.meta.dir.replace(/\/tests$/, ""); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const SOURCE_PROJECT = "import_test_source_key"; + +let sourceDb: Database; +let tmpFiles: string[] = []; + +function makeTmpPath(ext = ".json"): string { + const p = join(tmpdir(), `recall-import-test-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`); + tmpFiles.push(p); + return p; +} + +function makeInput(overrides: Partial = {}): StoreInput { + return { + project_key: SOURCE_PROJECT, + session_id: "sess-abc", + tool_name: "mcp__github__list_issues", + summary: "Issue #1", + full_content: JSON.stringify([{ number: 1, title: "Fix bug" }]), + original_size: 512, + ...overrides, + }; +} + +function exportToFile(filePath: string): void { + writeFileSync(filePath, toolExport(sourceDb, SOURCE_PROJECT)); +} + +beforeEach(() => { + // Use a raw Database (not the singleton) for the source so that + // handleImportCommand can open a fresh singleton to the target path. + sourceDb = new Database(":memory:"); + initSchema(sourceDb); + tmpFiles = []; +}); + +afterEach(() => { + sourceDb.close(); + closeDb(); // reset singleton opened by handleImportCommand + for (const p of tmpFiles) { + if (existsSync(p)) unlinkSync(p); + } +}); + +// ── round-trip ──────────────────────────────────────────────────────────────── + +describe("import round-trip", () => { + test("imports all items into target DB", async () => { + storeOutput(sourceDb, makeInput()); + storeOutput(sourceDb, makeInput({ tool_name: "mcp__github__get_issue", summary: "Issue #2" })); + + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + + const targetDb = new Database(targetDbPath); + const rows = targetDb + .prepare(`SELECT tool_name FROM stored_outputs ORDER BY created_at ASC`) + .all() as Array<{ tool_name: string }>; + targetDb.close(); + + expect(rows).toHaveLength(2); + expect(rows[0]!.tool_name).toBe("mcp__github__list_issues"); + expect(rows[1]!.tool_name).toBe("mcp__github__get_issue"); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("remaps project key to current project by default", async () => { + storeOutput(sourceDb, makeInput()); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile]); + + // Without --keep-project-key the project key is derived from cwd, not SOURCE_PROJECT. + const targetDb = new Database(targetDbPath); + const row = targetDb + .prepare(`SELECT project_key FROM stored_outputs LIMIT 1`) + .get() as { project_key: string } | null; + targetDb.close(); + + expect(row?.project_key).not.toBe(SOURCE_PROJECT); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("preserves original project key with --keep-project-key", async () => { + storeOutput(sourceDb, makeInput()); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + + const targetDb = new Database(targetDbPath); + const row = targetDb + .prepare(`SELECT project_key FROM stored_outputs LIMIT 1`) + .get() as { project_key: string } | null; + targetDb.close(); + + expect(row?.project_key).toBe(SOURCE_PROJECT); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("preserves pin flag", async () => { + const item = storeOutput(sourceDb, makeInput()); + pinOutput(sourceDb, item.id, SOURCE_PROJECT, true); + + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + + const targetDb = new Database(targetDbPath); + const row = targetDb + .prepare(`SELECT pinned FROM stored_outputs WHERE id = ?`) + .get(item.id) as { pinned: number } | null; + targetDb.close(); + + expect(row?.pinned).toBe(1); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("content is searchable via FTS after import", async () => { + storeOutput(sourceDb, makeInput({ full_content: "The quick brown fox jumps over the lazy dog" })); + + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + + const targetDb = new Database(targetDbPath); + const rows = targetDb + .prepare( + `SELECT o.id FROM stored_outputs o + JOIN outputs_fts f ON f.rowid = o.rowid + WHERE outputs_fts MATCH ?` + ) + .all("fox") as Array<{ id: string }>; + targetDb.close(); + + expect(rows.length).toBeGreaterThan(0); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); +}); + +// ── skip / overwrite ────────────────────────────────────────────────────────── + +describe("import conflict handling", () => { + test("skips existing items by default", async () => { + const item = storeOutput(sourceDb, makeInput()); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + // First import + await handleImportCommand([dumpFile, "--keep-project-key"]); + closeDb(); // reset singleton so next import opens the same file fresh + + // Mutate summary in source, re-export + sourceDb.prepare(`UPDATE stored_outputs SET summary = 'UPDATED' WHERE id = ?`).run(item.id); + exportToFile(dumpFile); + + // Second import — should skip + await handleImportCommand([dumpFile, "--keep-project-key"]); + + const targetDb = new Database(targetDbPath); + const row = targetDb + .prepare(`SELECT summary FROM stored_outputs WHERE id = ?`) + .get(item.id) as { summary: string } | null; + targetDb.close(); + + expect(row?.summary).toBe("Issue #1"); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("replaces existing items with --overwrite", async () => { + const item = storeOutput(sourceDb, makeInput()); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + closeDb(); + + sourceDb.prepare(`UPDATE stored_outputs SET summary = 'OVERWRITTEN' WHERE id = ?`).run(item.id); + exportToFile(dumpFile); + + await handleImportCommand([dumpFile, "--keep-project-key", "--overwrite"]); + + const targetDb = new Database(targetDbPath); + const row = targetDb + .prepare(`SELECT summary FROM stored_outputs WHERE id = ?`) + .get(item.id) as { summary: string } | null; + targetDb.close(); + + expect(row?.summary).toBe("OVERWRITTEN"); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("chunk rows are replaced on --overwrite (no stale FTS entries)", async () => { + const item = storeOutput(sourceDb, makeInput({ full_content: "original content" })); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--keep-project-key"]); + closeDb(); + + // Update content in source and re-export + sourceDb.prepare(`UPDATE stored_outputs SET full_content = 'replacement content' WHERE id = ?`).run(item.id); + exportToFile(dumpFile); + + await handleImportCommand([dumpFile, "--keep-project-key", "--overwrite"]); + + const targetDb = new Database(targetDbPath); + const chunkRows = targetDb + .prepare(`SELECT content FROM content_chunks WHERE output_id = ?`) + .all(item.id) as Array<{ content: string }>; + targetDb.close(); + + // There should be exactly one chunk group and none should contain "original" + expect(chunkRows.length).toBeGreaterThan(0); + expect(chunkRows.every((r) => !r.content.includes("original"))).toBe(true); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); +}); + +// ── dry-run ─────────────────────────────────────────────────────────────────── + +describe("import --dry-run", () => { + test("writes nothing to DB", async () => { + storeOutput(sourceDb, makeInput()); + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + await handleImportCommand([dumpFile, "--dry-run"]); + expect(existsSync(targetDbPath)).toBe(false); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); + + test("accurately counts skips when items already exist", async () => { + storeOutput(sourceDb, makeInput()); + storeOutput(sourceDb, makeInput({ tool_name: "mcp__github__get_issue", summary: "Issue #2" })); + + const dumpFile = makeTmpPath(); + const targetDbPath = makeTmpPath(".db"); + exportToFile(dumpFile); + + process.env.RECALL_DB_PATH = targetDbPath; + try { + // Real import first + await handleImportCommand([dumpFile, "--keep-project-key"]); + closeDb(); + + // Capture dry-run output + let output = ""; + const originalLog = console.log; + console.log = (...a: unknown[]) => { output += a.join(" ") + "\n"; }; + try { + await handleImportCommand([dumpFile, "--keep-project-key", "--dry-run"]); + } finally { + console.log = originalLog; + } + + expect(output).toContain("2 skipped"); + } finally { + delete process.env.RECALL_DB_PATH; + } + }); +}); + +// ── validation ──────────────────────────────────────────────────────────────── + +describe("import validation", () => { + test("rejects invalid JSON", async () => { + const dumpFile = makeTmpPath(); + writeFileSync(dumpFile, "not json at all"); + + const result = Bun.spawnSync( + ["bun", "run", "src/cli.ts", "import", dumpFile], + { cwd: PROJECT_ROOT, stderr: "pipe" } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("Invalid JSON"); + }); + + test("rejects JSON that doesn't match export schema", async () => { + const dumpFile = makeTmpPath(); + writeFileSync(dumpFile, JSON.stringify([{ foo: "bar" }])); + + const result = Bun.spawnSync( + ["bun", "run", "src/cli.ts", "import", dumpFile], + { cwd: PROJECT_ROOT, stderr: "pipe" } + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("recall__export"); + }); + + test("handles empty export gracefully", async () => { + const dumpFile = makeTmpPath(); + writeFileSync(dumpFile, "[]"); + // Should resolve without throwing + await handleImportCommand([dumpFile]); + }); +});