Back to Claude Mem

Plan: Remove the entire duplicate SQLite stack + rewrite its tests against the shipping path

plans/2026-06-20-sqlite-duplicate-stack-removal.md

13.8.020.1 KB
Original Source

Plan: Remove the entire duplicate SQLite stack + rewrite its tests against the shipping path

Scope: src/services/sqlite/ — delete the dead duplicate DB/migration stack AND the parallel free-function CRUD API it propped up. Delete every test coupled to that duplicate and write fresh tests against SessionStore (the path the worker actually runs). Out of scope: installer unification, SearchManager dedup (separate plans).

Philosophy (per maintainer): Do NOT adapt old tests written against the dead abstraction — that's busywork. Delete them, write new ones against SessionStore. The old tests' value is their behavior checklist, not their code; that checklist is captured in Phase 1.

Goal: Remove ~3,000+ src lines of duplication, zero production behavior change, and replace ~2,300 lines of misdirected tests with a smaller, focused suite that exercises the real worker path.

Why this exists: Two parallel SQLite systems live in src/services/sqlite/:

  • Shipping (KEEP): SessionStore — the worker does new Database(DB_PATH)new SessionStore(db), and SessionStore's constructor runs its own inline imperative migrations. All production reads/writes go through SessionStore methods.
  • Dead duplicate (DELETE): ClaudeMemDatabaseMigrationRunner (a second, drifted migration engine) plus a free-function CRUD API (Sessions.ts/Observations.ts/… barrels + submodules + transactions.ts) that re-implements the SessionStore methods. Reachable only from tests.

Phase 0 — Ground Truth (verified by 6 discovery agents + direct grep; re-confirm gates as you go)

Shipping path (KEEP, never touch)

  • src/services/worker/DatabaseManager.ts:17-32new Database(DB_PATH) then new SessionStore(this.db).
  • SessionStore ctor (SessionStore.ts:34, chain ~49-73) runs the inline migrations (schema_versions 4-32).
  • CLI server: worker-service.ts:855 opens new Database(DB_PATH,…) — also no MigrationRunner/ClaudeMemDatabase.
  • The worker never imports ClaudeMemDatabase, MigrationRunner, getDatabase, initializeDatabase, the services/sqlite/index.ts barrel, or the free-function CRUD API. Production's only direct imports into the free-fn tree are 5 helper functions (see keep-set below).

SessionStore API the new tests will call (verified signatures)

Constructor: constructor(dbPathOrDb: string | Database = DB_PATH) — accepts ':memory:' AND a raw bun:sqlite Database (adopts it, then migrates → use this to seed a legacy schema first). Public raw handle: store.db. Has close().

DomainMethod (SessionStore.ts line)
SessionscreateSDKSession(contentSessionId, project, userPrompt, customTitle?, platformSource?): number (1692); updateMemorySessionId(sessionDbId, memId|null) (1030); markSessionCompleted(sessionDbId) (1038); ensureMemorySessionIdRegistered(sessionDbId, memId, workerPort?) (1048); getSessionById(id) (1624)
ObservationsstoreObservation(memId, project, observation{type,title,subtitle,facts[],narrative,concepts[],files_read[],files_modified[],agent_type?,agent_id?,metadata?}, promptNumber?, discoveryTokens?, overrideTimestampEpoch?, generatedByModel?): {id,createdAtEpoch} (1781); storeObservations(memId, project, observations[], summary|null, promptNumber?, …): {observationIds[],summaryId,createdAtEpoch} (1901); getObservationById(id) (1475)
SummariesstoreSummary(memId, project, summary{request,investigated,learned,completed,next_steps,notes}, promptNumber?, …): {id,createdAtEpoch} (1855); getSummaryForSession(memId) (1555)
PromptssaveUserPrompt(contentSessionId, promptNumber, promptText): number (1754); getPromptNumberFromUserPrompts(contentSessionId): number (1685); findRecentDuplicateUserPrompt(contentSessionId, promptText, windowMs) (1406)

