Skip to content

feat: add recall export with JSON, Markdown, SQL dump, and SQLite backup formats#59

Merged
edheltzel merged 4 commits into
mainfrom
feat/43-export
Jun 11, 2026
Merged

feat: add recall export with JSON, Markdown, SQL dump, and SQLite backup formats#59
edheltzel merged 4 commits into
mainfrom
feat/43-export

Conversation

@edheltzel

Copy link
Copy Markdown
Owner

Closes #43

Summary

Adds recall export — portable and disaster-recovery export paths for Recall memory, built on the merged #41 (chunking) and #42 (Record Provenance) foundations.

  • --format json | markdown — app-level export of the six durable tables (sessions, messages, decisions, learnings, breadcrumbs, loa_entries). Every row of a provenance-bearing table carries an explicit provenance field; legacy NULL is exported as the literal unknown — never omitted, never guessed. sessions (no provenance column) gets no invented field. Embeddings excluded.
  • --format sql — textual SQL dump (CREATE TABLE from sqlite_master + per-row INSERTs) of the same durable tables. Restorable into an empty DB; one-command restore intentionally out of scope per the issue.
  • --format sqlite — full database backup via VACUUM INTO (includes embeddings and internal tables). Binary, so it always requires --output; refuses to overwrite.
  • --backup — timestamped SQL dump to ~/.agents/Recall/backups/, creating the directory if needed, never overwriting (-N suffix on collision), printing the output path.

Output contract

  • no --output: data → stdout, manifest summary → stderr (stdout stays pipeable — verified by a test that JSON.parses captured stdout)
  • --output <file>: single export file, manifest summary → stdout
  • --output <dir>: artifact + manifest.json (always written in dir mode)

Manifest carries Recall version, timestamp, PRAGMA user_version, format, included tables, per-table counts, per-table provenance counts including unknown (explicit even at zero), and the embeddings flag.

