Skip to content

Commit ec155dc

Browse files
committed
feat(server): Phase 0 plumbing for state.db durable-write split
Adds default_state_db_path (.calm/state.db, mirrors default_db_path), a state_db_path field on CalmServer (propagates to every for_connection clone via ..self.clone()), and two new connection helpers: make_state_read_conn (query_only read) and state_write_conn (open_state_writer, synchronous=FULL) — the state.db counterparts of make_read_conn/memory_write_conn. new_with_preset now also initializes state.db (init_state_db) and runs migrate_legacy_durable_tables to pull any durable rows a pre-split index.db still has, before recover_incomplete/reconcile_stale_at_startup -- both of which now scan the state connection instead of the index one. Nothing yet calls the new read/write helpers from a tool handler (Phase 2-4 of docs/plans/2026-08-05-state-db-rewiring-execution-plan.md) -- this commit only stands the plumbing up. Empirically confirmed while testing this phase: 80 calm-server tests already fail on unmodified HEAD with "no such table: edit_transactions" et al -- independent proof of the plan doc's §0 finding that PR #58 split the durable schema out of index.db without wiring any real call site to state.db, so every durable-table operation on a freshly created index.db currently targets a table that doesn't exist there. Also found while wiring: memory_write_conn is shared by remember AND pattern_debt_register/pattern_debt_status, but pattern_debt lives in the rebuildable index.db, not state.db -- so unlike the plan doc's §2.1 table (which said to redirect memory_write_conn itself), this adds a separate state_write_conn instead and leaves memory_write_conn pointed at index.db for pattern_debt's sake. remember's own call site moves to state_write_conn in the next phase.
1 parent c095a8d commit ec155dc

3 files changed

Lines changed: 75 additions & 3 deletions

File tree

crates/calm-server/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,17 @@ pub fn default_db_path(project_root: &std::path::Path) -> PathBuf {
723723
project_root.join(".calm").join("index.db")
724724
}
725725