3 old free-functions have no SessionStore method — new tests that need them import the leaf function and pass store.db: computeObservationContentHash (observations/store.ts:8), getFirstObservationCreatedAt (observations/recent.ts:36), getObservationsByFilePath (observations/get.ts:97). All three are in the keep-set anyway.

Test gotchas (from source, save debugging time):

  • getSessionSummaryById class method (SessionStore.ts:2435) queries non-existent columns → throws; don't call it (it's deleted in Phase 5 regardless).
  • getRecentObservations reads the legacy text column → NULL for rows written by storeObservation. Assert content via getObservationById (title/subtitle/narrative), not text.
  • Empty-project guard (project || cwd-derived) exists only in leaf observations/store.ts, not in SessionStore.storeObservation/.storeObservations. The worker path stores empty project as-is. Test the leaf for the guard; don't assert it on SessionStore.
  • pending_messages has no SessionStore store method — seed via raw store.db.prepare(INSERT…). For the cleanup test, observer cascade needs memory_session_id set: createSDKSession inserts NULL, so call updateMemorySessionId(id,'obs-memory-N') before storeObservation.

Dead duplicate — DELETE (all production-dead, grep-confirmed)

  1. DB/migration stack: Database.ts (ClaudeMemDatabase, the sqlite DatabaseManager singleton — NOT the worker's, getDatabase, initializeDatabase, Migration), migrations/runner.ts (MigrationRunner, 1147 lines), index.ts (barrel, 0 importers), and SchemaRepair (openWithSchemaRepair, imported only by Database.ts).
  2. Free-function CRUD API: all 6 barrels + submodule files + transactions.ts (see keep/delete map). transactions.ts confirmed dead: 0 direct prod importers; worker uses sessionStore.storeObservations.
  3. Dead SessionStore methods: getSessionSummaryById (2435-2476, dead+broken), storeObservationsAndMarkComplete (2017-2143, 0 call sites — .storeObservationsAndMarkComplete( greps to zero).

Free-function tree — KEEP/DELETE map

KEEP (5 files, trimmed to ONLY the live export — production imports these directly):

FileKeep ONLYLive importer
observations/store.tscomputeObservationContentHashSessionStore.ts:15
observations/files.tsparseFileListSessionStore.ts:16, ChromaSync.ts:7
observations/get.tsgetObservationsByFilePathDataRoutes.ts:17
observations/recent.tsgetFirstObservationCreatedAtDataRoutes.ts:18
prompts/get.tsfindRecentDuplicateUserPromptSessionStore.ts:18

Each survivor's only in-tree dep is a type-only ./types.js import; after trimming, those type imports become removable (the survivors use types from ../../../types/database.js, out of tree). Net: keep 5 function bodies, drop their ./types.js import lines.

DELETE ENTIRELY (18 files — only importers are the dead barrels/index/tests):

Observations.ts Sessions.ts Summaries.ts Prompts.ts Timeline.ts Import.ts   # 6 barrels
transactions.ts
sessions/create.ts sessions/get.ts sessions/types.ts
summaries/store.ts summaries/get.ts summaries/recent.ts summaries/types.ts
prompts/store.ts prompts/types.ts
timeline/queries.ts
import/bulk.ts

sessions/, summaries/, timeline/, import/ dirs become empty → remove.

The one risk — CONFIRMED SAFE

Server tables (projects, server_sessions, memory_items, teams, api_keys, audit_log, …) are created by ensureServerStorageSchema (src/storage/sqlite/schema.ts:21-305), called from 13 live server-repo sites — not by MigrationRunner. Deleting the duplicate doesn't touch them.

Anti-pattern guards (every phase)

  • ❌ Never delete a src/services/sqlite/ file before grepping its direct importers (excluding the dead index.ts).
  • ❌ Don't touch worker/DatabaseManager.ts, SessionStore's migration chain, or src/storage/sqlite/*.
  • ❌ Don't write new tests against the leaf free-functions where a SessionStore method exists — the worker uses methods; test the method. (Exceptions: the 3 leaf-only helpers above.)
  • ❌ Don't port schema-repair or MigrationRunner-version-specific assertions — those test code the worker never runs.

Phase 1 — Write NEW tests against SessionStore (FIRST, so coverage exists before any deletion)

These must pass against the current tree (SessionStore + the 5 survivor leaf functions all exist now). They become the regression guard for Phases 2-5. New-file layout + behavior spec (KEEP items from the old suites, retargeted):

tests/sqlite/session-store-observations.test.tsnew SessionStore(':memory:')

  • store returns {id>0, createdAtEpoch>0}; all fields round-trip via getObservationById
  • overrideTimestampEpoch honored (epoch + ISO); default = now when omitted
  • null subtitle/narrative stored OK; getObservationById returns null for missing id
  • subagent: agent_type/agent_id stored when provided; default NULL when omitted; agent_type alone OK
  • getFirstObservationCreatedAt(store.db) → null when empty, earliest ISO otherwise (leaf import)

tests/sqlite/session-store-dedup.test.ts

  • computeObservationContentHash (leaf import): deterministic, 16 chars, different content→different hash, null title/narrative OK, no field-boundary collision (\x00 separator → 4 distinct hashes — keep verbatim)
  • identical (memId,title,narrative) dedupes to same id regardless of time gap (collapse the old two "30s window" tests into ONE — dedup is the UNIQUE index, not time-based)
  • different content at same timestamp → distinct ids; content_hash populated (16 chars) on new rows
  • storeObservations batch: 3 identical inputs → 3 equal ids, 1 physical row (real worker hot path)
  • dedup unaffected by agent fields: 2nd insert w/ different agent_type returns existing id, count stays 1, original agent fields preserved
  • (optional, leaf) empty-project guard on observations/store.ts leaf only — note SessionStore stores empty as-is

tests/sqlite/session-store-sessions.test.ts

  • createSDKSession → id>0; idempotent (same content_session_id→same id); different→different
  • persisted user_prompt tag-stripped + bounded to MAX_STORED_PROMPT_CHARS ending
  • getSessionById round-trips fields; memory_session_id defaults null; null for missing
  • custom_title: stored at creation; defaults null; backfilled on idempotent call if unset; not overwritten if set; empty→null
  • platform_source: defaults 'claude'; preserves non-default when legacy caller omits it; throws /Platform source conflict/ on explicit conflict
  • updateMemorySessionId sets + allows re-update to different value

tests/sqlite/session-store-prompts.test.ts

  • saveUserPrompt → id>0, incrementing, distinct across sessions; prompt_text tag-stripped + bounded
  • findRecentDuplicateUserPrompt finds dup in window (id/prompt_number/prompt_text)
  • getPromptNumberFromUserPrompts: 0 when none; counts; session-isolated; handles 100 prompts

tests/sqlite/session-store-summaries.test.ts

  • storeSummary{id>0,createdAtEpoch>0}; all fields + prompt_number round-trip via getSummaryForSession
  • overrideTimestampEpoch honored; default = now; null notes preserved
  • getSummaryForSession: by memId; null when none; returns MOST RECENT when multiple

tests/sqlite/session-store-transactions.test.ts — target sessionStore.storeObservations (the real path)

  • stores N atomically → ids + null summaryId when no summary; correct createdAtEpoch
  • all observations in a batch share one timestamp
  • observations + summary together → summaryId non-null, summary retrievable
  • empty observations array → 0 ids, null summary; summary-only → 0 ids, summaryId set
  • promptNumber applied to all in batch
  • DROP the old storeObservationsAndMarkComplete queue-delete/rollback tests — that path is dead (worker completes via SessionCompletionHandler, not this function)

tests/sqlite/session-store-migrations.test.ts — seed legacy via new Database(':memory:') then new SessionStore(rawDb)

  • legacy NULL content_hash rows → rewritten to __null_migration_<id>__ (preserved), non-NULL dups deduped to one, ux_observations_session_hash UNIQUE index created (this is SessionStore's v29 addObservationsUniqueContentHashIndex; the existing data-integrity "Migration parity" test is the canonical source — port it here)
  • idempotency: constructing SessionStore twice over the same db → no throw, identical schema/version set, data unchanged
  • fresh-DB init creates SessionStore's core tables (schema_versions, sdk_sessions, observations, session_summaries, user_prompts, pending_messages) — assert SessionStore's tables, NOT MigrationRunner's server tables
  • (optional) PRAGMA foreign_key_list shows on_update=CASCADE,on_delete=CASCADE on a fresh SessionStore db
  • (optional) seed a legacy pending_messages with retry_count/completed_at_epoch/worker_pid → SessionStore drops them (v31/v32)
  • DROP: MigrationRunner-only server tables (v33/34), specific cross-stack version-number lists, mig-24 drift, #979 old-DatabaseManager conflicts, crash-recovery _new temp tables

tests/infrastructure/cleanup-v12_4_3.test.ts — REWRITE in place: reseed seedDatabase via new SessionStore(dbPath) + methods (raw store.db INSERT for pending_messages only). All behaviors KEEP:

  • missing DB → marker skipped:'no-db', null backupPath, zero counts
  • purges observer sessions (OBSERVER_SESSIONS_PROJECT) + cascade rows, purges stuck pending (COUNT>=10), wipes chroma dir + sync-state, writes backup; real-project rows survive
  • pending preserved when stuck count < 10 (9 survives); idempotent (2nd run no-ops, no 2nd backup)
  • proceeds on non-credible statfsSync (bsize=0) with WARN containing 'non-credible' {bsize:0} (keep the spy assertion)
  • honors CLAUDE_MEM_SKIP_CLEANUP_V12_4_3=1 (exits, no marker, observer intact)

Verify Phase 1: bun test tests/sqlite/session-store-*.test.ts tests/infrastructure/cleanup-v12_4_3.test.ts → all green against the current tree. New tests must import ONLY SessionStore + the 5 survivor leaf functions — grep them to confirm no import of a to-be-deleted barrel/transactions.ts.


Phase 2 — Delete the old tests coupled to the dead stack

rm tests/sqlite/observations.test.ts tests/sqlite/transactions.test.ts \
   tests/sqlite/sessions.test.ts tests/sqlite/prompts.test.ts \
   tests/sqlite/summaries.test.ts tests/sqlite/data-integrity.test.ts \
   tests/services/sqlite/observations/store-subagent-label.test.ts \
   tests/services/sqlite/migration-runner.test.ts \
   tests/services/sqlite/schema-repair.test.ts

(schema-repair.test.ts has NO replacement — it tests ClaudeMemDatabase.openWithSchemaRepair, which the worker never uses.) Verify: grep -rn "ClaudeMemDatabase\|MigrationRunner\|runAllMigrations\|sqlite/transactions\|sqlite/Sessions\|sqlite/Observations" tests/ → ZERO. bun test tests/ green (new suite carries the coverage).


Phase 3 — Delete the dead DB/migration stack

Gate:

grep -rn "from .*services/sqlite/Database" src/ --include=*.ts | grep -v "Database.ts:"   # ZERO
grep -rn "services/sqlite/index" src/ tests/ --include=*.ts                                # ZERO
grep -rn "ensureServerStorageSchema" src/ --include=*.ts                                    # 13 callers, none in runner.ts
rm src/services/sqlite/Database.ts src/services/sqlite/migrations/runner.ts src/services/sqlite/index.ts
rmdir src/services/sqlite/migrations
grep -rn "SchemaRepair\|openWithSchemaRepair" src/ --include=*.ts   # expect ZERO after Database.ts gone
rm src/services/sqlite/SchemaRepair.ts   # use actual path from grep; only if zero importers

Verify: bunx tsc --noEmit — no dangling imports.


Phase 4 — Delete the duplicate free-function CRUD API + trim survivors

Gate (per group, must be zero non-barrel/non-test importers):

grep -rn "sqlite/transactions\|sqlite/Sessions\|sqlite/Observations\|sqlite/Summaries\|sqlite/Prompts\|sqlite/Timeline\|sqlite/Import" src/ --include=*.ts
grep -rn "sessions/create\|sessions/get\|summaries/store\|summaries/get\|summaries/recent\|prompts/store\|timeline/queries\|import/bulk" src/ --include=*.ts

Delete (18 files):

rm src/services/sqlite/{Observations,Sessions,Summaries,Prompts,Timeline,Import}.ts
rm src/services/sqlite/transactions.ts
rm src/services/sqlite/sessions/{create,get,types}.ts
rm src/services/sqlite/summaries/{store,get,recent,types}.ts
rm src/services/sqlite/prompts/{store,types}.ts
rm src/services/sqlite/timeline/queries.ts src/services/sqlite/import/bulk.ts
rmdir src/services/sqlite/sessions src/services/sqlite/summaries src/services/sqlite/timeline src/services/sqlite/import

Trim the 5 survivor files to keep only the live export + remove their now-dead ./types.js imports:

observations/store.ts  → keep computeObservationContentHash; delete storeObservation; drop ./types.js import
observations/files.ts  → keep parseFileList; delete getFilesForSession; drop ./types.js import
observations/get.ts    → keep getObservationsByFilePath; delete getObservationById/getObservationsByIds/getObservationsForSession; drop ./types.js import
observations/recent.ts → keep getFirstObservationCreatedAt; delete getRecentObservations/getAllRecentObservations; drop ./types.js import
prompts/get.ts         → keep findRecentDuplicateUserPrompt; delete the other 7 prompt getters; drop ./types.js import (it uses LatestPromptResult from ../../../types/database.js)

Then observations/types.ts and prompts/types.ts should have no remaining consumer → grep and delete if zero. Verify: bunx tsc --noEmit clean; bun test tests/sqlite/ green. Confirm the 5 live anchors still resolve (SessionStore.ts:15/16/18, ChromaSync.ts:7, DataRoutes.ts:17/18).


Phase 5 — Delete the dead SessionStore methods

Gate: grep -rn "\.getSessionSummaryById(\|\.storeObservationsAndMarkComplete(" src/ tests/ --include=*.ts → ZERO (the standalone sessions/get.ts:getSessionSummaryById is already deleted in Phase 4; the live one is gone with it — confirm nothing else references either name). Delete (higher range first to keep line numbers):

  1. SessionStore.ts lines 2435-2476 (getSessionSummaryById)
  2. SessionStore.ts lines 2017-2143 (storeObservationsAndMarkComplete) Verify: bunx tsc --noEmit clean; bun test tests/ green.

Phase 6 — Final verification gate

  1. npm run build-and-sync — succeeds, worker restarts.
  2. Worker boots + migrates a fresh DB (temp ~/.claude-mem path) — no errors in worker log.
  3. Worker migrates a seeded legacy v22 DB without error (reuse the Phase 1 migrations-test seed helper).
  4. bun test — full suite green.
  5. bunx tsc --noEmit — no dangling imports of ClaudeMemDatabase, MigrationRunner, getDatabase, initializeDatabase, Database.ts, SchemaRepair, any deleted barrel/submodule, or the two SessionStore methods.
  6. Dead-reference sweep: grep -rn "ClaudeMemDatabase\|MigrationRunner\|sqlite/Database\|sqlite/index\|sqlite/transactions\|sqlite/migrations" src/ tests/ --include=*.ts → ZERO.

Done when: all 6 pass; the only migration engine + CRUD API in the tree is SessionStore; the new session-store-* suite covers the shipping behaviors; the 5 leaf helpers remain for their prod callers.


Line accounting (approx)

Delete (src)Lines
migrations/runner.ts1147
Database.ts211
index.ts22
SchemaRepair.ts (orphaned)~?
free-fn CRUD API (18 files)~1,500
survivor trims~150
SessionStore dead methods169
src subtotal~3,200
TestsLines
old suites deleted (9 files)~2,340
new session-store-* suites added~+1,000
test net~−1,340

Net: ~−4,500 lines, zero production behavior change, real path now directly tested.

Execution order rationale

New tests first (Phase 1) → they pass on the current tree and guard every subsequent deletion. Old tests next (Phase 2) → nothing then references the duplicate. Then peel the duplicate in dependency order: DB stack (3) → free-fn API + survivor trim (4) → dead methods (5) → gate (6). The suite stays green at every step.