Design notes

  • Chunking (Audit SQLite variable limits and add shared chunking #41): export reads bind a fixed parameter count via keyset pagination (WHERE id > ? LIMIT ?) sized by the shared SQLITE_SAFE_CHUNK_SIZE, so no bind list ever scales with row count — documented in the module header following the chunk.ts audit-note convention. A test exports >2 batches and asserts no dropped/duplicated rows.
  • SQL dump keeps NULL provenance deliberately: the schema CHECK constraint only admits the four real values or NULL; dumping the string 'unknown' would make the dump unrestorable. The explicit-unknown contract applies to JSON/Markdown per the issue.
  • PROVENANCE_TABLES hoisted to src/types/index.ts as the single source of truth (was about to be duplicated against the backfill's BACKFILL_TABLES list in src/commands/provenance.ts — both now import it).
  • Add focused property-based tests for import, chunking, export, and dedup invariants #44 readiness: all renderers/helpers in src/lib/export.ts are pure functions over an open DB handle + plain data, exported for the upcoming property suites (not written here, per scope).

Testing

  • 27 new tests (tests/commands/export.test.ts, tests/lib/export.test.ts) covering every issue-mandated scenario: provenance presence, explicit-unknown, manifest counts/provenance counts, SQL dump creation, SQLite backup creation + embeddings inclusion, backup no-overwrite, file-vs-dir behavior, stdout piping, large-export pagination.
  • Full suite: 536 pass / 0 fail; bun run lint clean.
  • Manual smoke through the real CLI: stdout-only JSON with stderr summary, dir mode artifact + manifest.json, sqlite-without-output error.
  • Pre-PR review: simplify pass + 7-angle review; applied fixes include manifest.json now written only after VACUUM INTO succeeds (a failed vacuum can no longer leave a manifest describing a nonexistent backup).

Known follow-ups (pre-existing, intentionally untouched)

src/commands/migrate.ts and src/commands/doctor.ts carry their own inline timestamp-slug and ~/.agents/Recall/backups path constructions; they could adopt the new timestampSlug() / defaultBackupDir() helpers in a follow-up.

Post-Deploy Monitoring & Validation

CLI-only feature, no server/runtime impact. After release: run recall export | head and recall export --backup on a real install and confirm the backup file lands under ~/.agents/Recall/backups/. Failure signal: any Export failed: stderr output on a healthy DB. No additional operational monitoring required.

…Lite backup formats

- recall export --format json|markdown|sql|sqlite with file/dir/stdout output contract
- explicit provenance on every provenance-bearing row; NULL exported as 'unknown'
- manifest with version, schema, counts, provenance counts incl. unknown
- --backup: timestamped SQL dump to ~/.agents/Recall/backups/, never overwrites
- keyset-paginated reads sized by shared SQLITE_SAFE_CHUNK_SIZE for large exports
…qlite dir-mode paths

- hoist the provenance-bearing table list to types/index.ts (was duplicated
  between lib/export.ts and the provenance backfill's BACKFILL_TABLES)
- name manifest.json path once in the sqlite directory branch
…s; unify text-format rendering

Review findings from the pre-PR pass:
- a failed VACUUM INTO no longer leaves a manifest.json describing a
  backup that was never created
- renderAppExport/renderSqlExport collapsed into renderTextExport;
  ARTIFACT_EXT narrowed to the formats that reach it
- document why --format has no Commander default and the id-PK invariant
  behind keyset pagination; docs note trailing-slash rule for new dirs
@edheltzel

Copy link
Copy Markdown
Owner Author

Review — PR #59 recall export (Closes #43)

How this was reviewed: CI verified green on head c40733a (macOS primary + Ubuntu smoke). Full suite re-run locally from the branch: 536 pass / 0 fail, tsc --noEmit clean. Four specialist review passes (code quality, test coverage, silent failures, comment accuracy) plus hands-on CLI exercise of every format and flag combination against a scratch database (1,200-row messages table, legacy NULL-provenance rows seeded in decisions/learnings/loa_entries).

Contract verification — all five implemented correctly

Contract Result
Provenance portability ✅ Verified in real output: every provenance-bearing row exports with an explicit provenance field; legacy NULL renders as literal unknown in JSON ({"decision":"legacy decision","provenance":"unknown"}) and Markdown (3 **provenance:** unknown lines = exactly the 3 seeded legacy rows); sessions gets no invented field.
NULL-in-SQL-dump decision Endorsed — it is not just defensible, it is required. Empirically proven: INSERT … provenance='unknown' is rejected by the schema CHECK (CHECK constraint failed: provenance IN ('verbatim', 'user_authored', 'extracted', 'derived')), while the actual dump restores cleanly through sqlite3 into a fresh DB with counts matching (1200/2/1), NULL preserved, and quote/newline content round-tripping. renderSqlDump correctly bypasses toExportRow (src/lib/export.ts:266).
stdout purity ✅ No --output: exactly one JSON value on stdout (jq -s 'length' → 1), summary on stderr. All error paths verified to leave stdout at 0 bytes with exit 1.
Backup safety ✅/⚠️ VACUUM-before-manifest ordering verified including a forced-failure test (read-only dir → error, exit 1, zero artifacts, no orphan manifest.json — the c40733a fix works). Overwrite refusal and sequential -N suffixing verified. But see Blocking #1: the no-overwrite guarantee fails under concurrency.
Chunking convention ✅ Keyset pagination WHERE id > ? ORDER BY id LIMIT ? — fixed 2-param bind; verified 1,200 rows export complete and ordered across 3 batches. All six EXPORT_TABLES confirmed INTEGER PRIMARY KEY AUTOINCREMENT (schema.ts). Audit-note convention from src/lib/chunk.ts followed in both new files. No bind list anywhere scales with row count.
PROVENANCE_TABLES hoist ✅ Single source in src/types/index.ts:14; src/commands/provenance.ts:34 and src/lib/export.ts:40-41 both consume it; the old BACKFILL_TABLES duplicate is removed. Backfill import verified correct (table validation + error message both use the canonical list).

Blocking

1. --backup's "never overwrites" guarantee is check-then-act — concurrent backups silently destroy each other. Reproduced: 4 concurrent recall export --backup invocations in the same second produced 1 surviving file — all four processes resolved the same path via existsSync (src/lib/export.ts:74-83) and writeFileSync with default flag 'w' truncated three of the writes, each reporting success with exit 0. This violates the issue #43 acceptance criterion in the one mode whose documented contract is never overwrite, and concurrent agent sessions are this tool's normal operating environment. Fix is small: write with { flag: 'wx' } and advance to the next -N candidate on EEXIST (applies to --backup at src/commands/export.ts:96 and dir-mode artifacts at :162).

2. A failed VACUUM INTO leaves a corrupt partial file, and the retry error then actively misleads. SQLite documents that an interrupted VACUUM INTO can leave an incomplete output file the application must delete. Scenario: disk fills mid-backup → error + exit 1 (correct so far), but a corrupt partial backup.db remains; the user frees space, retries, and hits Refusing to overwrite existing database backup at … (src/commands/export.ts:130-131) — implying a valid backup exists where a corrupt remnant sits. For a disaster-recovery tool that is the worst failure shape available. Fix: wrap the VACUUM (src/commands/export.ts:136), unlinkSync(target) on failure, rethrow.

3. No test pins the SQL-dump-keeps-NULL behavior — the PR's central deliberate decision is unguarded. Mutation check: if renderSqlDump were refactored to reuse the normalized collectExportData (a plausible change given this repo's DRY mandate), every existing test still passes — and restores would then silently corrupt legacy NULLs into the literal string 'unknown'… except they wouldn't restore at all, because the CHECK constraint rejects it. One round-trip test covers both the NULL fidelity and the "restorable into an empty database" claim (src/lib/export.ts:244): execute the dump into a fresh Database(':memory:'), assert legacy provenance restores as null and the dump contains no 'unknown' literal.

4. CHANGELOG. ## [Unreleased] has no entry for a major user-facing CLI surface; docs/releasing.md makes the changelog part of the release flow. Two lines under ### Added.

Non-blocking (recommended)

  • Repeat dir-mode exports clobber manifest.json while artifacts accumulate via resolveNonClobbering — N artifacts, 1 manifest, and ExportManifest has no field naming the artifact it describes (src/commands/export.ts:141, 161-163). If "manifest describes the latest export" is the intent, document it in the header comment and docs/cli-reference.md, and consider adding the artifact filename(s) to the manifest.
  • Latent silent truncation in collectTableRows (src/lib/export.ts:113): a future EXPORT_TABLES entry with a TEXT/UUID pk would ship partial exports with exit 0 — the only guard is a comment. Cheap insurance: validate lastId is a finite number, or cross-check manifest.counts[t] against data[t].length (both already computed).
  • Two comment mis-citations: src/lib/export.ts:11 claims a "search/recent display contract" — recent.ts renders no provenance at all (search.ts does); src/lib/export.ts:8 cites ADR-0001 for the NULL→'unknown' export rendering rule, but ADR-0001 only covers write-path stamping — the rendering rule comes from issue Add mem export with JSON, Markdown, SQL dump, and SQLite backup formats #43 (and Add Record Provenance across memory records #42's storage semantics).
  • Minor: error catch in src/index.ts:655 discards stack/error-class with no debug escape hatch; ENOSPC on the dir-mode manifest write orphans an artifact without naming it; empty-DB export and FTS-shadow-exclusion (listPhysicalTables) have no test; sqlQuote silently maps non-finite numbers to NULL (tested but undocumented).

Strengths

Genuinely strong PR. Real-DB tests at the right altitude throughout (the SQLite backup test opens the copy and counts rows; the chunking test spans 3 real batches via SQLITE_SAFE_CHUNK_SIZE * 2 + 50; the no-overwrite test re-runs with an identical timestamp). The --format-has-no-Commander-default comment is load-bearing and exactly right. Every deliberate error message names the conflicting flags and the rule. No SQL injection surface — every interpolated table name derives from hardcoded constants. The manifest-after-VACUUM ordering fix in c40733a is correct and verified under forced failure.


Verdict: REQUEST CHANGES — items 1 and 2 are small fixes in the disaster-recovery path (both empirically demonstrated or SQLite-documented), item 3 is one test guarding this PR's defining decision, item 4 is two lines. Everything else is follow-up material. Happy to re-review immediately on push.

…UM cleanup, round-trip test, changelog

- --backup and dir-mode text artifacts now claim their filename atomically
  (writeFileSync flag 'wx', advance to next -N candidate on EEXIST) — four
  concurrent same-second backups verified to produce four distinct files;
  resolveNonClobbering retained only for the VACUUM INTO target, where
  SQLite itself refuses an existing file
- a failed VACUUM INTO now unlinks its partial output so a retry is not
  blocked by the refuse-to-overwrite guard on a corrupt remnant
- round-trip test pins that SQL dumps keep NULL provenance (never the
  'unknown' display literal) and restore cleanly into an empty database
- CHANGELOG: Unreleased entry for recall export
- fix two comment mis-citations (search-only display contract; rendering
  rule comes from #43, not ADR-0001)
@edheltzel

Copy link
Copy Markdown
Owner Author

Review fixes pushed in 320ee04 — all four blocking items addressed:

  1. TOCTOU race: --backup and dir-mode text artifacts now claim their filename atomically via writeNonClobbering() (writeFileSync flag 'wx', advancing to the next -N candidate on EEXIST). Reproduced your scenario: 4 concurrent same-second --backup invocations now produce 4 distinct files (base + -1/-2/-3), none truncated. resolveNonClobbering survives only for the VACUUM INTO target, where SQLite itself refuses an existing file (lost race fails loudly) — its header comment now states that constraint.
  2. VACUUM partial file: failure path unlinks the target before rethrowing, so a retry no longer hits the refuse-to-overwrite guard on a corrupt remnant.
  3. Round-trip test: new test dumps the seeded DB (legacy NULL + known provenance, quote/newline content), asserts the dump contains no 'unknown' literal, restores via exec into a fresh :memory: DB, and checks per-table counts, NULL fidelity, known-value fidelity, and byte-for-byte content round-trip.
  4. CHANGELOG: recall export entry added under [Unreleased] / Added.

Also took the allowed trivial fix: both comment mis-citations corrected (search-only display contract; rendering rule attributed to #43 rather than ADR-0001). Non-blocking items left untouched as directed.

Gate: bun run lint clean, full suite 539 pass / 0 fail (3 new tests).

@edheltzel

Copy link
Copy Markdown
Owner Author

Re-review — delta c40733a..320ee04

CI green on head 320ee04 (macOS primary + Ubuntu smoke). Local from the branch: tsc --noEmit clean, 539 pass / 0 fail (536 + 3 new). The branch diff touches zero files matching extraction-semaphore — the earlier macOS flake on tests/lib/extraction-semaphore.test.ts is confirmed unrelated to this PR.

The four blocking items — all genuinely fixed

  1. Atomic no-overwrite (TOCTOU)writeNonClobbering (src/lib/export.ts) claims names with O_EXCL (flag: 'wx'), advancing -N on EEXIST; adopted by --backup and dir-mode text artifacts. Verified cross-process: 4 concurrent processes racing the identical target each landed on a distinct file (backup.sql, -1, -2, -3) with their own content intact. The surviving resolveNonClobbering is correctly scoped to the sqlite path, where VACUUM INTO itself refuses an existing target — a lost race fails loudly, never overwrites. Unit tests pin both the advancement and the existing-file-untouched behavior.
  2. VACUUM INTO partial cleanup — failure now unlinks the target and rethrows (src/commands/export.ts:136-144), killing the corrupt-remnant → misleading "Refusing to overwrite" sequence. Comment accurately cites the SQLite-documented behavior.
  3. Round-trip test — pins exactly the contract: dump contains no 'unknown' literal, restores into a fresh :memory: DB, all six table counts match, legacy NULL restores as NULL, and quote/newline content round-trips byte-for-byte. The DRY-refactor regression path is now guarded.
  4. CHANGELOG — proper [Unreleased] → Added entry.

Both comment mis-citations were also corrected (ADR-0001 now scoped to storage semantics; the nonexistent "recent display contract" now accurately names search.ts).

One honest correction and one observation

  • Correction to my original repro: re-running with per-process exit codes shows concurrent same-second --backup invocations mostly fail loudly with Export failed: database is locked (exit 1) — SQLite lock contention at getDb() open, not the file-write race, dominated the original "4 → 1 file" observable. The TOCTOU window was real in the code (existsSync + flag 'w') and is genuinely closed now, but the original demonstration overstated how often it bit at the file layer.
  • Pre-existing, out of scope: that database is locked on concurrent CLI opens (every command, not just export — getDb() sets journal_mode = WAL with no busy timeout) might deserve a busy_timeout pragma someday. Loud failure, no data loss — follow-up material at most.

All deferred non-blocking items from the original review remain valid follow-ups.


Final verdict: APPROVE — all four blocking fixes verified genuine (race fix empirically, cross-process), CI green on head, 539/0 locally. Merge-ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add mem export with JSON, Markdown, SQL dump, and SQLite backup formats

1 participant