726+
/// Sibling of `default_db_path` for the durable-state split
727+
/// (`KNOWN_LIMITATIONS.md` "Durable state and the rebuildable index share
728+
/// one SQLite file at runtime", `docs/plans/2026-08-05-state-db-rewiring-
729+
/// execution-plan.md`): `project_memory`/`edit_transactions`/`tx_events`/
730+
/// `maintenance_jobs`/`audit_ledger` live here (`db::conn::open_state_writer`,
731+
/// `PRAGMA synchronous=FULL`), separate from the rebuildable index/call-graph
732+
/// data in `default_db_path`'s `index.db` (`synchronous=NORMAL`).
733+
pub fn default_state_db_path(project_root: &std::path::Path) -> PathBuf {
734+
project_root.join(".calm").join("state.db")
735+
}
736+
726737
pub fn doctor(project_root: &std::path::Path, fix: bool) -> Result<()> {
727738
use calm_core::db::schema::init_db;
728739

crates/calm-server/src/tools.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ type CoChangeCache = Arc<
239239
pub struct CalmServer {
240240
project_root: PathBuf,
241241
db_path: PathBuf,
242+
/// Durable-state sibling of `db_path` (`docs/plans/2026-08-05-state-db-
243+
/// rewiring-execution-plan.md`) — `project_memory`/`edit_transactions`/
244+
/// `tx_events`/`maintenance_jobs`/`audit_ledger` live here
245+
/// (`db::conn::open_state_writer`, `synchronous=FULL`), not in the
246+
/// rebuildable index `db_path` points at. Propagates to every
247+
/// `for_connection` clone via `..self.clone()`, same as `db_path`.
248+
state_db_path: PathBuf,
242249
/// Current indexing phase, shared with the background indexer thread.
243250
/// Tools read it to report `indexing_phase` / `edges_ready` honestly instead
244251
/// of assuming the graph is built.

crates/calm-server/src/tools/common.rs

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@ impl CalmServer {
4545
// `busy_timeout` gives every other writer.
4646
let conn = calm_core::db::conn::open_writer(&db_path)?;
4747
calm_core::db::schema::init_db(&conn)?;
48+
drop(conn);
49+
50+
// docs/plans/2026-08-05-state-db-rewiring-execution-plan.md Phase 1:
51+
// durable state (project_memory/edit_transactions/tx_events/
52+
// maintenance_jobs/audit_ledger) lives in a separate state.db opened
53+
// at synchronous=FULL (open_state_writer), not index.db's
54+
// synchronous=NORMAL (open_writer) — none of it is rebuildable from
55+
// source the way the index is. init_state_db is safe on every
56+
// startup (every statement IF NOT EXISTS). migrate_legacy_durable_tables
57+
// is a one-time, idempotent, copy-only pull of any durable rows a
58+
// pre-split index.db still has — it must run BEFORE
59+
// recover_incomplete/reconcile_stale_at_startup below so rows a
60+
// previous process left are already present in state.db when those
61+
// two scan it.
62+
let state_db_path = crate::default_state_db_path(&project_root);
63+
if let Some(parent) = state_db_path.parent() {
64+
std::fs::create_dir_all(parent)?;
65+
}
66+
let state_conn = calm_core::db::conn::open_state_writer(&state_db_path)?;
67+
calm_core::db::schema::init_state_db(&state_conn)?;
68+
if let Err(e) =
69+
calm_core::db::schema::migrate_legacy_durable_tables(&state_conn, &db_path)
70+
{
71+
tracing::warn!(
72+
error = %e,
73+
"startup: migrate_legacy_durable_tables failed -- a pre-split index.db's \
74+
durable rows (if any) were not copied into state.db this attempt; safe to \
75+
retry next startup (copy-only, idempotent)"
76+
);
77+
}
4878
// WS-1 startup recovery (docs/plans/2026-08-02-phase1-p0-execution-plan.md
4979
// §4.6): every real launch path (stdio, unix daemon, CLI direct) funnels
5080
// through `bootstrap` -> here, exactly once per process, so this is the
@@ -60,7 +90,7 @@ impl CalmServer {
6090
// why this does NOT re-invoke the real scip/embed refresh itself
6191
// (`bootstrap` already does that unconditionally moments later for
6292
// whichever process wins the indexer lock).
63-
if let Ok(incomplete) = calm_core::txn::recover_incomplete(&conn) {
93+
if let Ok(incomplete) = calm_core::txn::recover_incomplete(&state_conn) {
6494
for tx in &incomplete {
6595
tracing::warn!(
6696
target: crate::telemetry::AUDIT_TARGET,
@@ -71,7 +101,7 @@ impl CalmServer {
71101
);
72102
}
73103
}
74-
if let Ok(reconciled) = calm_core::maintenance::reconcile_stale_at_startup(&conn) {
104+
if let Ok(reconciled) = calm_core::maintenance::reconcile_stale_at_startup(&state_conn) {
75105
for job in &reconciled {
76106
tracing::warn!(
77107
target: crate::telemetry::AUDIT_TARGET,
@@ -82,12 +112,13 @@ impl CalmServer {
82112
);
83113
}
84114
}
85-
drop(conn);
115+
drop(state_conn);
86116
let coverage = calm_core::analysis::coverage::load_coverage(&project_root);
87117
let tool_router = CalmServer::tool_router_for_preset(&preset)?;
88118
Ok(Self {
89119
project_root,
90120
db_path,
121+
state_db_path,
91122
phase: Arc::new(RwLock::new(IndexingPhase::Scanning)),
92123
last_index_error: Arc::new(RwLock::new(None)),
93124
last_graph_mode: Arc::new(RwLock::new(None)),
@@ -192,6 +223,29 @@ impl CalmServer {
192223
Ok(conn)
193224
}
194225

226+
/// State-db sibling of `make_read_conn` — a dedicated `query_only`
227+
/// connection to `state_db_path` instead of `db_path`, for reading a
228+
/// durable table (`project_memory`/`edit_transactions`/`tx_events`/
229+
/// `maintenance_jobs`/`audit_ledger`). See `docs/plans/2026-08-05-
230+
/// state-db-rewiring-execution-plan.md`.
231+
pub(crate) fn make_state_read_conn(&self) -> Result<rusqlite::Connection, rusqlite::Error> {
232+
let conn = rusqlite::Connection::open(&self.state_db_path)?;
233+
conn.execute_batch("PRAGMA query_only = ON;")?;
234+
Ok(conn)
235+
}
236+
237+
/// Write connection to `state.db` (`synchronous=FULL`) for a durable
238+
/// table (`project_memory`/`edit_transactions`/`tx_events`/
239+
/// `maintenance_jobs`/`audit_ledger`) — the state-db counterpart of
240+
/// `memory_write_conn`. NOT for `pattern_debt` (`memory_write_conn`
241+
/// still serves that — `pattern_debt` lives in the rebuildable
242+
/// `index.db`, not `state.db`; see `docs/plans/2026-08-05-state-db-
243+
/// rewiring-execution-plan.md` §2.1, which specifically flags this
244+
/// distinction after `edit_context` showed `memory_write_conn` is
245+
/// shared by `remember` AND `pattern_debt_register`/`pattern_debt_status`).
246+
pub(crate) fn state_write_conn(&self) -> Result<rusqlite::Connection, rusqlite::Error> {
247+
calm_core::db::conn::open_state_writer(&self.state_db_path)
248+
}
195249
/// Cached `load_config` (audit F12): checks `config.json`'s current
196250
/// mtime against what's cached; a match serves the cached `Config`
197251
/// clone without touching disk beyond the one `stat()` inside

0 commit comments

Comments
 (0)