Skip to main content

rto_graph/
store.rs

1//! SQLite-backed graph store.
2
3use std::collections::BTreeSet;
4use std::path::Path;
5
6use rusqlite::{Connection, OptionalExtension, params};
7
8use crate::findings::{self, AnalysisRun, Finding, FindingsApplied, FindingsLayer};
9use crate::media::{self, MediaFilter, MediaKind, MediaRecord, MediaWrite, ProducerSummary};
10use crate::memory::{
11    self, CacheEntry, CacheStats, CacheSweep, CacheWrite, MemoryError, MemoryFilter,
12    MemoryForgotten, MemoryListing, MemoryRecord, MemoryWrite, Recall, RecallOptions,
13};
14use crate::migrations;
15use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
16use crate::provenance::Provenance;
17
18/// Errors raised by the store.
19#[derive(Debug, thiserror::Error)]
20pub enum StoreError {
21    /// Underlying `SQLite` failure.
22    #[error("sqlite error: {0}")]
23    Sqlite(#[from] rusqlite::Error),
24    /// A node's `meta` could not be (de)serialized as JSON.
25    #[error("json error: {0}")]
26    Json(#[from] serde_json::Error),
27    /// An edge referenced a node key that does not exist in the store.
28    #[error("unknown node key: {0}")]
29    UnknownNode(String),
30    /// An edge violated the provenance/confidence invariant.
31    #[error("invalid edge: {0}")]
32    InvalidEdge(String),
33    /// A stored value could not be interpreted (database corruption).
34    #[error("corrupt store: {0}")]
35    Corrupt(String),
36}
37
38/// A store written by a build **newer** than this one: it records migrations
39/// this binary has never heard of. Produced by [`Store::schema_ahead`].
40///
41/// # Why this is its own type, and not a `StoreError` variant
42///
43/// Opening such a store is not an error and reading its *schema* is sound —
44/// migrations are additive in effect, so every column an older build selects is
45/// still there (each `DROP TABLE` in `crate::migrations` is a table rebuild
46/// that re-selects every prior column). What is *not* sound is **rewriting** the
47/// graph: this build would re-extract every file with its older extractor and
48/// replace the newer build's content with worse content, silently. So the
49/// refusal belongs on the write paths, and the carrier of that refusal has no
50/// business in the type every `Store` call returns.
51///
52/// # Columns are not the whole story, since migration 14
53///
54/// "Additive in effect" was a claim about *schema*, and every migration up to 13
55/// kept it about values too. Migration 14 does not: it widened the provenance
56/// vocabulary, so an older build reading a row written at `external-authored`
57/// gets [`StoreError::Corrupt`] from `Provenance::from_token`, which knows only
58/// its own six-minus-three. That is a real limit of the read guarantee and is
59/// recorded here rather than left to be discovered.
60///
61/// It is not left silent, either. Such a store necessarily records migration 14,
62/// so this type reports it as written by a newer Roteiro *before* any row is
63/// read, and [`StoreError::Corrupt`]'s message names the same cause. The
64/// alternative — teaching `from_token` a tolerant fallback — was rejected: every
65/// fallback value is one of the six, and each choice silently re-tiers a fact.
66///
67/// The second reason is semver, and it is the binding one. `StoreError` is a
68/// public enum on a published 1.x crate and is not `#[non_exhaustive]`, so
69/// adding a variant would stop a downstream exhaustive `match` from compiling —
70/// a breaking change, which in this workspace means a major bump of all seven
71/// crates. A new type is purely additive. Its fields are private and read
72/// through accessors for the same reason: adding one later stays non-breaking.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct SchemaAhead {
75    store: u32,
76    build: u32,
77    unknown: Vec<u32>,
78}
79
80impl SchemaAhead {
81    /// The highest schema version this store records — necessarily one this
82    /// build does not know.
83    #[must_use]
84    pub fn store_version(&self) -> u32 {
85        self.store
86    }
87
88    /// The highest schema version this build knows how to apply.
89    #[must_use]
90    pub fn build_version(&self) -> u32 {
91        self.build
92    }
93
94    /// Every recorded version this build has never heard of, ascending. Never
95    /// empty. More than one means the store ran several unknown migrations, not
96    /// that anything is inconsistent.
97    #[must_use]
98    pub fn unknown_versions(&self) -> &[u32] {
99        &self.unknown
100    }
101}
102
103impl std::fmt::Display for SchemaAhead {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        let unknown = self
106            .unknown
107            .iter()
108            .map(u32::to_string)
109            .collect::<Vec<_>>()
110            .join(", ");
111        // Both versions, and what to do about it. A bare "schema mismatch" would
112        // leave the reader with nothing to act on: the useful facts are *which*
113        // side is behind (this binary) and that the fix is upgrading it, not
114        // deleting the store.
115        write!(
116            f,
117            "this graph store was written by a newer Roteiro: it is at schema \
118             version {store}, and this build knows only up to {build} \
119             (unrecognised migration(s): {unknown}). Refusing to rewrite the \
120             graph — this build would re-extract every file with its older \
121             extractor and replace the newer build's graph with worse content, \
122             silently. Upgrade the `roteiro` binary to one that knows schema \
123             version {store} or later, then retry. Reading this store is \
124             unaffected and needs no upgrade.",
125            store = self.store,
126            build = self.build,
127        )
128    }
129}
130
131impl std::error::Error for SchemaAhead {}
132
133/// A summary of applying/re-applying import layers (see
134/// [`Store::apply_import_layer`] and [`Store::reapply_imports`]).
135#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
136pub struct ImportApplied {
137    /// Number of import layers processed.
138    pub layers: usize,
139    /// Import nodes upserted (across all layers).
140    pub nodes: usize,
141    /// Import edges applied — both endpoints resolved. A duplicate of an
142    /// already-present edge is a harmless no-op but still counted as applied.
143    pub edges_applied: usize,
144    /// Import edges **pruned**: an endpoint was absent (a cross-reference to code
145    /// that no longer exists), so the edge was dropped from the persisted layer
146    /// rather than kept as stale data.
147    pub edges_pruned: usize,
148    /// Import **nodes removed**: a node this `src_ref` used to contribute and no
149    /// longer does, which nothing else in the store still refers to. See
150    /// [`Store::apply_import_layer`] for the rule and why a withdrawn concept
151    /// has to go.
152    pub nodes_removed: usize,
153}
154
155/// Qualified node columns for `SELECT`s that alias the `nodes` table as `n`.
156const NODE_COLS: &str = "n.key, n.kind, n.name, n.path, n.lang, n.blob_hash, n.span_start, n.span_end, n.provenance, n.meta";
157
158/// `SELECT` prefix that yields an [`Edge`] row (endpoints resolved back to keys).
159const EDGE_SELECT: &str = "SELECT ns.key AS src, nd.key AS dst, e.kind, e.provenance, \
160     e.confidence, e.src_ref \
161     FROM edges e JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst";
162
163/// A Roteiro graph store backed by a single `SQLite` database.
164pub struct Store {
165    conn: Connection,
166}
167
168impl Store {
169    /// Open (creating if absent) a store at `path` and apply pending migrations.
170    ///
171    /// # Errors
172    /// Returns [`StoreError::Sqlite`] if the database cannot be opened or a
173    /// migration fails.
174    pub fn open(path: &Path) -> Result<Self, StoreError> {
175        let conn = Connection::open(path)?;
176        Self::from_conn(conn)
177    }
178
179    /// Open an in-memory store (tests, previews).
180    ///
181    /// # Errors
182    /// Returns [`StoreError::Sqlite`] if a migration fails.
183    pub fn open_in_memory() -> Result<Self, StoreError> {
184        let conn = Connection::open_in_memory()?;
185        Self::from_conn(conn)
186    }
187
188    fn from_conn(mut conn: Connection) -> Result<Self, StoreError> {
189        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
190        // Wait for a concurrent writer instead of failing with `database is
191        // locked`. Matters for workspace `serve` (ADR-0008), where a long-lived
192        // server reads a project's graph while that repo's own `roteiro sync`
193        // commits an update to the same file — and for any two Roteiro processes
194        // over one repository, which is ordinary rather than exotic: an editor's
195        // MCP client and a terminal, or several clients each spawning their own
196        // stdio server.
197        conn.busy_timeout(std::time::Duration::from_secs(5))?;
198
199        // **The timeout alone does not deliver that**, which is what this pair of
200        // settings is for. Both are needed and neither substitutes for the other.
201        //
202        // `Immediate` takes the write lock when the transaction *opens*, rather
203        // than upgrading to it on the first write. rusqlite's default is the lazy
204        // one — `TransactionBehavior::Deferred` — and under it two writers that  roteiro:ignore
205        // each read before they write, which every upsert here does, both hold a
206        // shared lock and then both ask to upgrade.
207        // SQLite refuses that immediately with `SQLITE_BUSY` and **does not invoke
208        // the busy handler**, because waiting could only deadlock. So the five
209        // second timeout above was never reached in the case it looks like it
210        // covers: one of the two writers died at once. Taking the lock up front
211        // turns the deadlock into a queue, which is what the timeout can wait on.
212        conn.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate);
213
214        // WAL is the other half: under the default rollback journal a writer
215        // excludes readers for the whole transaction, so a [`crate::sync`] blocks every
216        // query in a running `serve`. In WAL, readers never block writers and
217        // writers never block readers, so only writer-against-writer waits.
218        //
219        // Tolerated rather than required, and **silently** — which is the one place
220        // silence is right here. WAL needs shared memory and is refused on most
221        // network filesystems; `PRAGMA journal_mode` reports the mode actually in
222        // force rather than failing. A store that cannot take WAL keeps exactly
223        // the behaviour it had before this change, so there is no new failure to
224        // report — only an improvement that did not apply. `:memory:` answers
225        // `memory` for the same reason and is equally fine.
226        //
227        // The result is discarded rather than checked because nothing here can act
228        // on it. `PRAGMA journal_mode` answers for anyone who needs to know which
229        // of the two a given store got.
230        let _: String = conn.query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))?;
231
232        migrations::apply(&mut conn)?;
233        Ok(Self { conn })
234    }
235
236    /// The schema version this store has been migrated to: the highest `v` for
237    /// which every migration `1..=v` is recorded as applied.
238    ///
239    /// **Not the maximum recorded version.** Migrations are selected by set
240    /// membership rather than `> MAX(version)` (see `crate::migrations`), so a
241    /// store *can* hold a gap — one written by a build that knew a higher
242    /// migration but not a lower one. For a store recorded as `1..11, 13`, the
243    /// maximum is 13 while none of migration 12's schema is present; reporting 13
244    /// would be a higher number than the truth. The contiguous prefix reports 11,
245    /// which is the version the store can actually be relied on to provide.
246    ///
247    /// The gap is transient in practice — the next [`Store::open`] repairs it —
248    /// but this must not answer wrongly in the window where it exists, which is
249    /// exactly the window in which someone is debugging.
250    ///
251    /// # Errors
252    /// Returns [`StoreError::Sqlite`] on query failure.
253    pub fn schema_version(&self) -> Result<u32, StoreError> {
254        Ok(migrations::store_version(&self.conn)?)
255    }
256
257    /// The highest schema version **this build** knows how to apply — the
258    /// binary's half of the comparison [`Store::schema_ahead`] makes.
259    #[must_use]
260    pub fn build_schema_version() -> u32 {
261        migrations::latest_version()
262    }
263
264    /// `Some` when this store was written by a **newer** build: it records
265    /// migrations this binary has never heard of. `None` — the ordinary case —
266    /// when the store is level with this build or behind it.
267    ///
268    /// Call this before **rewriting** a graph (sync, reconcile, rebuild). Do not
269    /// call it to gate reads: an older binary's reads are sound, which is the
270    /// whole reason this is not checked in [`Store::open`] (issue #342).
271    ///
272    /// # Which version this compares, and why it is not [`Store::schema_version`]
273    ///
274    /// It compares the **set** of recorded versions against the migrations this
275    /// build carries, so it answers *has anything newer than me written here?*
276    /// [`Store::schema_version`] answers a different question — the highest
277    /// **gap-free** version, a floor describing what a reader may rely on — and
278    /// using it here would let the bug through: a store recorded `1..=13, 15`,
279    /// read by a build that knows 13, has a gap-free version of 13, so
280    /// `schema_version() > build` is false while migration 15's schema sits in
281    /// the file and a newer build plainly assembled the graph.
282    ///
283    /// Conflating the two situations would also misreport both, so it does not.
284    /// A **gap** — a store missing a lower migration this build knows — is not
285    /// a store from the future, and [`Store::open`] has already repaired it by
286    /// applying the missing migration before this can be called. What is left is
287    /// only ever a version no build of this vintage could have written.
288    ///
289    /// # Errors
290    /// Returns [`StoreError::Sqlite`] on query failure.
291    pub fn schema_ahead(&self) -> Result<Option<SchemaAhead>, StoreError> {
292        let unknown = migrations::versions_ahead_of_build(&self.conn)?;
293        let Some(&store) = unknown.last() else {
294            return Ok(None);
295        };
296        Ok(Some(SchemaAhead {
297            store,
298            build: Self::build_schema_version(),
299            unknown,
300        }))
301    }
302
303    /// Number of nodes currently in the store.
304    ///
305    /// # Errors
306    /// Returns [`StoreError::Sqlite`] on query failure.
307    pub fn node_count(&self) -> Result<u64, StoreError> {
308        let n: i64 = self
309            .conn
310            .query_row("SELECT COUNT(*) FROM nodes", [], |r| r.get(0))?;
311        Ok(u64::try_from(n).unwrap_or(0))
312    }
313
314    /// Number of edges currently in the store.
315    ///
316    /// # Errors
317    /// Returns [`StoreError::Sqlite`] on query failure.
318    pub fn edge_count(&self) -> Result<u64, StoreError> {
319        let n: i64 = self
320            .conn
321            .query_row("SELECT COUNT(*) FROM edges", [], |r| r.get(0))?;
322        Ok(u64::try_from(n).unwrap_or(0))
323    }
324
325    /// Insert or update a node, keyed by its natural [`Node::key`].
326    ///
327    /// # Errors
328    /// Returns [`StoreError::Json`] if `meta` cannot be serialized, or
329    /// [`StoreError::Sqlite`] on write failure.
330    pub fn upsert_node(&self, node: &Node) -> Result<(), StoreError> {
331        upsert_node(&self.conn, node)
332    }
333
334    /// Insert an edge. Both endpoints must already resolve to nodes.
335    ///
336    /// # Errors
337    /// Returns [`StoreError::InvalidEdge`] if the provenance/confidence
338    /// invariant is violated, [`StoreError::UnknownNode`] if an endpoint key is
339    /// absent, or [`StoreError::Sqlite`] on write failure.
340    pub fn insert_edge(&self, edge: &Edge) -> Result<(), StoreError> {
341        insert_edge(&self.conn, edge)
342    }
343
344    /// Apply a fact set atomically: all nodes are upserted, then all edges are
345    /// inserted, in a single transaction. On any error nothing is committed.
346    ///
347    /// # Errors
348    /// Returns the first error encountered (see [`Store::upsert_node`] and
349    /// [`Store::insert_edge`]); the transaction is rolled back.
350    pub fn apply_factset(&mut self, facts: &FactSet) -> Result<(), StoreError> {
351        let tx = self.conn.transaction()?;
352        for node in &facts.nodes {
353            upsert_node(&tx, node)?;
354        }
355        for edge in &facts.edges {
356            insert_edge(&tx, edge)?;
357        }
358        tx.commit()?;
359        Ok(())
360    }
361
362    /// The `HEAD` tree id recorded at the last successful [`Store::rebuild`], if
363    /// any. Used by the sync engine to detect an unchanged tree.
364    ///
365    /// # Errors
366    /// Returns [`StoreError::Sqlite`] on query failure.
367    pub fn sync_state(&self) -> Result<Option<String>, StoreError> {
368        Ok(self
369            .conn
370            .query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
371            .optional()?)
372    }
373
374    /// The extractor environment recorded with the last committed [`crate::sync`],
375    /// `None` if unset (a legacy row, or the last sync was a worktree/index
376    /// preview). The incremental committed [`crate::sync`] compares this to the current
377    /// env and falls back to a full re-extraction when they differ.
378    ///
379    /// # Errors
380    /// Returns [`StoreError::Sqlite`] on query failure.
381    pub fn sync_env(&self) -> Result<Option<String>, StoreError> {
382        Ok(self
383            .conn
384            .query_row("SELECT env FROM sync_state WHERE id = 0", [], |r| r.get(0))
385            .optional()?
386            .flatten())
387    }
388
389    /// Record the extractor environment for the current synced tree. Called by a
390    /// committed [`crate::sync`] right after it writes the tree, so a later sync can decide
391    /// whether the incremental fast path is sound. A no-op if no tree is recorded.
392    ///
393    /// # Errors
394    /// Returns [`StoreError::Sqlite`] on write failure.
395    pub fn set_sync_env(&self, env: &str) -> Result<(), StoreError> {
396        self.conn
397            .execute("UPDATE sync_state SET env = ?1 WHERE id = 0", [env])?;
398        Ok(())
399    }
400
401    /// Which working tree this graph was assembled from, or `None` when the store
402    /// has never been synced or predates the column (issue #330).
403    ///
404    /// `graph.db` is an assembled view of **one** tree, so a store that came to
405    /// describe a different tree — restored from a backup, copied along with a
406    /// `.git` directory, or reached after a layout change — would otherwise
407    /// answer confidently about the wrong one: [`crate::sync`] reporting "up to date"
408    /// against a state id belonging to someone else's tree, `check` validating a
409    /// tree nobody is looking at. `None` reads as "unknown" and is *adopted*
410    /// rather than treated as a mismatch, so an existing store is never rebuilt
411    /// merely for predating the stamp.
412    ///
413    /// # Errors
414    /// Returns [`StoreError::Sqlite`] on query failure.
415    pub fn synced_worktree(&self) -> Result<Option<String>, StoreError> {
416        Ok(self
417            .conn
418            .query_row("SELECT worktree FROM sync_state WHERE id = 0", [], |r| {
419                r.get(0)
420            })
421            .optional()?
422            .flatten())
423    }
424
425    /// Stamp this graph as assembled from `worktree`. Called by every sync entry
426    /// point right after it writes the tree state, so the two always agree about
427    /// whose tree the state describes. A no-op if no tree is recorded.
428    ///
429    /// Unlike [`Store::set_sync_env`]'s value, this survives a tree write: it
430    /// identifies the *store*, not the tree, and a new commit does not move the
431    /// graph to a different working tree.
432    ///
433    /// # Errors
434    /// Returns [`StoreError::Sqlite`] on write failure.
435    pub fn set_synced_worktree(&self, worktree: &str) -> Result<(), StoreError> {
436        self.conn.execute(
437            "UPDATE sync_state SET worktree = ?1 WHERE id = 0",
438            [worktree],
439        )?;
440        Ok(())
441    }
442
443    /// Atomically replace the entire graph with `facts`, recording `tree` as the
444    /// synced state (or clearing it when `tree` is `None`). All existing nodes
445    /// and edges are deleted first, so the store reflects exactly the given fact
446    /// set.
447    ///
448    /// Passing `None` records *no* synced tree — distinct from an empty string —
449    /// so [`Store::sync_state`] returns `None` and a later [`crate::sync`] will not
450    /// spuriously short-circuit.
451    ///
452    /// # Errors
453    /// Returns the first error encountered (see [`Store::apply_factset`]); on any
454    /// error nothing is committed.
455    pub fn rebuild(&mut self, facts: &FactSet, tree: Option<&str>) -> Result<(), StoreError> {
456        let tx = self.conn.transaction()?;
457        tx.execute("DELETE FROM edges", [])?;
458        tx.execute("DELETE FROM nodes", [])?;
459        for node in &facts.nodes {
460            upsert_node(&tx, node)?;
461        }
462        for edge in &facts.edges {
463            insert_edge(&tx, edge)?;
464        }
465        write_sync_state(&tx, tree)?;
466        tx.commit()?;
467        Ok(())
468    }
469
470    /// Bring the store to exactly `facts` (as [`Store::rebuild`] does) but writing
471    /// only what **differs** instead of wiping and reinserting the whole graph —
472    /// the git-style "write only the delta". Unchanged node rows (which carry the
473    /// heavy JSON `meta`) and unchanged edge rows are left untouched; only removed
474    /// rows are deleted and new/changed rows written. The final state — nodes,
475    /// edges, and `sync_state` — is identical to `rebuild(facts, tree)`.
476    ///
477    /// Leaving unchanged edges in place means their row ids do not match a cold
478    /// rebuild's — which is safe *because* every edge query is content-ordered
479    /// (`(src, dst, kind, provenance)`, see [`Store::all_edges`]), never by row
480    /// id. So an incrementally reconciled store and a fresh rebuild return every
481    /// query identically; the delta is invisible above the storage layer.
482    ///
483    /// # Errors
484    /// Returns [`StoreError`] on a query failure; the transaction is rolled back.
485    pub fn reconcile(&mut self, facts: &FactSet, tree: Option<&str>) -> Result<(), StoreError> {
486        let current_nodes = self.all_nodes()?;
487        let current_edges = self.all_edges()?;
488        let cur_by_key: std::collections::HashMap<&str, &Node> =
489            current_nodes.iter().map(|n| (n.key.as_str(), n)).collect();
490        let new_keys: std::collections::HashSet<&str> =
491            facts.nodes.iter().map(|n| n.key.as_str()).collect();
492
493        // Edge identity is the full tuple, so a changed `confidence`/`src_ref`
494        // counts as remove-old + add-new — keeping the result identical to a
495        // wholesale rebuild, not the store's insert-time `DO NOTHING` semantics.
496        let new_edge_ids: std::collections::HashSet<EdgeId> =
497            facts.edges.iter().map(edge_identity).collect();
498        let cur_edge_ids: std::collections::HashSet<EdgeId> =
499            current_edges.iter().map(edge_identity).collect();
500
501        let tx = self.conn.transaction()?;
502        // 1. Delete removed edges first, so any node they reference can then be
503        //    dropped (edges are FK-constrained on node ids, with no cascade). A
504        //    removed node's edges are all removals, so they are gone before step 2.
505        for edge in &current_edges {
506            if !new_edge_ids.contains(&edge_identity(edge)) {
507                delete_edge(&tx, edge)?;
508            }
509        }
510        // 2. Drop nodes that no longer exist.
511        for old in &current_nodes {
512            if !new_keys.contains(old.key.as_str()) {
513                tx.execute("DELETE FROM nodes WHERE key = ?1", [&old.key])?;
514            }
515        }
516        // 3. Upsert only the nodes that are new or whose content changed (an upsert
517        //    keeps the row id, so unchanged edges stay valid).
518        for node in &facts.nodes {
519            if cur_by_key
520                .get(node.key.as_str())
521                .is_none_or(|cur| *cur != node)
522            {
523                upsert_node(&tx, node)?;
524            }
525        }
526        // 4. Insert only the added edges (their endpoints now all exist).
527        for edge in &facts.edges {
528            if !cur_edge_ids.contains(&edge_identity(edge)) {
529                insert_edge(&tx, edge)?;
530            }
531        }
532        write_sync_state(&tx, tree)?;
533        tx.commit()?;
534        Ok(())
535    }
536
537    /// Fetch a node by its natural key.
538    ///
539    /// # Errors
540    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
541    /// [`StoreError::Corrupt`] if a stored value cannot be decoded.
542    pub fn get_node(&self, key: &str) -> Result<Option<Node>, StoreError> {
543        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.key = ?1");
544        let mut stmt = self.conn.prepare(&sql)?;
545        let mut rows = stmt.query([key])?;
546        match rows.next()? {
547            Some(row) => Ok(Some(row_to_node(row)?)),
548            None => Ok(None),
549        }
550    }
551
552    /// Every node key in the store, ordered. Useful for whole-graph exports.
553    ///
554    /// # Errors
555    /// Returns [`StoreError::Sqlite`] on query failure.
556    pub fn all_keys(&self) -> Result<Vec<String>, StoreError> {
557        let mut stmt = self.conn.prepare("SELECT key FROM nodes ORDER BY key")?;
558        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
559        let mut out = Vec::new();
560        for row in rows {
561            out.push(row?);
562        }
563        Ok(out)
564    }
565
566    /// Dump the entire graph as a single [`FactSet`], with nodes and edges in a
567    /// deterministic order — suitable for a portable, content-stable artifact.
568    ///
569    /// # Errors
570    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
571    /// [`StoreError::Corrupt`] on decode failure.
572    pub fn export_factset(&self) -> Result<FactSet, StoreError> {
573        let node_sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
574        let mut node_stmt = self.conn.prepare(&node_sql)?;
575        let mut node_rows = node_stmt.query([])?;
576        let nodes = collect_nodes(&mut node_rows)?;
577
578        // Order edges by their resolved endpoint keys (not row id) so the dump is
579        // stable regardless of insertion order.
580        let edge_sql = format!("{EDGE_SELECT} ORDER BY ns.key, nd.key, e.kind, e.provenance");
581        let mut edge_stmt = self.conn.prepare(&edge_sql)?;
582        let mut edge_rows = edge_stmt.query([])?;
583        let edges = collect_edges(&mut edge_rows)?;
584
585        Ok(FactSet { nodes, edges })
586    }
587
588    /// All nodes of a given kind.
589    ///
590    /// # Errors
591    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
592    /// [`StoreError::Corrupt`] on decode failure.
593    pub fn nodes_by_kind(&self, kind: &NodeKind) -> Result<Vec<Node>, StoreError> {
594        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.kind = ?1 ORDER BY n.key");
595        let mut stmt = self.conn.prepare(&sql)?;
596        let mut rows = stmt.query([kind.as_str()])?;
597        collect_nodes(&mut rows)
598    }
599
600    /// Nodes of a given kind whose `name` equals `name_lower` **case-insensitively**,
601    /// ordered by key. Narrows a lookup at the SQL layer — using the `kind` index and
602    /// filtering `name` in-query — so only matching rows are decoded, never every
603    /// node of that kind. Used by the cross-repo follow bridge to fetch just the
604    /// candidate struct(s) for a config section rather than scanning all structs.
605    ///
606    /// # Errors
607    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
608    /// [`StoreError::Corrupt`] on decode failure.
609    pub fn nodes_by_kind_named(
610        &self,
611        kind: &NodeKind,
612        name_lower: &str,
613    ) -> Result<Vec<Node>, StoreError> {
614        let sql = format!(
615            "SELECT {NODE_COLS} FROM nodes n \
616             WHERE n.kind = ?1 AND lower(n.name) = ?2 ORDER BY n.key"
617        );
618        let mut stmt = self.conn.prepare(&sql)?;
619        let mut rows = stmt.query([kind.as_str(), name_lower])?;
620        collect_nodes(&mut rows)
621    }
622
623    /// Every `config_key` node's flattened setting (ADR-0009), read back out of
624    /// the graph as [`crate::ConfigKey`]s — the graph-native source the cross-repo
625    /// link matcher (`roteiro links --infer`) consumes, so it never re-parses
626    /// config files. Ordered by node key (deterministic). A node missing the
627    /// `key`/`path` a well-formed `config_key` carries is skipped defensively.
628    ///
629    /// # Errors
630    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
631    /// [`StoreError::Corrupt`] on decode failure.
632    pub fn config_keys(&self) -> Result<Vec<crate::ConfigKey>, StoreError> {
633        let nodes = self.nodes_by_kind(&NodeKind::Other(crate::config_keys::KIND.to_owned()))?;
634        let mut out = Vec::with_capacity(nodes.len());
635        for n in &nodes {
636            let key = n.meta.get("key").and_then(serde_json::Value::as_str);
637            // A `config_key` node carries a `value` in `meta` when it has a real
638            // setting (file-derived keys always do, even an empty string). A
639            // struct-derived key (`meta.source = "struct"`) omits it — its value is
640            // *unknown*, not empty — so record that absence explicitly rather than
641            // defaulting it to `""`, which would false-match in value agreement.
642            let value = n.meta.get("value").and_then(serde_json::Value::as_str);
643            if let (Some(key), Some(file)) = (key, n.path.as_deref()) {
644                out.push(crate::ConfigKey {
645                    file: file.to_owned(),
646                    key: key.to_owned(),
647                    value: value.unwrap_or_default().to_owned(),
648                    value_known: value.is_some(),
649                });
650            }
651        }
652        Ok(out)
653    }
654
655    /// Every node whose source `path` is `path`, ordered by key — the file node
656    /// plus the symbols and markers defined in it. Used to scope a change to the
657    /// graph (e.g. `roteiro review`).
658    ///
659    /// # Errors
660    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
661    /// [`StoreError::Corrupt`] on decode failure.
662    pub fn nodes_by_path(&self, path: &str) -> Result<Vec<Node>, StoreError> {
663        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.path = ?1 ORDER BY n.key");
664        let mut stmt = self.conn.prepare(&sql)?;
665        let mut rows = stmt.query([path])?;
666        collect_nodes(&mut rows)
667    }
668
669    /// Every node produced by a given layer, ordered by key. The incremental
670    /// [`crate::sync`] loads the `Derived` layer to reconstruct the extraction graph
671    /// without re-reading every blob.
672    ///
673    /// # Errors
674    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
675    /// [`StoreError::Corrupt`] on decode failure.
676    pub fn nodes_by_provenance(&self, provenance: Provenance) -> Result<Vec<Node>, StoreError> {
677        let sql = format!("SELECT {NODE_COLS} FROM nodes n WHERE n.provenance = ?1 ORDER BY n.key");
678        let mut stmt = self.conn.prepare(&sql)?;
679        let mut rows = stmt.query([provenance.as_str()])?;
680        collect_nodes(&mut rows)
681    }
682
683    /// Every node in the store, ordered by key. Unlike [`Store::export_factset`]
684    /// this decodes no edges, so it is cheap for node-only scans (e.g. search).
685    ///
686    /// # Errors
687    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
688    /// [`StoreError::Corrupt`] on decode failure.
689    pub fn all_nodes(&self) -> Result<Vec<Node>, StoreError> {
690        let sql = format!("SELECT {NODE_COLS} FROM nodes n ORDER BY n.key");
691        let mut stmt = self.conn.prepare(&sql)?;
692        let mut rows = stmt.query([])?;
693        collect_nodes(&mut rows)
694    }
695
696    /// Every edge in the store, with endpoints resolved to their node keys. Used
697    /// by [`Store::reconcile`] to diff the edge set.
698    ///
699    /// Ordered by the edge's **content** — `(src key, dst key, kind, provenance)`,
700    /// the table's unique tuple — not by row id. This makes the order a function
701    /// of the *graph*, not of insertion history, so an incrementally
702    /// [`reconcile`](Store::reconcile)d store and a cold [`rebuild`](Store::rebuild)
703    /// return edges identically. (The same reason node scans order by `key`.)
704    ///
705    /// # Errors
706    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
707    pub fn all_edges(&self) -> Result<Vec<Edge>, StoreError> {
708        let sql = format!("{EDGE_SELECT} ORDER BY ns.key, nd.key, e.kind, e.provenance");
709        let mut stmt = self.conn.prepare(&sql)?;
710        let mut rows = stmt.query([])?;
711        collect_edges(&mut rows)
712    }
713
714    /// Edges whose source is the node with the given key, in content order
715    /// (`(dst key, kind, provenance)` — `src` is fixed). Content-ordered rather
716    /// than by row id so the result is history-independent; see [`Store::all_edges`].
717    ///
718    /// # Errors
719    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
720    pub fn edges_from(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
721        let sql = format!("{EDGE_SELECT} WHERE ns.key = ?1 ORDER BY nd.key, e.kind, e.provenance");
722        let mut stmt = self.conn.prepare(&sql)?;
723        let mut rows = stmt.query([key])?;
724        collect_edges(&mut rows)
725    }
726
727    /// Edges whose destination is the node with the given key, in content order
728    /// (`(src key, kind, provenance)` — `dst` is fixed). See [`Store::all_edges`].
729    ///
730    /// # Errors
731    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
732    pub fn edges_to(&self, key: &str) -> Result<Vec<Edge>, StoreError> {
733        let sql = format!("{EDGE_SELECT} WHERE nd.key = ?1 ORDER BY ns.key, e.kind, e.provenance");
734        let mut stmt = self.conn.prepare(&sql)?;
735        let mut rows = stmt.query([key])?;
736        collect_edges(&mut rows)
737    }
738
739    /// All edges with the given provenance, in content order
740    /// (`(src key, dst key, kind)` — `provenance` is fixed). See [`Store::all_edges`].
741    ///
742    /// # Errors
743    /// Returns [`StoreError::Sqlite`] or [`StoreError::Corrupt`] on failure.
744    pub fn edges_by_provenance(&self, provenance: Provenance) -> Result<Vec<Edge>, StoreError> {
745        let sql = format!("{EDGE_SELECT} WHERE e.provenance = ?1 ORDER BY ns.key, nd.key, e.kind");
746        let mut stmt = self.conn.prepare(&sql)?;
747        let mut rows = stmt.query([provenance.as_str()])?;
748        collect_edges(&mut rows)
749    }
750
751    /// Delete all edges with the given provenance, returning how many were
752    /// removed. Used to re-derive a whole provenance class authoritatively (e.g.
753    /// `inferred` edges when re-running inference with different parameters).
754    ///
755    /// # Errors
756    /// Returns [`StoreError::Sqlite`] on write failure.
757    pub fn delete_edges_by_provenance(&self, provenance: Provenance) -> Result<u64, StoreError> {
758        let n = self.conn.execute(
759            "DELETE FROM edges WHERE provenance = ?1",
760            [provenance.as_str()],
761        )?;
762        Ok(u64::try_from(n).unwrap_or(0))
763    }
764
765    /// Delete all edges carrying the given `src_ref`, returning how many were
766    /// removed. Lets one producer of `inferred` edges (e.g. the embedding layer,
767    /// or a Graphify import) re-derive its own edges authoritatively without
768    /// touching edges another producer contributed.
769    ///
770    /// # Errors
771    /// Returns [`StoreError::Sqlite`] on write failure.
772    pub fn delete_edges_by_src_ref(&self, src_ref: &str) -> Result<u64, StoreError> {
773        let n = self
774            .conn
775            .execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
776        Ok(u64::try_from(n).unwrap_or(0))
777    }
778
779    /// Apply an import layer to the live graph **and** persist it durably under
780    /// `src_ref`, validating as it goes: this ref's prior edges are cleared
781    /// (an authoritative re-import), the layer's nodes are upserted, and each
782    /// edge is applied only if both endpoints resolve. Dangling edges — cross-
783    /// references to code that is not present — are dropped, and only the
784    /// validated (trimmed) layer is persisted, so stale data is never stored.
785    ///
786    /// This is the "validate on import" half; [`Store::reapply_imports`] is the
787    /// "validate on sync" half, re-checking layers against the rebuilt graph.
788    ///
789    /// # Removal propagates, because a re-run that only adds is not idempotent
790    ///
791    /// Re-importing cannot **duplicate**: nodes are `INSERT … ON CONFLICT(key) DO
792    /// UPDATE`, and this ref's edges are deleted before being re-applied. What is
793    /// not free is the other direction. A node the source has since *withdrawn*
794    /// used to survive as an orphan carrying the layer's provenance, because only
795    /// edges were pruned by `src_ref` — so a re-run **looked** idempotent while
796    /// quietly accumulating deleted facts, which is harder to notice than a
797    /// duplicate precisely because nothing appears twice.
798    ///
799    /// So a node this ref previously contributed and no longer does is removed,
800    /// under a rule deliberately narrower than "this ref owned it":
801    ///
802    /// 1. it must not appear in **any other persisted import layer** — two layers
803    ///    can name one node on purpose, and `import:links` and
804    ///    `import:links/authored` both contribute the same `extref:` placeholder;
805    /// 2. **nothing may still be attached to it** — a surviving edge means some
806    ///    other producer still relates it to the graph, and a node's removal must
807    ///    not silently take a relationship it did not own with it.
808    ///
809    /// Both are checked against the live store rather than assumed from the key,
810    /// so this stays correct for a layer whose namespace it knows nothing about.
811    /// The node's cached context row goes with it — a bundle for a node that no
812    /// longer exists can only be served as a stale answer. Episodic
813    /// [`crate::MemoryKind`] records anchored to it do **not**: ADR-0013 makes
814    /// memory the one tier with no generating function, so dropping it is data
815    /// loss, and its anchor already reports the node as gone.
816    ///
817    /// # Errors
818    /// Returns [`StoreError::Json`] if `facts` cannot be (de)serialized,
819    /// [`StoreError::InvalidEdge`] on a malformed edge, or [`StoreError::Sqlite`]
820    /// on write failure.
821    pub fn apply_import_layer(
822        &mut self,
823        src_ref: &str,
824        facts: &FactSet,
825    ) -> Result<ImportApplied, StoreError> {
826        // Read before the row is overwritten: what this ref contributed last time
827        // is the only record of what it may now be withdrawing.
828        let prior = self.import_layer_node_keys(src_ref)?;
829        let tx = self.conn.transaction()?;
830        // Authoritative re-import: drop this ref's prior edges from the live graph.
831        tx.execute("DELETE FROM edges WHERE src_ref = ?1", [src_ref])?;
832        for node in &facts.nodes {
833            upsert_node(&tx, node)?;
834        }
835        let (kept, applied) = apply_edges_pruning(&tx, &facts.edges)?;
836        let trimmed = FactSet {
837            nodes: facts.nodes.clone(),
838            edges: kept,
839        };
840        put_import_row(&tx, src_ref, &trimmed)?;
841        // After the edges are gone and the new nodes are in, so the "still
842        // attached" test sees the graph as it will be rather than as it was.
843        let nodes_removed = remove_withdrawn_nodes(&tx, src_ref, &prior, &facts.nodes)?;
844        tx.commit()?;
845        Ok(ImportApplied {
846            layers: 1,
847            nodes: facts.nodes.len(),
848            nodes_removed,
849            ..applied
850        })
851    }
852
853    /// The node keys the persisted layer for `src_ref` currently contributes, or
854    /// an empty set when there is no such layer.
855    ///
856    /// # Errors
857    /// Returns [`StoreError::Sqlite`] on query failure or [`StoreError::Json`] if
858    /// the stored layer cannot be decoded.
859    fn import_layer_node_keys(&self, src_ref: &str) -> Result<BTreeSet<String>, StoreError> {
860        let json: Option<String> = self
861            .conn
862            .query_row(
863                "SELECT facts FROM imports WHERE src_ref = ?1",
864                [src_ref],
865                |r| r.get(0),
866            )
867            .optional()?;
868        let Some(json) = json else {
869            return Ok(BTreeSet::new());
870        };
871        let facts: FactSet = serde_json::from_str(&json)?;
872        Ok(facts.nodes.into_iter().map(|n| n.key).collect())
873    }
874
875    /// Remove the persisted import layer for `src_ref`, returning whether one
876    /// existed. Does not remove edges already in the live graph (use
877    /// [`Store::delete_edges_by_src_ref`] for that).
878    ///
879    /// # Errors
880    /// Returns [`StoreError::Sqlite`] on write failure.
881    pub fn delete_import(&self, src_ref: &str) -> Result<bool, StoreError> {
882        let n = self
883            .conn
884            .execute("DELETE FROM imports WHERE src_ref = ?1", [src_ref])?;
885        Ok(n > 0)
886    }
887
888    /// The `src_ref`s of all persisted import layers, ordered.
889    ///
890    /// # Errors
891    /// Returns [`StoreError::Sqlite`] on query failure.
892    pub fn import_refs(&self) -> Result<Vec<String>, StoreError> {
893        let mut stmt = self
894            .conn
895            .prepare("SELECT src_ref FROM imports ORDER BY src_ref")?;
896        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
897        let mut out = Vec::new();
898        for row in rows {
899            out.push(row?);
900        }
901        Ok(out)
902    }
903
904    /// Re-apply every persisted import layer on top of the current graph and
905    /// **re-validate** it: all import nodes are upserted first (so cross-layer
906    /// and self references resolve), then each edge is applied; an edge whose
907    /// endpoint is now absent — a cross-reference to code a sync removed — is
908    /// pruned from the persisted layer, not merely skipped. So the durable store
909    /// keeps only still-correct data. Idempotent; safe to run after each rebuild.
910    ///
911    /// # Errors
912    /// Returns [`StoreError::Json`] if a stored layer cannot be (de)serialized,
913    /// or [`StoreError::Sqlite`] on write failure.
914    pub fn reapply_imports(&mut self) -> Result<ImportApplied, StoreError> {
915        let layers = self.load_import_layers()?;
916        let tx = self.conn.transaction()?;
917        // Pass 1: upsert every layer's nodes so intra-import edges resolve
918        // regardless of which layer defines the endpoint.
919        for (_, facts) in &layers {
920            for node in &facts.nodes {
921                upsert_node(&tx, node)?;
922            }
923        }
924        // Pass 2: apply edges, pruning (and rewriting) any that dangle.
925        let mut applied = ImportApplied {
926            layers: layers.len(),
927            ..ImportApplied::default()
928        };
929        for (src_ref, facts) in &layers {
930            applied.nodes += facts.nodes.len();
931            let (kept, counts) = apply_edges_pruning(&tx, &facts.edges)?;
932            applied.edges_applied += counts.edges_applied;
933            applied.edges_pruned += counts.edges_pruned;
934            if kept.len() != facts.edges.len() {
935                let trimmed = FactSet {
936                    nodes: facts.nodes.clone(),
937                    edges: kept,
938                };
939                put_import_row(&tx, src_ref, &trimmed)?;
940            }
941        }
942        tx.commit()?;
943        Ok(applied)
944    }
945
946    /// Load and decode every persisted import layer as `(src_ref, FactSet)`, in
947    /// `src_ref` order.
948    fn load_import_layers(&self) -> Result<Vec<(String, FactSet)>, StoreError> {
949        let mut stmt = self
950            .conn
951            .prepare("SELECT src_ref, facts FROM imports ORDER BY src_ref")?;
952        let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
953        let mut out = Vec::new();
954        for row in rows {
955            let (src_ref, json) = row?;
956            let mut facts: FactSet = serde_json::from_str(&json)?;
957            // An import-layer node is *never* derived (derivation is [`crate::sync`]'s job),
958            // so a `Derived` tag here is always wrong. It arises two ways, both
959            // repaired the same: a legacy layer persisted before nodes carried
960            // provenance (the field is absent → serde defaults `Derived`), or —
961            // anomalously — a layer that stored an explicit `"provenance":"derived"`
962            // (a producer/data bug). We deliberately repair *both* rather than only
963            // the absent case: leaving an explicit-`derived` import node in place
964            // would let a layer-scoped [`crate::sync`] treat it as derived and delete it —
965            // the exact corruption this guards against — so repair is the safe
966            // recovery, not silent masking. Idempotent, runs on every reapply
967            // (old stores self-heal), and a no-op for correctly-tagged fresh imports.
968            for node in &mut facts.nodes {
969                if node.provenance == Provenance::Derived {
970                    node.provenance = import_node_provenance(&node.key);
971                }
972            }
973            out.push((src_ref, facts));
974        }
975        Ok(out)
976    }
977
978    /// Neighbouring nodes reachable from `key` in the given direction. Returns
979    /// an empty vector if the node does not exist.
980    ///
981    /// # Errors
982    /// Returns [`StoreError::Sqlite`], [`StoreError::Json`], or
983    /// [`StoreError::Corrupt`] on failure.
984    pub fn neighbors(&self, key: &str, dir: Direction) -> Result<Vec<Node>, StoreError> {
985        let out = format!(
986            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.dst \
987             JOIN nodes s ON s.id = e.src WHERE s.key = ?1"
988        );
989        let inc = format!(
990            "SELECT {NODE_COLS} FROM nodes n JOIN edges e ON n.id = e.src \
991             JOIN nodes d ON d.id = e.dst WHERE d.key = ?1"
992        );
993        // Order by output column 1 (the node key) so results are deterministic
994        // across SQLite versions/plans. Positional ordering avoids both the
995        // ambiguity of a bare `key` (present in every joined table) and the fact
996        // that a table-qualified name cannot be used after the `Both` UNION.
997        let sql = match dir {
998            Direction::Outgoing => format!("{out} ORDER BY 1"),
999            Direction::Incoming => format!("{inc} ORDER BY 1"),
1000            Direction::Both => format!("{out} UNION {inc} ORDER BY 1"),
1001        };
1002        let mut stmt = self.conn.prepare(&sql)?;
1003        let mut rows = stmt.query([key])?;
1004        collect_nodes(&mut rows)
1005    }
1006
1007    /// Fetch the cached context bundle for `key` as `(fingerprint, json)`, if
1008    /// present. The caller compares the fingerprint to the node's current one to
1009    /// decide whether the entry is fresh (see [`crate::context`]).
1010    ///
1011    /// # Errors
1012    /// Returns [`StoreError::Sqlite`] on query failure.
1013    pub fn context_cache_get(&self, key: &str) -> Result<Option<(String, String)>, StoreError> {
1014        let row = self
1015            .conn
1016            .query_row(
1017                "SELECT fingerprint, json FROM node_context WHERE key = ?1",
1018                [key],
1019                |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
1020            )
1021            .optional()?;
1022        Ok(row)
1023    }
1024
1025    /// Fetch just the cached fingerprint for `key`, without reading the (larger)
1026    /// JSON payload — for a cheap freshness check.
1027    ///
1028    /// # Errors
1029    /// Returns [`StoreError::Sqlite`] on query failure.
1030    pub fn context_cache_fingerprint(&self, key: &str) -> Result<Option<String>, StoreError> {
1031        let fp = self
1032            .conn
1033            .query_row(
1034                "SELECT fingerprint FROM node_context WHERE key = ?1",
1035                [key],
1036                |r| r.get::<_, String>(0),
1037            )
1038            .optional()?;
1039        Ok(fp)
1040    }
1041
1042    /// Store (or replace) the cached context bundle for `key`.
1043    ///
1044    /// # Errors
1045    /// Returns [`StoreError::Sqlite`] on write failure.
1046    pub fn context_cache_put(
1047        &self,
1048        key: &str,
1049        fingerprint: &str,
1050        json: &str,
1051    ) -> Result<(), StoreError> {
1052        self.conn.execute(
1053            "INSERT INTO node_context (key, fingerprint, json) VALUES (?1, ?2, ?3)
1054             ON CONFLICT(key) DO UPDATE SET
1055                 fingerprint = excluded.fingerprint, json = excluded.json",
1056            [key, fingerprint, json],
1057        )?;
1058        Ok(())
1059    }
1060
1061    /// Delete the cached context entry for `key`, returning whether one existed.
1062    ///
1063    /// # Errors
1064    /// Returns [`StoreError::Sqlite`] on write failure.
1065    pub fn context_cache_delete(&self, key: &str) -> Result<bool, StoreError> {
1066        let n = self
1067            .conn
1068            .execute("DELETE FROM node_context WHERE key = ?1", [key])?;
1069        Ok(n > 0)
1070    }
1071
1072    /// Every key with a cached context entry, ordered. Used to prune entries for
1073    /// nodes that no longer exist.
1074    ///
1075    /// # Errors
1076    /// Returns [`StoreError::Sqlite`] on query failure.
1077    pub fn context_cache_keys(&self) -> Result<Vec<String>, StoreError> {
1078        let mut stmt = self
1079            .conn
1080            .prepare("SELECT key FROM node_context ORDER BY key")?;
1081        let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
1082        let mut out = Vec::new();
1083        for row in rows {
1084            out.push(row?);
1085        }
1086        Ok(out)
1087    }
1088
1089    // --- Analyzer findings (ADR-0012). A separate artifact store: these methods
1090    // touch `analysis_runs`/`findings` only, never `nodes`/`edges`, so
1091    // `export_factset` — and the published `GraphArtifact` — stays a pure
1092    // function of the tree no matter what an analyzer reports. ---
1093
1094    /// The recorded answer to the OKF trust question for `peer`, if there is one
1095    /// (#706 phase 2).
1096    ///
1097    /// # Errors
1098    /// Returns [`StoreError::Sqlite`] on query failure.
1099    pub fn okf_consent(&self, peer: &str) -> Result<Option<crate::OkfConsent>, StoreError> {
1100        let row = self
1101            .conn
1102            .query_row(
1103                "SELECT peer, decision, root, screen_classes, decided_at
1104                 FROM okf_consent WHERE peer = ?1",
1105                [peer],
1106                |r| {
1107                    Ok((
1108                        r.get::<_, String>(0)?,
1109                        r.get::<_, String>(1)?,
1110                        r.get::<_, String>(2)?,
1111                        r.get::<_, String>(3)?,
1112                        r.get::<_, String>(4)?,
1113                    ))
1114                },
1115            )
1116            .optional()?;
1117        let Some((peer, decision, root, screen_classes, decided_at)) = row else {
1118            return Ok(None);
1119        };
1120        // A token the `CHECK` constraint permits but this build does not know is
1121        // a store written by a newer Roteiro. Reading it as "no answer" is the
1122        // safe direction: it asks again rather than acting on a decision whose
1123        // meaning this build cannot state.
1124        let Some(decision) = crate::OkfDecision::from_token(&decision) else {
1125            return Ok(None);
1126        };
1127        Ok(Some(crate::OkfConsent {
1128            peer,
1129            decision,
1130            root,
1131            screen_classes,
1132            decided_at,
1133        }))
1134    }
1135
1136    /// Whether the recorded answer for `peer` still covers the bundle now at
1137    /// `root` screening as `classes`, and why not when it does not.
1138    ///
1139    /// The two invalidators, and the one deliberately absent, are argued in
1140    /// [`crate::ConsentState::Lapsed`].
1141    ///
1142    /// # Errors
1143    /// Returns [`StoreError::Sqlite`] on query failure.
1144    pub fn okf_consent_holds(
1145        &self,
1146        peer: &str,
1147        root: &str,
1148        classes: &str,
1149    ) -> Result<crate::ConsentState, StoreError> {
1150        let Some(record) = self.okf_consent(peer)? else {
1151            return Ok(crate::ConsentState::Unasked);
1152        };
1153        if record.root != root {
1154            return Ok(crate::ConsentState::Moved { was: record.root });
1155        }
1156        if crate::screen_regressed(&record.screen_classes, classes) {
1157            return Ok(crate::ConsentState::Lapsed {
1158                was: record.screen_classes,
1159                now: classes.to_owned(),
1160            });
1161        }
1162        Ok(crate::ConsentState::Holds(record.decision))
1163    }
1164
1165    /// Record (or replace) the answer for one peer.
1166    ///
1167    /// Replacing rather than appending is deliberate: this is a *current
1168    /// decision*, not a history. ADR-0019's ledger appends because it records
1169    /// acts that happened; a consent record answers "what is it now", and two
1170    /// rows for one peer would need a tie-break nobody would get right.
1171    ///
1172    /// # Errors
1173    /// Returns [`StoreError::Sqlite`] on write failure.
1174    pub fn put_okf_consent(&self, record: &crate::OkfConsent) -> Result<(), StoreError> {
1175        self.conn.execute(
1176            "INSERT INTO okf_consent (peer, decision, root, screen_classes, decided_at)
1177             VALUES (?1, ?2, ?3, ?4, ?5)
1178             ON CONFLICT(peer) DO UPDATE SET
1179                 decision = excluded.decision,
1180                 root = excluded.root,
1181                 screen_classes = excluded.screen_classes,
1182                 decided_at = excluded.decided_at",
1183            [
1184                record.peer.as_str(),
1185                record.decision.as_str(),
1186                record.root.as_str(),
1187                record.screen_classes.as_str(),
1188                record.decided_at.as_str(),
1189            ],
1190        )?;
1191        Ok(())
1192    }
1193
1194    /// Every recorded OKF answer, in peer order — for `roteiro import --from okf`
1195    /// reporting and for tests.
1196    ///
1197    /// # Errors
1198    /// Returns [`StoreError::Sqlite`] on query failure.
1199    pub fn okf_consents(&self) -> Result<Vec<crate::OkfConsent>, StoreError> {
1200        let mut stmt = self.conn.prepare(
1201            "SELECT peer, decision, root, screen_classes, decided_at
1202             FROM okf_consent ORDER BY peer",
1203        )?;
1204        let rows = stmt.query_map([], |r| {
1205            Ok((
1206                r.get::<_, String>(0)?,
1207                r.get::<_, String>(1)?,
1208                r.get::<_, String>(2)?,
1209                r.get::<_, String>(3)?,
1210                r.get::<_, String>(4)?,
1211            ))
1212        })?;
1213        let mut out = Vec::new();
1214        for row in rows {
1215            let (peer, decision, root, screen_classes, decided_at) = row?;
1216            if let Some(decision) = crate::OkfDecision::from_token(&decision) {
1217                out.push(crate::OkfConsent {
1218                    peer,
1219                    decision,
1220                    root,
1221                    screen_classes,
1222                    decided_at,
1223                });
1224            }
1225        }
1226        Ok(out)
1227    }
1228
1229    /// Replace the findings layer `run.layer` **wholesale**, atomically: the
1230    /// previous run for that layer and every finding row it owned are deleted,
1231    /// then this run and its findings are written. A finding that has since been
1232    /// fixed therefore *disappears* instead of lingering, and re-ingesting an
1233    /// unchanged report is idempotent — the store ends up with the same rows and
1234    /// no growth.
1235    ///
1236    /// The owned-record cleanup is explicit rather than inherited. The import
1237    /// path ([`Store::apply_import_layer`]) deletes a layer's edges but leaves its
1238    /// obsolete *nodes* behind; copying that shape here would silently orphan
1239    /// findings, so the previous run's rows are deleted by hand and counted in
1240    /// [`FindingsApplied::removed`]. The schema's `ON DELETE CASCADE` is kept as
1241    /// defence in depth, not as the mechanism.
1242    ///
1243    /// `findings` must carry distinct [`FindingKey`](crate::FindingKey)s: a
1244    /// duplicate identity is a producer bug and is rejected by the unique index
1245    /// *inside* the transaction, so nothing is committed. Callers that parse
1246    /// untrusted reports should reject duplicates earlier, with a better message.
1247    ///
1248    /// # Errors
1249    /// Returns [`StoreError::Json`] if the run's command policy or a finding's
1250    /// `meta` cannot be serialized, or [`StoreError::Sqlite`] on write failure. On
1251    /// any error the transaction is rolled back and the previous layer survives
1252    /// intact.
1253    pub fn replace_findings_layer(
1254        &mut self,
1255        run: &AnalysisRun,
1256        findings: &[Finding],
1257    ) -> Result<FindingsApplied, StoreError> {
1258        let tx = self.conn.transaction()?;
1259        let applied = findings::replace_layer(&tx, run, findings)?;
1260        tx.commit()?;
1261        Ok(applied)
1262    }
1263
1264    /// Delete a findings layer and every finding row it owns, returning how many
1265    /// findings went with it, or `None` if the layer was not live.
1266    ///
1267    /// # Errors
1268    /// Returns [`StoreError::Sqlite`] on write failure; nothing is committed on
1269    /// error.
1270    pub fn delete_findings_layer(&mut self, layer: &str) -> Result<Option<usize>, StoreError> {
1271        let tx = self.conn.transaction()?;
1272        let removed = findings::delete_layer(&tx, layer)?;
1273        tx.commit()?;
1274        Ok(removed)
1275    }
1276
1277    /// Every live findings layer with its findings, ordered by layer key and, in
1278    /// each layer, by finding key. Pass `analyzer` to narrow to one analyzer.
1279    ///
1280    /// # Errors
1281    /// Returns [`StoreError::Sqlite`] on query failure, [`StoreError::Json`] if a
1282    /// stored policy or `meta` cannot be decoded, or [`StoreError::Corrupt`] on an
1283    /// unrecognised stored token.
1284    pub fn findings_layers(
1285        &self,
1286        analyzer: Option<&str>,
1287    ) -> Result<Vec<FindingsLayer>, StoreError> {
1288        findings::layers(&self.conn, analyzer)
1289    }
1290
1291    /// Number of findings currently stored, across every layer.
1292    ///
1293    /// # Errors
1294    /// Returns [`StoreError::Sqlite`] on query failure.
1295    pub fn finding_count(&self) -> Result<u64, StoreError> {
1296        findings::count_findings(&self.conn)
1297    }
1298
1299    /// Number of live analysis runs — one per findings layer.
1300    ///
1301    /// # Errors
1302    /// Returns [`StoreError::Sqlite`] on query failure.
1303    pub fn analysis_run_count(&self) -> Result<u64, StoreError> {
1304        findings::count_runs(&self.conn)
1305    }
1306
1307    // --- Generated media content (ADR-0015). A separate artifact store, on the
1308    // same terms as findings above: these methods touch `media_content` only,
1309    // never `nodes`/`edges`, so `export_factset` stays a pure function of the
1310    // tree across a `media build`, and generated text can never reach the
1311    // `authored` relevance boost in `search`. ---
1312
1313    /// Write one generated-content record, returning `true` if a row was written.
1314    ///
1315    /// Keyed by `(blob_id, producer)`. A record for that exact pair already
1316    /// present is **left alone** and `false` is returned — which is what makes
1317    /// `media build` incremental and a second run free. A *different* producer is
1318    /// a new row beside it, never an overwrite: that is the point of keying on the
1319    /// producer identity, so a better model's description can be compared with the
1320    /// one it replaces and a distrusted producer can be dropped wholesale.
1321    /// [`MediaWrite::replace`] (only `media build --force`) is the one path that
1322    /// overwrites, and only for the identical producer.
1323    ///
1324    /// # Errors
1325    /// Returns [`StoreError::Sqlite`] on a write failure; the transaction is
1326    /// rolled back.
1327    pub fn record_media_content(&mut self, write: &MediaWrite<'_>) -> Result<bool, StoreError> {
1328        let tx = self.conn.transaction()?;
1329        let written = media::record(&tx, write)?;
1330        tx.commit()?;
1331        Ok(written)
1332    }
1333
1334    /// Whether a record already exists for exactly this `(blob, producer)`.
1335    ///
1336    /// # Errors
1337    /// Returns [`StoreError::Sqlite`] on query failure.
1338    pub fn has_media_record(&self, blob_id: &str, producer: &str) -> Result<bool, StoreError> {
1339        media::exists(&self.conn, blob_id, producer)
1340    }
1341
1342    /// Stored records matching `filter`, ordered by `(producer, blob id)`.
1343    ///
1344    /// # Errors
1345    /// Returns [`StoreError::Sqlite`] on query failure, or
1346    /// [`StoreError::Corrupt`] if a row carries an unknown modality token.
1347    pub fn media_records(&self, filter: &MediaFilter<'_>) -> Result<Vec<MediaRecord>, StoreError> {
1348        media::records(&self.conn, filter)
1349    }
1350
1351    /// Discard records — all of them, or only those written by `producer`.
1352    /// Returns how many rows went.
1353    ///
1354    /// Nothing in the graph is touched: dropping a model you no longer trust must
1355    /// not cost you a re-sync.
1356    ///
1357    /// # Errors
1358    /// Returns [`StoreError::Sqlite`] on a write failure.
1359    pub fn clear_media_content(&mut self, producer: Option<&str>) -> Result<usize, StoreError> {
1360        let tx = self.conn.transaction()?;
1361        let removed = media::delete(&tx, producer)?;
1362        tx.commit()?;
1363        Ok(removed)
1364    }
1365
1366    /// Number of generated-content records currently stored.
1367    ///
1368    /// # Errors
1369    /// Returns [`StoreError::Sqlite`] on query failure.
1370    pub fn media_content_count(&self) -> Result<u64, StoreError> {
1371        media::count(&self.conn)
1372    }
1373
1374    /// One summary per producer that owns records, ordered by producer id.
1375    ///
1376    /// # Errors
1377    /// Returns [`StoreError::Sqlite`] on query failure, or
1378    /// [`StoreError::Corrupt`] if a row carries an unknown modality token.
1379    pub fn media_producer_summaries(&self) -> Result<Vec<ProducerSummary>, StoreError> {
1380        media::producer_summaries(&self.conn)
1381    }
1382
1383    /// The blob ids that have at least one record carrying **generated text**
1384    /// for `kind`. A blob the pre-generation gate refused is not described, so it
1385    /// is not here — see [`Store::gated_media_blobs`].
1386    ///
1387    /// # Errors
1388    /// Returns [`StoreError::Sqlite`] on query failure.
1389    pub fn described_media_blobs(
1390        &self,
1391        kind: MediaKind,
1392    ) -> Result<std::collections::BTreeSet<String>, StoreError> {
1393        media::described_blobs(&self.conn, kind)
1394    }
1395
1396    /// The blob ids the [pre-generation gate](crate::media::gate) refused for
1397    /// `kind` — silent clips, blank images — each of which has a record naming
1398    /// the value that refused it.
1399    ///
1400    /// # Errors
1401    /// Returns [`StoreError::Sqlite`] on query failure.
1402    pub fn gated_media_blobs(
1403        &self,
1404        kind: MediaKind,
1405    ) -> Result<std::collections::BTreeSet<String>, StoreError> {
1406        media::gated_blobs(&self.conn, kind)
1407    }
1408
1409    /// Records whose blob is no longer anywhere in `present`. Exposed so a caller
1410    /// can see what a tree change has orphaned; nothing deletes them implicitly,
1411    /// because a record is expensive to reproduce and a blob can return.
1412    ///
1413    /// # Errors
1414    /// Returns [`StoreError::Sqlite`] on query failure.
1415    pub fn orphan_media_records(
1416        &self,
1417        present: &std::collections::BTreeSet<String>,
1418    ) -> Result<Vec<MediaRecord>, StoreError> {
1419        Ok(self
1420            .media_records(&MediaFilter::default())?
1421            .into_iter()
1422            .filter(|r| !present.contains(&r.blob_id))
1423            .collect())
1424    }
1425
1426    // --- Episodic agent memory (ADR-0013). A separate artifact store, on the
1427    // same terms as findings and media above: these methods write `agent_memory`
1428    // and nothing else — they *read* `nodes` to capture and check an anchor, and
1429    // never write one. So `export_factset` stays a pure function of the tree
1430    // across every memory write, and nothing an agent remembers can reach the
1431    // `authored` relevance boost in `search`.
1432    //
1433    // These rows are not touched by `rebuild`, following the `imports` precedent:
1434    // what has no generating function must not be destroyed by a re-derivation.
1435    // ---
1436
1437    /// Record one memory, returning its id — the monotonic generation it was
1438    /// written at.
1439    ///
1440    /// The anchor's blob and path, and the `sync_state` tree witness, are
1441    /// captured **here, from the graph**, so a caller cannot record evidence the
1442    /// node never carried. An anchor key naming no node is accepted and reads
1443    /// back as [`crate::AnchorState::Vanished`].
1444    ///
1445    /// [`MemoryWrite::supersedes`] records the supersession explicitly, in the
1446    /// same transaction as the successor: either both land or neither does.
1447    ///
1448    /// # Errors
1449    /// Returns [`MemoryError::InvalidScope`] / [`MemoryError::InvalidBody`] /
1450    /// [`MemoryError::InvalidConfidence`] for a record that could not be recalled,
1451    /// [`MemoryError::NotFound`] or [`MemoryError::AlreadySuperseded`] for a bad
1452    /// supersession target, or [`MemoryError::Store`] on a write failure — in
1453    /// every case with nothing committed.
1454    pub fn record_memory(&mut self, write: &MemoryWrite<'_>) -> Result<i64, MemoryError> {
1455        let tx = self.conn.transaction().map_err(StoreError::from)?;
1456        let id = memory::record(&tx, write)?;
1457        tx.commit().map_err(StoreError::from)?;
1458        Ok(id)
1459    }
1460
1461    /// Memory records matching `filter`, newest generation first.
1462    ///
1463    /// Live records only unless [`MemoryFilter::include_superseded`] is set: a
1464    /// superseded record drops out immediately and regardless of age, because the
1465    /// test is a recorded pointer and not a clock.
1466    ///
1467    /// Each record's [`crate::AnchorState`] is computed against the **current**
1468    /// graph on every call and is never stored.
1469    ///
1470    /// # Errors
1471    /// Returns [`StoreError::Sqlite`] on query failure, or [`StoreError::Corrupt`]
1472    /// if a row carries an unknown kind token.
1473    pub fn memory_records(
1474        &self,
1475        filter: &MemoryFilter<'_>,
1476    ) -> Result<Vec<MemoryRecord>, StoreError> {
1477        memory::records(&self.conn, filter)
1478    }
1479
1480    /// One memory record by id, or `None` if there is no such record.
1481    ///
1482    /// # Errors
1483    /// Returns [`StoreError::Sqlite`] on query failure, or [`StoreError::Corrupt`]
1484    /// if the row carries an unknown kind token.
1485    pub fn memory_record(&self, id: i64) -> Result<Option<MemoryRecord>, StoreError> {
1486        memory::get(&self.conn, id)
1487    }
1488
1489    /// A [`MemoryListing`]: the records matching `filter`, plus the whole store's
1490    /// live and superseded counts, so an empty result is legible as *nothing
1491    /// matched* rather than *nothing is stored*.
1492    ///
1493    /// # Errors
1494    /// Returns [`StoreError::Sqlite`] on query failure, or [`StoreError::Corrupt`]
1495    /// if a row carries an unknown kind token.
1496    pub fn memory_listing(&self, filter: &MemoryFilter<'_>) -> Result<MemoryListing, StoreError> {
1497        let (live, superseded) = memory::counts(&self.conn)?;
1498        Ok(MemoryListing {
1499            schema: memory::MEMORY_SCHEMA,
1500            records: self.memory_records(filter)?,
1501            live,
1502            superseded,
1503        })
1504    }
1505
1506    /// **The only way a memory record is ever removed.** Deletes it, and returns
1507    /// `None` if there was no such record.
1508    ///
1509    /// Episodic memory is unbounded and never auto-evicted — no sweep, no TTL, no
1510    /// capacity bound reaches this table — so an explicit call is the whole
1511    /// reclamation story. It is also the privacy story: memory has no redaction
1512    /// chokepoint, so a record that captured a token or a customer name is
1513    /// removed by asking.
1514    ///
1515    /// Anything the deleted record had superseded becomes **live again** and is
1516    /// named in [`MemoryForgotten::restored`]: leaving it superseded would hide it
1517    /// on the authority of a record that no longer exists.
1518    ///
1519    /// # Errors
1520    /// Returns [`StoreError::Sqlite`] on a write failure; the transaction is
1521    /// rolled back.
1522    pub fn forget_memory(&mut self, id: i64) -> Result<Option<MemoryForgotten>, StoreError> {
1523        let tx = self.conn.transaction()?;
1524        let forgotten = memory::forget(&tx, id)?;
1525        tx.commit()?;
1526        Ok(forgotten)
1527    }
1528
1529    /// How many memory records are stored, as `(live, superseded)`.
1530    ///
1531    /// # Errors
1532    /// Returns [`StoreError::Sqlite`] on query failure.
1533    pub fn memory_counts(&self) -> Result<(u64, u64), StoreError> {
1534        memory::counts(&self.conn)
1535    }
1536
1537    /// **Ranked recall**: the live records that match `opts`, scored
1538    /// `base_confidence × anchor_penalty × decay(age)` and ordered best first.
1539    ///
1540    /// Every term is computed **here, at retrieval time, and written to no
1541    /// column** (ADR-0013). A stored score that decayed would rewrite the store on
1542    /// every read and would be wrong in between, making recall depend on when you
1543    /// last looked. Three consequences follow, and each is a promise:
1544    ///
1545    /// - **This call mutates nothing.** Recall over an unchanged store and an
1546    ///   unchanged tree is idempotent, which is what makes
1547    ///   [`crate::Decay::None`] byte-identical across runs.
1548    /// - **A superseded record is never returned**, immediately and regardless of
1549    ///   age: the test is a recorded pointer, not a clock.
1550    /// - **A record whose anchor no longer resolves is still returned**, demoted
1551    ///   and labelled. Drift ranks it down; nothing deletes it.
1552    ///
1553    /// # Errors
1554    /// Returns [`StoreError::Sqlite`] on query failure, or [`StoreError::Corrupt`]
1555    /// if a row carries an unknown kind token.
1556    pub fn recall_memory(&self, opts: &RecallOptions<'_>) -> Result<Recall, StoreError> {
1557        let (live, superseded) = memory::counts(&self.conn)?;
1558        Ok(Recall {
1559            schema: memory::RECALL_SCHEMA,
1560            generation: memory::generation(&self.conn)?,
1561            decay: opts.decay,
1562            reproducible: opts.decay.is_reproducible(),
1563            results: memory::recall(&self.conn, opts)?,
1564            live,
1565            superseded,
1566        })
1567    }
1568
1569    // --- The bounded cache tier (ADR-0013, Tier 2). The *opposite* rules to the
1570    // episodic tier above, because it holds the opposite kind of knowledge:
1571    // everything here is re-derivable, so eviction costs cycles and never
1572    // information. Nothing in this section can reach `agent_memory` — it has no
1573    // `bytes`, no `last_used` and no `hits` for a capacity policy to grip. ---
1574
1575    /// Write (or replace) one cache entry.
1576    ///
1577    /// The payload size is computed here and the anchor's blob is captured from
1578    /// the graph here, so the sweep can order and total the tier without reading
1579    /// it, and a caller cannot record evidence a node never carried.
1580    ///
1581    /// # Errors
1582    /// Returns [`StoreError::Sqlite`] on write failure.
1583    pub fn agent_cache_put(&self, write: &CacheWrite<'_>) -> Result<(), StoreError> {
1584        memory::cache_put(&self.conn, write)
1585    }
1586
1587    /// Read one cache entry back, **recording the access** — `hits` increments and
1588    /// `last_used` advances.
1589    ///
1590    /// This is the one read in the memory store that writes, and what it writes is
1591    /// the cache's own bookkeeping: those two columns exist to be moved by exactly
1592    /// this, and a hit counter nothing increments is a column that lies. It
1593    /// touches nothing outside `agent_cache`, so [`Store::recall_memory`] — the
1594    /// read whose reproducibility is promised — stays free of it.
1595    ///
1596    /// # Errors
1597    /// Returns [`StoreError::Sqlite`] on query failure.
1598    pub fn agent_cache_get(&self, key: &str) -> Result<Option<CacheEntry>, StoreError> {
1599        memory::cache_get(&self.conn, key)
1600    }
1601
1602    /// Every cache entry, ordered by key, **without** recording an access:
1603    /// inspecting a cache is not using it.
1604    ///
1605    /// # Errors
1606    /// Returns [`StoreError::Sqlite`] on query failure.
1607    pub fn agent_cache_entries(&self) -> Result<Vec<CacheEntry>, StoreError> {
1608        memory::cache_entries(&self.conn)
1609    }
1610
1611    /// Delete one cache entry, returning whether there was one.
1612    ///
1613    /// # Errors
1614    /// Returns [`StoreError::Sqlite`] on write failure.
1615    pub fn agent_cache_forget(&self, key: &str) -> Result<bool, StoreError> {
1616        memory::cache_forget(&self.conn, key)
1617    }
1618
1619    /// What the cache tier holds, against `budget_bytes`.
1620    ///
1621    /// # Errors
1622    /// Returns [`StoreError::Sqlite`] on query failure.
1623    pub fn agent_cache_stats(&self, budget_bytes: u64) -> Result<CacheStats, StoreError> {
1624        memory::cache_stats(&self.conn, budget_bytes)
1625    }
1626
1627    /// **Sweep the cache tier down to `budget_bytes`**, evicting oldest-first on
1628    /// `(anchor_valid ASC, last_used ASC)`, and advance the generation.
1629    ///
1630    /// Called at the maintenance seam (beside `refresh_contexts`) and **never on
1631    /// the read path**, so an ordinary query never mutates the store. Three things
1632    /// are never evicted: anything episodic — structurally, there is no column to
1633    /// grip it by; an entry written in the current generation whose anchor still
1634    /// applies, which is the session's own work; and the most-recently-used entry,
1635    /// always, even if it alone exceeds the budget.
1636    ///
1637    /// That last pair means a sweep can legitimately finish still over budget.
1638    /// [`CacheSweep::over_budget`] reports it rather than leaving a bound that
1639    /// silently failed to bind.
1640    ///
1641    /// # Errors
1642    /// Returns [`StoreError::Sqlite`] on failure; the transaction is rolled back.
1643    pub fn sweep_agent_cache(&mut self, budget_bytes: u64) -> Result<CacheSweep, StoreError> {
1644        let tx = self.conn.transaction()?;
1645        let swept = memory::cache_sweep(&tx, budget_bytes)?;
1646        tx.commit()?;
1647        Ok(swept)
1648    }
1649
1650    /// Findings whose owning run no longer exists. Always `0` in a healthy store;
1651    /// exposed so layer replacement can be asserted to clean up its own records
1652    /// rather than orphaning them.
1653    ///
1654    /// # Errors
1655    /// Returns [`StoreError::Sqlite`] on query failure.
1656    pub fn orphan_finding_count(&self) -> Result<u64, StoreError> {
1657        findings::count_orphan_findings(&self.conn)
1658    }
1659}
1660
1661// --- Free helpers operating on a `Connection` (a `Transaction` derefs to one) ---
1662
1663fn node_row_id(conn: &Connection, key: &str) -> rusqlite::Result<Option<i64>> {
1664    conn.query_row("SELECT id FROM nodes WHERE key = ?1", [key], |r| r.get(0))
1665        .optional()
1666}
1667
1668/// Record (or clear) the last-synced `HEAD` tree id. Shared by `rebuild` and
1669/// `reconcile` so both leave identical `sync_state`.
1670fn write_sync_state(conn: &Connection, tree: Option<&str>) -> Result<(), StoreError> {
1671    match tree {
1672        // Clear `env` on every tree write: it is only valid for the tree a
1673        // committed [`crate::sync`] set it against, and that sync re-records it (via
1674        // `set_sync_env`) immediately after. So a worktree/index sync, or any
1675        // path that does not re-set it, leaves `env` NULL — reading as "unknown"
1676        // and forcing the safe full re-extraction next time.
1677        Some(tree) => conn.execute(
1678            "INSERT INTO sync_state (id, tree) VALUES (0, ?1)
1679             ON CONFLICT(id) DO UPDATE SET tree = excluded.tree, env = NULL",
1680            [tree],
1681        )?,
1682        None => conn.execute("DELETE FROM sync_state WHERE id = 0", [])?,
1683    };
1684    Ok(())
1685}
1686
1687fn upsert_node(conn: &Connection, node: &Node) -> Result<(), StoreError> {
1688    let meta = serde_json::to_string(&node.meta)?;
1689    let (span_start, span_end) = match node.span {
1690        Some(s) => (Some(i64::from(s.start)), Some(i64::from(s.end))),
1691        None => (None, None),
1692    };
1693    conn.execute(
1694        "INSERT INTO nodes (key, kind, name, path, lang, blob_hash, span_start, span_end, provenance, meta)
1695         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1696         ON CONFLICT(key) DO UPDATE SET
1697             kind = excluded.kind, name = excluded.name, path = excluded.path,
1698             lang = excluded.lang, blob_hash = excluded.blob_hash,
1699             span_start = excluded.span_start, span_end = excluded.span_end,
1700             provenance = excluded.provenance, meta = excluded.meta",
1701        params![
1702            node.key,
1703            node.kind.as_str(),
1704            node.name,
1705            node.path,
1706            node.lang,
1707            node.blob_hash,
1708            span_start,
1709            span_end,
1710            node.provenance.as_str(),
1711            meta,
1712        ],
1713    )?;
1714    Ok(())
1715}
1716
1717fn insert_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
1718    validate_edge(edge)?;
1719    let src_id =
1720        node_row_id(conn, &edge.src)?.ok_or_else(|| StoreError::UnknownNode(edge.src.clone()))?;
1721    let dst_id =
1722        node_row_id(conn, &edge.dst)?.ok_or_else(|| StoreError::UnknownNode(edge.dst.clone()))?;
1723    insert_edge_row(conn, edge, src_id, dst_id)
1724}
1725
1726/// Apply `edge` only if both endpoints already resolve to nodes, returning
1727/// whether it was **applied** (both endpoints resolved; a duplicate of an
1728/// existing edge is a harmless no-op via `ON CONFLICT DO NOTHING` but still
1729/// reports `true`). A missing endpoint returns `false` rather than erroring —
1730/// the caller prunes such dangling cross-references from the import layer.
1731fn insert_edge_if_present(conn: &Connection, edge: &Edge) -> Result<bool, StoreError> {
1732    validate_edge(edge)?;
1733    let (Some(src_id), Some(dst_id)) =
1734        (node_row_id(conn, &edge.src)?, node_row_id(conn, &edge.dst)?)
1735    else {
1736        return Ok(false);
1737    };
1738    insert_edge_row(conn, edge, src_id, dst_id)?;
1739    Ok(true)
1740}
1741
1742/// Apply `edges`, keeping those whose endpoints resolve and pruning the rest.
1743/// Returns the kept edges plus the applied/pruned counts (in an [`ImportApplied`]
1744/// whose `layers`/`nodes` are left zero for the caller to fill).
1745fn apply_edges_pruning(
1746    conn: &Connection,
1747    edges: &[Edge],
1748) -> Result<(Vec<Edge>, ImportApplied), StoreError> {
1749    let mut kept = Vec::with_capacity(edges.len());
1750    let mut counts = ImportApplied::default();
1751    for edge in edges {
1752        if insert_edge_if_present(conn, edge)? {
1753            kept.push(edge.clone());
1754            counts.edges_applied += 1;
1755        } else {
1756            counts.edges_pruned += 1;
1757        }
1758    }
1759    Ok((kept, counts))
1760}
1761
1762/// Delete the nodes `src_ref` has withdrawn since its last apply, returning how
1763/// many went.
1764///
1765/// `prior` is the key set the persisted layer held before this apply; `current`
1766/// is what it holds now. The difference is what the source stopped asserting —
1767/// and the two guards below are what keep "stopped asserting" from becoming
1768/// "deleted somebody else's node".
1769///
1770/// Both guards read the **live store**, not the key's namespace. A namespace rule
1771/// would have to be re-derived for every layer that ever exists, and would be
1772/// wrong the first time two producers shared a prefix; a query is right by
1773/// construction for a layer this function has never heard of.
1774fn remove_withdrawn_nodes(
1775    conn: &Connection,
1776    src_ref: &str,
1777    prior: &BTreeSet<String>,
1778    current: &[Node],
1779) -> Result<usize, StoreError> {
1780    let held: BTreeSet<&str> = current.iter().map(|n| n.key.as_str()).collect();
1781    let withdrawn: Vec<&str> = prior
1782        .iter()
1783        .map(String::as_str)
1784        .filter(|k| !held.contains(k))
1785        .collect();
1786    if withdrawn.is_empty() {
1787        return Ok(0);
1788    }
1789
1790    // Guard 1: every other persisted layer's node keys. Two layers naming one
1791    // node is a supported arrangement, not an anomaly — `import:links` and
1792    // `import:links/authored` share every `extref:` placeholder between them.
1793    let mut claimed: BTreeSet<String> = BTreeSet::new();
1794    {
1795        let mut stmt = conn.prepare("SELECT facts FROM imports WHERE src_ref <> ?1")?;
1796        let rows = stmt.query_map([src_ref], |r| r.get::<_, String>(0))?;
1797        for row in rows {
1798            let facts: FactSet = serde_json::from_str(&row?)?;
1799            claimed.extend(facts.nodes.into_iter().map(|n| n.key));
1800        }
1801    }
1802
1803    let mut removed = 0usize;
1804    for key in withdrawn {
1805        if claimed.contains(key) {
1806            continue;
1807        }
1808        // Guard 2: anything still attached. A surviving edge belongs to some
1809        // other producer, and removing the node would take that relationship
1810        // with it — a deletion nobody asked for, disguised as a cleanup. It
1811        // would also violate the foreign key, so this is correctness twice over.
1812        //
1813        // `EXISTS`, not `COUNT(*) > 0`. The question is "is there one", and a
1814        // count says "tally them all, then ask whether the tally was zero" —
1815        // which is both a different sentence and more work: with the `OR dst`
1816        // arm, a well-connected node in this repository would scan on the order
1817        // of 1,600 edge rows where the probe stops at the first hit.
1818        let attached: bool = conn.query_row(
1819            "SELECT EXISTS (SELECT 1 FROM edges \
1820             WHERE src = (SELECT id FROM nodes WHERE key = ?1) \
1821                OR dst = (SELECT id FROM nodes WHERE key = ?1))",
1822            [key],
1823            |r| r.get(0),
1824        )?;
1825        if attached {
1826            continue;
1827        }
1828        let gone = conn.execute("DELETE FROM nodes WHERE key = ?1", [key])?;
1829        if gone > 0 {
1830            // A cached context bundle for a node that no longer exists can only
1831            // ever be served as a stale answer, so it goes with it.
1832            conn.execute("DELETE FROM node_context WHERE key = ?1", [key])?;
1833            removed += 1;
1834        }
1835    }
1836    Ok(removed)
1837}
1838
1839/// Upsert a persisted import layer row. Free helper so it can run inside the same
1840/// transaction as an apply/prune pass.
1841fn put_import_row(conn: &Connection, src_ref: &str, facts: &FactSet) -> Result<(), StoreError> {
1842    let json = serde_json::to_string(facts)?;
1843    conn.execute(
1844        "INSERT INTO imports (src_ref, facts) VALUES (?1, ?2)
1845         ON CONFLICT(src_ref) DO UPDATE SET facts = excluded.facts, imported_at = datetime('now')",
1846        params![src_ref, json],
1847    )?;
1848    Ok(())
1849}
1850
1851/// The provenance/confidence invariant guard shared by the strict and tolerant
1852/// edge inserts.
1853fn validate_edge(edge: &Edge) -> Result<(), StoreError> {
1854    if edge.is_valid() {
1855        Ok(())
1856    } else {
1857        Err(StoreError::InvalidEdge(format!(
1858            "confidence must be present iff provenance is inferred (src={}, dst={})",
1859            edge.src, edge.dst
1860        )))
1861    }
1862}
1863
1864/// Insert an edge row given already-resolved endpoint ids. Edges are a set: a
1865/// duplicate `(src, dst, kind, provenance)` is a no-op via `ON CONFLICT … DO
1866/// NOTHING`, so re-applying a fact set never accumulates duplicates. Other
1867/// constraint violations (guarded in Rust above) still surface.
1868fn insert_edge_row(
1869    conn: &Connection,
1870    edge: &Edge,
1871    src_id: i64,
1872    dst_id: i64,
1873) -> Result<(), StoreError> {
1874    conn.execute(
1875        "INSERT INTO edges (src, dst, kind, provenance, confidence, src_ref)
1876         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
1877         ON CONFLICT(src, dst, kind, provenance) DO NOTHING",
1878        params![
1879            src_id,
1880            dst_id,
1881            edge.kind.as_str(),
1882            edge.provenance.as_str(),
1883            edge.confidence,
1884            edge.src_ref,
1885        ],
1886    )?;
1887    Ok(())
1888}
1889
1890/// A hashable identity for an edge over **all** its fields — used by
1891/// [`Store::reconcile`] to diff the edge set. A tuple (not a delimiter-joined
1892/// string) so no field value can be confused with a separator: node keys embed
1893/// git paths, which may legally contain any byte (including control characters),
1894/// so a joined string could collapse distinct edges to one identity and drop an
1895/// edge. Confidence is compared by its exact bit pattern (`f64::to_bits`, wrapped
1896/// in `Option` so `None` and `Some(_)` stay distinct), the only non-`Eq` field.
1897fn edge_identity(edge: &Edge) -> EdgeId {
1898    (
1899        edge.src.clone(),
1900        edge.dst.clone(),
1901        edge.kind.as_str().to_owned(),
1902        edge.provenance.as_str().to_owned(),
1903        edge.confidence.map(f64::to_bits),
1904        edge.src_ref.clone(),
1905    )
1906}
1907
1908/// The tuple form of an edge's full-field identity (see [`edge_identity`]):
1909/// `(src, dst, kind, provenance, confidence-bits, src_ref)`.
1910type EdgeId = (String, String, String, String, Option<u64>, Option<String>);
1911
1912/// Delete the edge row identified by `(src, dst, kind, provenance)` — the table's
1913/// unique key — resolving the endpoint node keys to ids. A no-op if absent.
1914fn delete_edge(conn: &Connection, edge: &Edge) -> Result<(), StoreError> {
1915    conn.execute(
1916        "DELETE FROM edges
1917         WHERE src = (SELECT id FROM nodes WHERE key = ?1)
1918           AND dst = (SELECT id FROM nodes WHERE key = ?2)
1919           AND kind = ?3 AND provenance = ?4",
1920        params![
1921            edge.src,
1922            edge.dst,
1923            edge.kind.as_str(),
1924            edge.provenance.as_str()
1925        ],
1926    )?;
1927    Ok(())
1928}
1929
1930fn collect_nodes(rows: &mut rusqlite::Rows) -> Result<Vec<Node>, StoreError> {
1931    let mut out = Vec::new();
1932    while let Some(row) = rows.next()? {
1933        out.push(row_to_node(row)?);
1934    }
1935    Ok(out)
1936}
1937
1938fn collect_edges(rows: &mut rusqlite::Rows) -> Result<Vec<Edge>, StoreError> {
1939    let mut out = Vec::new();
1940    while let Some(row) = rows.next()? {
1941        out.push(row_to_edge(row)?);
1942    }
1943    Ok(out)
1944}
1945
1946fn row_to_node(row: &rusqlite::Row) -> Result<Node, StoreError> {
1947    let kind: String = row.get("kind")?;
1948    let span_start: Option<i64> = row.get("span_start")?;
1949    let span_end: Option<i64> = row.get("span_end")?;
1950    let span = match (span_start, span_end) {
1951        (Some(s), Some(e)) => Some(Span::new(to_u32(s)?, to_u32(e)?)),
1952        _ => None,
1953    };
1954    let meta: String = row.get("meta")?;
1955    let provenance: String = row.get("provenance")?;
1956    let provenance = Provenance::from_token(&provenance)
1957        .ok_or_else(|| StoreError::Corrupt(unknown_provenance("node", &provenance)))?;
1958    Ok(Node {
1959        key: row.get("key")?,
1960        kind: NodeKind::from_token(&kind),
1961        name: row.get("name")?,
1962        path: row.get("path")?,
1963        lang: row.get("lang")?,
1964        blob_hash: row.get("blob_hash")?,
1965        span,
1966        provenance,
1967        meta: serde_json::from_str(&meta)?,
1968    })
1969}
1970
1971fn row_to_edge(row: &rusqlite::Row) -> Result<Edge, StoreError> {
1972    let kind: String = row.get("kind")?;
1973    let provenance: String = row.get("provenance")?;
1974    let provenance = Provenance::from_token(&provenance)
1975        .ok_or_else(|| StoreError::Corrupt(unknown_provenance("edge", &provenance)))?;
1976    Ok(Edge {
1977        src: row.get("src")?,
1978        dst: row.get("dst")?,
1979        kind: EdgeKind::from_token(&kind),
1980        provenance,
1981        confidence: row.get("confidence")?,
1982        src_ref: row.get("src_ref")?,
1983    })
1984}
1985
1986fn to_u32(v: i64) -> Result<u32, StoreError> {
1987    u32::try_from(v).map_err(|_| StoreError::Corrupt(format!("span offset out of range: {v}")))
1988}
1989
1990/// The message for a stored provenance token this build cannot parse.
1991///
1992/// It names **both** causes, because until migration 14 there was only one and
1993/// the old wording said so: "e.g. a corrupt database row". That migration widened
1994/// the vocabulary, so an unrecognised token now most often means the store was
1995/// written by a newer Roteiro — [`Store::schema_ahead`] says so first and more
1996/// precisely, but a reader who reaches this message deserves not to be sent
1997/// looking for corruption that is not there.
1998fn unknown_provenance(what: &str, token: &str) -> String {
1999    format!(
2000        "unknown {what} provenance `{token}`: expected one of {}. Either this row is corrupt, \
2001         or this store was written by a newer Roteiro that knows a provenance this build does \
2002         not — check `roteiro --version` against the build that last wrote the graph.",
2003        Provenance::tokens().join(", ")
2004    )
2005}
2006
2007/// The true provenance of an import-layer node, from its key namespace: Graphify
2008/// nodes (`graphify:`) are [`Provenance::Inferred`]; an OKF concept (`okf:`) is
2009/// [`Provenance::ExternalInferred`]; every other import node (lat, …) is
2010/// [`Provenance::Authored`]. Import-layer nodes are never derived, so this is
2011/// used to repair a legacy `Derived` tag on load (see `load_import_layers`).
2012fn import_node_provenance(key: &str) -> Provenance {
2013    if key.starts_with("graphify:") {
2014        Provenance::Inferred
2015    } else if key.starts_with("okf:") {
2016        // An OKF concept is a peer's, and this is a *repair* path with no
2017        // frontmatter left to consult — so it takes the lowest claim the peer
2018        // could have made. Falling through to `Authored` below would assert that
2019        // this graph human-authored another repository's concept, which is the
2020        // exact laundering the `external-*` tier exists to refuse, and it would
2021        // arrive through an error-recovery path where nobody would look for it.
2022        //
2023        // A **filled placeholder** deliberately does not match: its key is
2024        // `extref:`, it is a node this repo created, and its own provenance is
2025        // whichever layer wrote it last (see `rto_graph::links`).
2026        Provenance::ExternalInferred
2027    } else {
2028        Provenance::Authored
2029    }
2030}
2031
2032#[cfg(test)]
2033mod tests {
2034    use super::Store;
2035    use crate::model::{Direction, Edge, EdgeKind, FactSet, Node, NodeKind, Span};
2036    use crate::provenance::Provenance;
2037
2038    /// A fresh directory for a file-backed store, cleared on the way out of this
2039    /// function rather than trusted to be absent.
2040    ///
2041    /// Keyed by process id **and** a monotonic counter, matching
2042    /// `rto-render`'s `okf_inspect` tests and the CLI tests' `bundle` helper —
2043    /// whose own note explains why, and which I should have followed first time:
2044    /// *"uniqueness must not depend on everyone remembering to pick a distinct
2045    /// name."*
2046    ///
2047    /// The pre-clean is the half that matters most here, and is what review
2048    /// caught. A store is not one file: a failed run leaves `graph.db` beside its
2049    /// `-wal` and `-shm`, and reusing that state would have the concurrency test
2050    /// counting rows a previous run inserted — failing, eventually, for a reason
2051    /// that has nothing to do with locking. A test that can fail for the wrong
2052    /// reason is worse than no test, because the next person debugs the wrong
2053    /// thing.
2054    fn scratch(tag: &str) -> std::path::PathBuf {
2055        static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
2056        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2057        let root =
2058            std::env::temp_dir().join(format!("rto-store-{}-{seq}-{tag}", std::process::id()));
2059        let _ = std::fs::remove_dir_all(&root);
2060        root
2061    }
2062
2063    fn sample_node(key: &str) -> Node {
2064        Node {
2065            key: key.to_owned(),
2066            kind: NodeKind::Fn,
2067            name: "sample".to_owned(),
2068            path: Some("src/lib.rs".to_owned()),
2069            lang: Some("rust".to_owned()),
2070            blob_hash: Some("deadbeef".to_owned()),
2071            span: Some(Span::new(10, 42)),
2072            provenance: Provenance::Derived,
2073            meta: serde_json::json!({"vis": "pub"}),
2074        }
2075    }
2076
2077    /// Two Roteiro processes over one repository is ordinary rather than exotic —
2078    /// an editor's MCP client and a terminal, a long-lived `serve` and a [`crate::sync`],
2079    /// or several clients each spawning their own stdio server. Both writing must
2080    /// **queue**, not kill one of them.
2081    ///
2082    /// Reproduces the shape every upsert in this file has: *read, then write,
2083    /// inside one transaction.* That is precisely the case the five-second
2084    /// `busy_timeout` never covered — SQLite refuses a lock upgrade outright
2085    /// rather than waiting on it, because waiting could deadlock, so the handler
2086    /// is never invoked and the second writer dies at once. See `from_conn`.
2087    ///
2088    /// Drives the connection directly rather than a public method because the
2089    /// first writer has to still be *holding* the lock when the second arrives,
2090    /// and no public API lets a caller pause mid-transaction.
2091    #[test]
2092    fn a_second_writer_waits_for_the_first_instead_of_failing() {
2093        let dir = scratch("lock");
2094        std::fs::create_dir_all(&dir).expect("temp dir");
2095        let path = dir.join("graph.db");
2096
2097        let mut first = Store::open(&path).expect("first store opens");
2098        first
2099            .conn
2100            .execute_batch("CREATE TABLE IF NOT EXISTS probe(v INTEGER);")
2101            .expect("probe table");
2102        let mut second = Store::open(&path).expect("second store opens");
2103
2104        // Signalled once the first writer is inside its transaction *and* holding
2105        // the write lock, so the second arrives during the contention rather than
2106        // racing to it.
2107        //
2108        // A channel rather than a `Barrier`, because `Barrier::wait` cannot fail:
2109        // if the writer panicked before reaching it — a failing `expect`, a
2110        // migration error — the main thread would wait for ever and the test would
2111        // hang CI instead of failing it. Dropping the `Sender` on a panicking
2112        // thread disconnects the channel, so `recv_timeout` returns *immediately*
2113        // in that case, and the timeout covers the merely-pathological one.
2114        // Raised in review of this change.
2115        let (signal, holding) = std::sync::mpsc::channel::<()>();
2116
2117        let writer = std::thread::spawn(move || {
2118            let tx = first.conn.transaction().expect("first writer opens");
2119            let _: i64 = tx
2120                .query_row("SELECT count(*) FROM probe", [], |r| r.get(0))
2121                .expect("first writer reads");
2122            tx.execute("INSERT INTO probe(v) VALUES (1)", [])
2123                .expect("first writer writes");
2124            // Ignored deliberately: if the main thread has already gone (its own
2125            // assertion failed), there is nobody to tell and nothing to do.
2126            let _ = signal.send(());
2127            std::thread::sleep(std::time::Duration::from_millis(250));
2128            tx.commit().expect("first writer commits");
2129        });
2130
2131        if holding
2132            .recv_timeout(std::time::Duration::from_secs(10))
2133            .is_err()
2134        {
2135            // Re-raise the writer's own panic, which says far more than "timed
2136            // out" would; only if it did not panic is the timeout itself the news.
2137            writer.join().expect("first writer reached the lock");
2138            panic!("first writer neither signalled nor panicked");
2139        }
2140        let tx = second
2141            .conn
2142            .transaction()
2143            .expect("a second writer must wait for the first, not be refused");
2144        let _: i64 = tx
2145            .query_row("SELECT count(*) FROM probe", [], |r| r.get(0))
2146            .expect("second writer reads");
2147        tx.execute("INSERT INTO probe(v) VALUES (2)", [])
2148            .expect("second writer writes rather than failing `database is locked`");
2149        tx.commit().expect("second writer commits");
2150
2151        writer.join().expect("first writer finished");
2152
2153        let seen: i64 = second
2154            .conn
2155            .query_row("SELECT count(*) FROM probe", [], |r| r.get(0))
2156            .expect("count");
2157        assert_eq!(seen, 2, "both writers' rows survive — neither was dropped");
2158
2159        drop(second);
2160        std::fs::remove_dir_all(&dir).ok();
2161    }
2162
2163    /// A file-backed store takes WAL wherever the filesystem allows it, so a
2164    /// reader is not shut out for the whole of a writer's transaction.
2165    ///
2166    /// Asserted **against what a plain connection gets on this same filesystem**
2167    /// rather than against the literal `wal`, because `from_conn` treats WAL as
2168    /// tolerated rather than required: it is refused on most network filesystems,
2169    /// and a hard assertion would fail there for a store behaving exactly as
2170    /// designed. Raised in review — the first version of this test contradicted
2171    /// the tolerance documented by the code it tests.
2172    ///
2173    /// Still not vacuous, which is what an "assert the two agree" test has to
2174    /// earn: the probe is a raw `Connection` that asks for WAL itself, so a
2175    /// `from_conn` that stopped asking would leave the probe reporting `wal` and
2176    /// the store reporting something else, and the two would disagree.
2177    #[test]
2178    fn a_file_backed_store_takes_whatever_wal_this_filesystem_allows() {
2179        let dir = scratch("wal");
2180        std::fs::create_dir_all(&dir).expect("temp dir");
2181
2182        let available: String = {
2183            let probe = super::Connection::open(dir.join("probe.db")).expect("probe opens");
2184            probe
2185                .query_row("PRAGMA journal_mode = WAL", [], |r| r.get(0))
2186                .expect("probe journal mode")
2187        };
2188
2189        let store = Store::open(&dir.join("graph.db")).expect("store opens");
2190        let mode: String = store
2191            .conn
2192            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2193            .expect("journal mode");
2194
2195        assert_eq!(
2196            mode.to_ascii_lowercase(),
2197            available.to_ascii_lowercase(),
2198            "a store must reach the same journal mode a plain connection does here"
2199        );
2200
2201        drop(store);
2202        std::fs::remove_dir_all(&dir).ok();
2203    }
2204
2205    /// And an in-memory store still opens, which is the tolerance path: WAL is
2206    /// meaningless there, `PRAGMA journal_mode` answers `memory`, and that must
2207    /// not be an error — the same branch a network filesystem takes.
2208    #[test]
2209    fn an_in_memory_store_opens_without_wal() {
2210        let store = Store::open_in_memory().expect("in-memory store opens");
2211        let mode: String = store
2212            .conn
2213            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2214            .expect("journal mode");
2215        assert_ne!(mode.to_ascii_lowercase(), "wal");
2216    }
2217
2218    #[test]
2219    fn reconcile_matches_a_full_rebuild() {
2220        // reconcile must leave the store identical to a fresh rebuild, across an
2221        // add, a remove, a content change, and edge churn.
2222        let node = |k: &str, name: &str| {
2223            let mut n = sample_node(k);
2224            n.name = name.to_owned();
2225            n
2226        };
2227        let edge =
2228            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
2229        // An inferred edge carries confidence and a src_ref — exercise both so the
2230        // equivalence claim covers every edge field, not just derived calls.
2231        let inferred = |src: &str, dst: &str, conf: f64| {
2232            let mut e = Edge::inferred(src.to_owned(), dst.to_owned(), EdgeKind::Related, conf);
2233            e.src_ref = Some("import:demo".to_owned());
2234            e
2235        };
2236
2237        let facts1 = FactSet {
2238            nodes: vec![node("a", "A"), node("b", "B"), node("c", "C")],
2239            edges: vec![edge("a", "b"), edge("b", "c"), inferred("a", "c", 0.7)],
2240        };
2241        // b changes (name), c is removed, d is added; edge b->c drops, a->d added,
2242        // and the inferred edge's confidence changes.
2243        let facts2 = FactSet {
2244            nodes: vec![node("a", "A"), node("b", "B2"), node("d", "D")],
2245            edges: vec![edge("a", "b"), edge("a", "d"), inferred("a", "d", 0.9)],
2246        };
2247
2248        // Path 1: rebuild facts1, then reconcile to facts2.
2249        let mut reconciled = Store::open_in_memory().expect("open");
2250        reconciled.rebuild(&facts1, Some("t1")).expect("rebuild");
2251        reconciled
2252            .reconcile(&facts2, Some("t2"))
2253            .expect("reconcile");
2254
2255        // Path 2: a fresh full rebuild of facts2.
2256        let mut rebuilt = Store::open_in_memory().expect("open");
2257        rebuilt.rebuild(&facts2, Some("t2")).expect("rebuild");
2258
2259        let canon = |fs: FactSet| {
2260            let mut nodes = fs.nodes;
2261            nodes.sort_by(|a, b| a.key.cmp(&b.key));
2262            let mut edges: Vec<String> = fs
2263                .edges
2264                .iter()
2265                .map(|e| {
2266                    format!(
2267                        "{}\0{}\0{}\0{}\0{:?}\0{:?}",
2268                        e.kind.as_str(),
2269                        e.src,
2270                        e.dst,
2271                        e.provenance.as_str(),
2272                        e.confidence,
2273                        e.src_ref
2274                    )
2275                })
2276                .collect();
2277            edges.sort();
2278            (nodes, edges)
2279        };
2280        assert_eq!(
2281            canon(reconciled.export_factset().expect("export")),
2282            canon(rebuilt.export_factset().expect("export")),
2283            "reconcile must match a full rebuild",
2284        );
2285        assert_eq!(
2286            reconciled.sync_state().expect("state").as_deref(),
2287            Some("t2")
2288        );
2289    }
2290
2291    /// The worktree stamp (issue #330) identifies the *store*, not the tree: an
2292    /// unstamped store reads as "unknown" and is adopted rather than rebuilt, and
2293    /// a new tree state must not clear the stamp the way it clears `env`.
2294    #[test]
2295    fn the_worktree_stamp_survives_tree_writes_and_starts_unknown() {
2296        let mut store = Store::open_in_memory().expect("open");
2297        let facts = FactSet::new().with_node(sample_node("sym:a"));
2298
2299        // Never synced ⇒ no stamp. A legacy store reads the same way, which is
2300        // why "unknown" must mean adopt, not mismatch.
2301        assert_eq!(store.synced_worktree().expect("stamp"), None);
2302
2303        store.reconcile(&facts, Some("t1")).expect("reconcile");
2304        assert_eq!(
2305            store.synced_worktree().expect("stamp"),
2306            None,
2307            "writing a tree does not invent a stamp; the sync engine records it"
2308        );
2309
2310        store.set_synced_worktree("/w/one").expect("stamp");
2311        store.set_sync_env("env-1").expect("env");
2312        assert_eq!(
2313            store.synced_worktree().expect("stamp").as_deref(),
2314            Some("/w/one")
2315        );
2316
2317        // A later tree write clears `env` (it is only valid for one tree) but must
2318        // KEEP the stamp: a new commit does not move the graph to another tree.
2319        store.reconcile(&facts, Some("t2")).expect("reconcile");
2320        assert_eq!(store.sync_env().expect("env"), None, "env is tree-scoped");
2321        assert_eq!(
2322            store.synced_worktree().expect("stamp").as_deref(),
2323            Some("/w/one"),
2324            "the stamp identifies the store, not the tree, so it must survive"
2325        );
2326
2327        // Re-stamping replaces it (the store was adopted by another tree).
2328        store.set_synced_worktree("/w/two").expect("restamp");
2329        assert_eq!(
2330            store.synced_worktree().expect("stamp").as_deref(),
2331            Some("/w/two")
2332        );
2333
2334        // Clearing the synced state clears the stamp with it: a store with no
2335        // recorded tree describes no working tree either.
2336        store.rebuild(&facts, None).expect("rebuild unstated");
2337        assert_eq!(store.sync_state().expect("state"), None);
2338        assert_eq!(store.synced_worktree().expect("stamp"), None);
2339    }
2340
2341    #[test]
2342    fn reconcile_writes_only_the_edge_delta() {
2343        // An unchanged edge must keep its row (proving reconcile does not wipe and
2344        // reinsert the whole edge set); a removed edge's row goes; a new edge's row
2345        // appears. Row identity is the SQLite `rowid` — stable unless deleted.
2346        let n = |k: &str| sample_node(k);
2347        let e =
2348            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
2349
2350        let mut store = Store::open_in_memory().expect("open");
2351        store
2352            .rebuild(
2353                &FactSet {
2354                    nodes: vec![n("a"), n("b"), n("c")],
2355                    edges: vec![e("a", "b"), e("b", "c")],
2356                },
2357                None,
2358            )
2359            .expect("rebuild");
2360
2361        // Map (src_key, dst_key) → rowid via the private connection.
2362        let rowids = |store: &Store| -> std::collections::HashMap<(String, String), i64> {
2363            let mut stmt = store
2364                .conn
2365                .prepare(
2366                    "SELECT ns.key, nd.key, e.rowid FROM edges e \
2367                     JOIN nodes ns ON ns.id = e.src JOIN nodes nd ON nd.id = e.dst",
2368                )
2369                .expect("prepare");
2370            stmt.query_map([], |r| {
2371                Ok((
2372                    (r.get::<_, String>(0)?, r.get::<_, String>(1)?),
2373                    r.get::<_, i64>(2)?,
2374                ))
2375            })
2376            .expect("query")
2377            .map(Result::unwrap)
2378            .collect()
2379        };
2380
2381        let before = rowids(&store);
2382        let ab_rowid = before[&("a".to_owned(), "b".to_owned())];
2383
2384        // Keep a->b, drop b->c, add a->c.
2385        store
2386            .reconcile(
2387                &FactSet {
2388                    nodes: vec![n("a"), n("b"), n("c")],
2389                    edges: vec![e("a", "b"), e("a", "c")],
2390                },
2391                None,
2392            )
2393            .expect("reconcile");
2394
2395        let after = rowids(&store);
2396        assert_eq!(
2397            after.get(&("a".to_owned(), "b".to_owned())),
2398            Some(&ab_rowid),
2399            "the unchanged edge keeps its row (not rewritten)"
2400        );
2401        assert!(
2402            !after.contains_key(&("b".to_owned(), "c".to_owned())),
2403            "the removed edge's row is gone"
2404        );
2405        assert!(
2406            after.contains_key(&("a".to_owned(), "c".to_owned())),
2407            "the added edge has a new row"
2408        );
2409    }
2410
2411    #[test]
2412    fn reconcile_is_history_independent_for_edge_queries() {
2413        // The whole point of the edge delta: it must be invisible above storage.
2414        // A store reached by rebuild(f1)+reconcile(f2) has different edge row ids
2415        // than a cold rebuild at f2, yet every edge query must return byte-for-byte
2416        // the same result — order included — because the queries are content-ordered.
2417        let n = |k: &str| sample_node(k);
2418        let d =
2419            |src: &str, dst: &str| Edge::derived(src.to_owned(), dst.to_owned(), EdgeKind::Calls);
2420        let inf = |src: &str, dst: &str, c: f64| {
2421            Edge::inferred(src.to_owned(), dst.to_owned(), EdgeKind::Related, c)
2422        };
2423        let f1 = FactSet {
2424            nodes: vec![n("a"), n("b"), n("c")],
2425            edges: vec![d("a", "b"), d("b", "c"), inf("a", "c", 0.7)],
2426        };
2427        let f2 = FactSet {
2428            nodes: vec![n("a"), n("b"), n("d")],
2429            edges: vec![d("a", "b"), d("a", "d"), inf("a", "d", 0.9)],
2430        };
2431
2432        let mut incremental = Store::open_in_memory().expect("open");
2433        incremental.rebuild(&f1, None).expect("rebuild");
2434        incremental.reconcile(&f2, None).expect("reconcile");
2435        let mut cold = Store::open_in_memory().expect("open");
2436        cold.rebuild(&f2, None).expect("rebuild");
2437
2438        // Project to all fields so the comparison covers order *and* content.
2439        let proj = |es: Vec<Edge>| -> Vec<String> {
2440            es.into_iter()
2441                .map(|e| {
2442                    format!(
2443                        "{}|{}|{}|{}|{:?}|{:?}",
2444                        e.src,
2445                        e.dst,
2446                        e.kind.as_str(),
2447                        e.provenance.as_str(),
2448                        e.confidence,
2449                        e.src_ref
2450                    )
2451                })
2452                .collect()
2453        };
2454
2455        for key in ["a", "b", "d"] {
2456            assert_eq!(
2457                proj(incremental.edges_from(key).expect("from")),
2458                proj(cold.edges_from(key).expect("from")),
2459                "edges_from({key}) must match a cold rebuild"
2460            );
2461            assert_eq!(
2462                proj(incremental.edges_to(key).expect("to")),
2463                proj(cold.edges_to(key).expect("to")),
2464                "edges_to({key}) must match a cold rebuild"
2465            );
2466        }
2467        assert_eq!(
2468            proj(incremental.all_edges().expect("all")),
2469            proj(cold.all_edges().expect("all")),
2470            "all_edges must match a cold rebuild"
2471        );
2472        for p in [Provenance::Derived, Provenance::Inferred] {
2473            assert_eq!(
2474                proj(incremental.edges_by_provenance(p).expect("prov")),
2475                proj(cold.edges_by_provenance(p).expect("prov")),
2476                "edges_by_provenance({}) must match a cold rebuild",
2477                p.as_str()
2478            );
2479        }
2480    }
2481
2482    #[test]
2483    fn reconcile_updates_confidence_on_an_unchanged_tuple() {
2484        // A change to *only* an edge's confidence — same (src, dst, kind,
2485        // provenance) — must still be applied. Edge identity includes confidence,
2486        // so it is delete+add (matching a full rebuild), not the insert-time
2487        // `DO NOTHING` that would leave the stale confidence in place.
2488        let n = |k: &str| sample_node(k);
2489        let inf = |c: f64| {
2490            let mut e = Edge::inferred("a".to_owned(), "b".to_owned(), EdgeKind::Related, c);
2491            e.src_ref = Some("import:demo".to_owned());
2492            e
2493        };
2494
2495        let mut store = Store::open_in_memory().expect("open");
2496        store
2497            .rebuild(
2498                &FactSet {
2499                    nodes: vec![n("a"), n("b")],
2500                    edges: vec![inf(0.5)],
2501                },
2502                None,
2503            )
2504            .expect("rebuild");
2505        store
2506            .reconcile(
2507                &FactSet {
2508                    nodes: vec![n("a"), n("b")],
2509                    edges: vec![inf(0.9)],
2510                },
2511                None,
2512            )
2513            .expect("reconcile");
2514
2515        let edges = store.edges_from("a").expect("edges");
2516        assert_eq!(edges.len(), 1);
2517        assert_eq!(
2518            edges[0].confidence,
2519            Some(0.9),
2520            "confidence updated, not left stale"
2521        );
2522    }
2523
2524    #[test]
2525    fn edge_identity_does_not_collide_across_field_boundaries() {
2526        // Node keys embed git paths, which may contain any byte — including the
2527        // unit separator (`\x1f`). A delimiter-joined identity would map these two
2528        // distinct edges to the same string (`a\x1fb\x1fc\x1f…`); the tuple identity
2529        // must keep them apart, or reconcile would drop one edge as a "duplicate".
2530        let e1 = super::edge_identity(&Edge::derived(
2531            "a\u{1f}b".to_owned(),
2532            "c".to_owned(),
2533            EdgeKind::Calls,
2534        ));
2535        let e2 = super::edge_identity(&Edge::derived(
2536            "a".to_owned(),
2537            "b\u{1f}c".to_owned(),
2538            EdgeKind::Calls,
2539        ));
2540        assert_ne!(
2541            e1, e2,
2542            "control chars in a key must not collapse identities"
2543        );
2544
2545        // A confidence-only difference (same tuple otherwise) also stays distinct,
2546        // and `None` (derived) never equals `Some(0.0)`.
2547        let derived = super::edge_identity(&Edge::derived(
2548            "a".to_owned(),
2549            "b".to_owned(),
2550            EdgeKind::Related,
2551        ));
2552        let inferred0 = super::edge_identity(&Edge::inferred(
2553            "a".to_owned(),
2554            "b".to_owned(),
2555            EdgeKind::Related,
2556            0.0,
2557        ));
2558        assert_ne!(derived, inferred0, "None vs Some(0.0) confidence differ");
2559    }
2560
2561    #[test]
2562    fn open_in_memory_applies_schema() {
2563        let store = Store::open_in_memory().expect("open");
2564        assert_eq!(store.node_count().expect("count"), 0);
2565        // Written against `latest_version()` rather than the literal that stood
2566        // here (8 for analyzer findings, then 9, 10, 11 as the media and
2567        // agent-memory tables landed). The literal was defended as making someone
2568        // confirm a new migration is meant to apply on open — but it could not do
2569        // that job: `apply` applies every migration not already recorded, so
2570        // "applies on open" is not a per-migration choice there is anything to
2571        // confirm. What the literal actually asserted was the value of a shared
2572        // constant, which every future migration then has to come here and edit,
2573        // in a file it otherwise has no business in — the brittleness #329
2574        // replaced with a property test elsewhere. This is the idiom
2575        // `migrations::tests::a_later_migration_is_additive_on_a_populated_store`
2576        // already uses, for the same reason.
2577        //
2578        // Since migrations are selected by set membership, the property is now
2579        // strictly stronger than "the biggest recorded number is N":
2580        // `schema_version` is the highest **gap-free** version, so equality with
2581        // `latest_version` also rules out a store that skipped one and stamped a
2582        // higher one. A fresh store cannot exhibit that gap, so
2583        // `reopening_repairs_a_skipped_migration` exercises it directly.
2584        assert_eq!(
2585            store.schema_version().expect("version"),
2586            crate::migrations::latest_version(),
2587            "opening a store must apply every known migration, with no gaps"
2588        );
2589    }
2590
2591    /// Reopening a store that is **missing a lower-numbered migration while
2592    /// carrying a higher one** repairs it — through the real public API, which
2593    /// is where it matters to callers.
2594    ///
2595    /// This is not a hypothetical: it is the shape `main` itself carries.
2596    /// `feat/stage25-memory-recall` merged migration **13** while migration
2597    /// **12** was still on this branch, so every store opened by a current-`main`
2598    /// build — including this repository's own `.git/roteiro/graph.db` and each
2599    /// linked worktree's — records `1..11, 13`. Under the old
2600    /// `version > MAX(recorded)` rule, `12 > 13` is false and migration 12 would
2601    /// never be applied to any of them, permanently: the store opens cleanly,
2602    /// reports a schema it does not have, and `synced_worktree()` fails with
2603    /// `no such column: worktree` — the issue #330 tree stamp, silently absent.
2604    ///
2605    /// The two migrations are deliberately named by number rather than derived
2606    /// from `latest_version()`. Which one is missing and which is present is the
2607    /// specific historical fact being tested; arithmetic on the newest version
2608    /// would quietly re-aim the test at whatever lands next and stop covering
2609    /// this.
2610    #[test]
2611    fn reopening_repairs_a_skipped_migration() {
2612        /// The migration this branch adds — absent from a current-`main` store.
2613        const SKIPPED: u32 = 12;
2614        /// The migration `main` added in parallel — present, and stamped higher.
2615        const AHEAD: u32 = 13;
2616
2617        let path = std::env::temp_dir().join(format!(
2618            "roteiro-migration-gap-{}-{:?}.db",
2619            std::process::id(),
2620            std::thread::current().id()
2621        ));
2622        std::fs::remove_file(&path).ok();
2623
2624        {
2625            let mut store = Store::open(&path).expect("open");
2626            // Reconcile rather than a bare upsert: it records a synced tree, so
2627            // `sync_state` has the row the worktree stamp updates. Without one,
2628            // `set_synced_worktree` is a documented no-op and would prove nothing.
2629            let facts = FactSet::new().with_node(sample_node("kept"));
2630            store
2631                .reconcile(&facts, Some("t1"))
2632                .expect("seed graph and sync state");
2633        }
2634        // Rewind to exactly what a current-`main` build leaves behind: migration
2635        // 12's column and record gone, 13's schema and record intact.
2636        {
2637            let conn = rusqlite::Connection::open(&path).expect("reopen raw");
2638            conn.execute_batch("ALTER TABLE sync_state DROP COLUMN worktree;")
2639                .expect("undo migration 12");
2640            conn.execute(
2641                "DELETE FROM schema_migrations WHERE version = ?1",
2642                [SKIPPED],
2643            )
2644            .expect("unrecord 12");
2645            // Sanity: the store really is in the damaged shape, 13 and all.
2646            let recorded: i64 = conn
2647                .query_row(
2648                    "SELECT COUNT(*) FROM schema_migrations WHERE version = ?1",
2649                    [AHEAD],
2650                    |r| r.get(0),
2651                )
2652                .expect("count 13");
2653            assert_eq!(recorded, 1, "migration {AHEAD} must still be recorded");
2654        }
2655        // Migration 13's own state, captured before the repair so it can be shown
2656        // untouched afterwards — a repair that healed the gap by disturbing the
2657        // other branch's tables would be no better than the bug.
2658        let clock_before = cache_clock(&path);
2659
2660        // Before the repair the store under-reports rather than over-reports: it
2661        // claims the last gap-free version, never the higher stamped one.
2662        {
2663            let damaged = rusqlite::Connection::open(&path).expect("reopen raw");
2664            assert_eq!(
2665                crate::migrations::store_version(&damaged).expect("version"),
2666                SKIPPED - 1,
2667                "a gapped store must report the version it actually provides, \
2668                 not the maximum recorded ({AHEAD})"
2669            );
2670        }
2671
2672        // Reopening through the public API repairs the gap…
2673        let store = Store::open(&path).expect("reopen");
2674        assert_eq!(
2675            store.schema_version().expect("version"),
2676            crate::migrations::latest_version(),
2677            "1..={AHEAD} is contiguous once the skipped migration is applied"
2678        );
2679        // …migration 12's schema is really back, which is what the caller
2680        // depends on — recorded-but-absent would be the same bug in a new
2681        // costume…
2682        assert!(
2683            store.synced_worktree().is_ok(),
2684            "the repaired migration's column must exist, not merely be recorded"
2685        );
2686        store
2687            .set_synced_worktree("/w/repaired")
2688            .expect("the stamp must be writable, not just readable");
2689        assert_eq!(
2690            store.synced_worktree().expect("stamp").as_deref(),
2691            Some("/w/repaired")
2692        );
2693        // …migration 13's tables are untouched, clock row and all…
2694        assert_eq!(
2695            cache_clock(&path),
2696            clock_before,
2697            "the other branch's seeded `agent_cache_clock` row must survive the \
2698             repair unchanged"
2699        );
2700        {
2701            let conn = rusqlite::Connection::open(&path).expect("reopen raw");
2702            let rows: i64 = conn
2703                .query_row("SELECT COUNT(*) FROM agent_cache", [], |r| r.get(0))
2704                .expect("agent_cache must still exist");
2705            assert_eq!(rows, 0, "`agent_cache` is present and empty, not recreated");
2706        }
2707        // …and no data was lost.
2708        assert!(store.get_node("kept").expect("get").is_some());
2709        assert_eq!(store.node_count().expect("count"), 1);
2710
2711        std::fs::remove_file(&path).expect("cleanup");
2712    }
2713
2714    /// Migration 13's single `agent_cache_clock` row as `(ticks, generation)`,
2715    /// read raw so this test does not depend on the recall API another branch
2716    /// owns.
2717    fn cache_clock(path: &std::path::Path) -> (i64, i64) {
2718        let conn = rusqlite::Connection::open(path).expect("open raw");
2719        conn.query_row(
2720            "SELECT ticks, generation FROM agent_cache_clock WHERE id = 0",
2721            [],
2722            |r| Ok((r.get(0)?, r.get(1)?)),
2723        )
2724        .expect("the seeded clock row must exist")
2725    }
2726
2727    /// Record `version` in `store`'s migration table as though a build that knew
2728    /// that migration had run it — the only way to manufacture a store from the
2729    /// future without a time machine.
2730    ///
2731    /// Only the *record* is written, not any schema the migration would have
2732    /// added. That is exactly the position an older binary is in: it can see the
2733    /// stamp and cannot see, or reason about, the shape.
2734    fn stamp_version(store: &Store, version: u32) {
2735        store
2736            .conn
2737            .execute(
2738                "INSERT INTO schema_migrations (version) VALUES (?1)",
2739                [version],
2740            )
2741            .expect("stamp a version this build does not know");
2742    }
2743
2744    #[test]
2745    fn a_store_this_build_wrote_is_not_ahead_of_it() {
2746        let store = Store::open_in_memory().expect("open");
2747        assert_eq!(
2748            store.schema_ahead().expect("schema_ahead"),
2749            None,
2750            "a store this very build just migrated cannot be ahead of it"
2751        );
2752    }
2753
2754    /// A store carrying migrations this build has never heard of is reported
2755    /// ahead, and the report names **both** sides plus what to do about it.
2756    ///
2757    /// Versions are `latest_version() + n` rather than literals: the property is
2758    /// "beyond what this build knows", and a literal would silently stop testing
2759    /// that the day a real migration reached the same number.
2760    #[test]
2761    fn a_store_stamped_beyond_this_build_is_reported_ahead() {
2762        let build = Store::build_schema_version();
2763        let store = Store::open_in_memory().expect("open");
2764        stamp_version(&store, build + 1);
2765        stamp_version(&store, build + 2);
2766
2767        let ahead = store
2768            .schema_ahead()
2769            .expect("schema_ahead")
2770            .expect("a store two migrations beyond this build must be reported");
2771        assert_eq!(ahead.build_version(), build);
2772        assert_eq!(
2773            ahead.store_version(),
2774            build + 2,
2775            "the store's version is the highest it records, not the first \
2776             unknown one"
2777        );
2778        assert_eq!(ahead.unknown_versions(), [build + 1, build + 2]);
2779
2780        // The message has to be actionable, not merely correct: both versions by
2781        // number, and the fix (upgrade the binary — never "delete the store").
2782        let message = ahead.to_string();
2783        for expected in [
2784            &format!("{}", build + 2),
2785            &format!("{build}"),
2786            &"pgrade".to_owned(),
2787        ] {
2788            assert!(
2789                message.contains(expected.as_str()),
2790                "the refusal must name `{expected}`: {message}"
2791            );
2792        }
2793    }
2794
2795    /// A **gap** — a store missing a lower migration while carrying a higher one
2796    /// this build knows — is not a store from the future, and must not be
2797    /// reported as one. Conflating them would name a version the store is not
2798    /// at, in a message telling the reader to upgrade a binary that is already
2799    /// new enough.
2800    ///
2801    /// This is the shape `main` itself carried (see
2802    /// `reopening_repairs_a_skipped_migration`, which proves `Store::open`
2803    /// repairs it), so it is a live case, not a hypothetical.
2804    ///
2805    /// The second half is the one that matters: a store that is *both* gapped
2806    /// and from the future must be described only by the half the reader can act
2807    /// on. Naming the gap would tell someone to upgrade a binary that is already
2808    /// new enough for it.
2809    #[test]
2810    fn a_gap_below_this_build_is_not_a_store_from_the_future() {
2811        let build = Store::build_schema_version();
2812
2813        // A gap and nothing else: behind this build, so never ahead of it.
2814        let gapped = Store::open_in_memory().expect("open");
2815        gapped
2816            .conn
2817            .execute("DELETE FROM schema_migrations WHERE version = ?1", [build])
2818            .expect("open a gap");
2819        assert!(
2820            gapped.schema_version().expect("version") < build,
2821            "the gapped store must under-report, or this test proves nothing"
2822        );
2823        assert_eq!(
2824            gapped.schema_ahead().expect("schema_ahead"),
2825            None,
2826            "a store *missing* a migration is behind this build, never ahead"
2827        );
2828
2829        // A gap *and* a version from the future: reported ahead, and reported
2830        // only in terms of the future version.
2831        stamp_version(&gapped, build + 1);
2832        let ahead = gapped
2833            .schema_ahead()
2834            .expect("schema_ahead")
2835            .expect("the unknown migration is still unknown, gap or no gap");
2836        assert_eq!(ahead.unknown_versions(), [build + 1]);
2837        assert_eq!(
2838            ahead.store_version(),
2839            build + 1,
2840            "the gap is a separate condition and must not colour this report"
2841        );
2842    }
2843
2844    /// The reason the guard compares the recorded **set** and not
2845    /// [`Store::schema_version`].
2846    ///
2847    /// `schema_version` is the highest *gap-free* version — a floor for readers.
2848    /// A store recorded `1..=build, build + 2` therefore reports `build`, and a
2849    /// `schema_version() > build` test sees a perfectly ordinary store, while
2850    /// migration `build + 2`'s schema is in the file and a newer binary plainly
2851    /// assembled the graph. That is issue #342 surviving the check written to
2852    /// catch it, so it is asserted directly rather than trusted.
2853    #[test]
2854    fn the_gap_free_version_alone_would_miss_a_store_from_the_future() {
2855        let build = Store::build_schema_version();
2856        let store = Store::open_in_memory().expect("open");
2857        stamp_version(&store, build + 2); // note: `build + 1` deliberately absent
2858
2859        assert_eq!(
2860            store.schema_version().expect("version"),
2861            build,
2862            "the gap-free version stops at the last contiguous migration, so it \
2863             cannot see the one above the hole"
2864        );
2865        let ahead = store
2866            .schema_ahead()
2867            .expect("schema_ahead")
2868            .expect("the set difference sees what the contiguity walk cannot");
2869        assert_eq!(ahead.store_version(), build + 2);
2870        assert_eq!(ahead.unknown_versions(), [build + 2]);
2871    }
2872
2873    #[test]
2874    fn upsert_and_get_round_trips_all_fields() {
2875        let store = Store::open_in_memory().expect("open");
2876        let node = sample_node("sym:rust:src/lib.rs#sample");
2877        store.upsert_node(&node).expect("upsert");
2878        let got = store.get_node(&node.key).expect("get").expect("present");
2879        assert_eq!(got, node);
2880    }
2881
2882    #[test]
2883    fn upsert_updates_in_place() {
2884        let store = Store::open_in_memory().expect("open");
2885        let mut node = sample_node("k");
2886        store.upsert_node(&node).expect("insert");
2887        node.name = "renamed".to_owned();
2888        node.kind = NodeKind::Struct;
2889        store.upsert_node(&node).expect("update");
2890        assert_eq!(store.node_count().expect("count"), 1);
2891        let got = store.get_node("k").expect("get").expect("present");
2892        assert_eq!(got.name, "renamed");
2893        assert_eq!(got.kind, NodeKind::Struct);
2894    }
2895
2896    #[test]
2897    fn edge_with_unknown_endpoint_is_rejected() {
2898        let store = Store::open_in_memory().expect("open");
2899        store
2900            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
2901            .expect("a");
2902        let edge = Edge::derived("a", "missing", EdgeKind::Calls);
2903        let err = store.insert_edge(&edge).expect_err("should reject");
2904        assert!(matches!(err, super::StoreError::UnknownNode(k) if k == "missing"));
2905    }
2906
2907    #[test]
2908    fn inferred_edge_requires_confidence() {
2909        let store = Store::open_in_memory().expect("open");
2910        store
2911            .upsert_node(&Node::new("a", NodeKind::Fn, "a"))
2912            .expect("a");
2913        store
2914            .upsert_node(&Node::new("b", NodeKind::Fn, "b"))
2915            .expect("b");
2916        // Hand-build an inferred edge with no confidence to violate the invariant.
2917        let bad = Edge {
2918            src: "a".to_owned(),
2919            dst: "b".to_owned(),
2920            kind: EdgeKind::References,
2921            provenance: Provenance::Inferred,
2922            confidence: None,
2923            src_ref: None,
2924        };
2925        assert!(matches!(
2926            store.insert_edge(&bad).expect_err("reject"),
2927            super::StoreError::InvalidEdge(_)
2928        ));
2929    }
2930
2931    #[test]
2932    fn apply_factset_is_atomic() {
2933        let mut store = Store::open_in_memory().expect("open");
2934        // Second edge references a missing node, so the whole set must roll back.
2935        let facts = FactSet::new()
2936            .with_node(Node::new("a", NodeKind::Fn, "a"))
2937            .with_node(Node::new("b", NodeKind::Fn, "b"))
2938            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
2939            .with_edge(Edge::derived("a", "ghost", EdgeKind::Calls));
2940        assert!(store.apply_factset(&facts).is_err());
2941        assert_eq!(store.node_count().expect("count"), 0, "rolled back");
2942        assert_eq!(store.edge_count().expect("count"), 0, "rolled back");
2943    }
2944
2945    #[test]
2946    fn neighbors_and_provenance_queries() {
2947        let mut store = Store::open_in_memory().expect("open");
2948        let facts = FactSet::new()
2949            .with_node(Node::new("a", NodeKind::Fn, "a"))
2950            .with_node(Node::new("b", NodeKind::Fn, "b"))
2951            .with_node(Node::new("c", NodeKind::Fn, "c"))
2952            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
2953            .with_edge(Edge::inferred("a", "c", EdgeKind::References, 0.5));
2954        store.apply_factset(&facts).expect("apply");
2955
2956        let out = store.neighbors("a", Direction::Outgoing).expect("out");
2957        let mut keys: Vec<_> = out.iter().map(|n| n.key.clone()).collect();
2958        keys.sort();
2959        assert_eq!(keys, ["b", "c"]);
2960
2961        assert!(
2962            store
2963                .neighbors("b", Direction::Outgoing)
2964                .expect("b out")
2965                .is_empty()
2966        );
2967        assert_eq!(
2968            store
2969                .neighbors("b", Direction::Incoming)
2970                .expect("b in")
2971                .len(),
2972            1
2973        );
2974
2975        let inferred = store
2976            .edges_by_provenance(Provenance::Inferred)
2977            .expect("inf");
2978        assert_eq!(inferred.len(), 1);
2979        assert_eq!(inferred[0].confidence, Some(0.5));
2980    }
2981
2982    #[test]
2983    fn neighbors_of_absent_node_is_empty() {
2984        let store = Store::open_in_memory().expect("open");
2985        assert!(
2986            store
2987                .neighbors("nope", Direction::Both)
2988                .expect("q")
2989                .is_empty()
2990        );
2991    }
2992
2993    #[test]
2994    fn get_missing_node_is_none() {
2995        let store = Store::open_in_memory().expect("open");
2996        assert!(store.get_node("absent").expect("get").is_none());
2997    }
2998
2999    #[test]
3000    fn nodes_by_kind_and_edges_to() {
3001        let mut store = Store::open_in_memory().expect("open");
3002        let facts = FactSet::new()
3003            .with_node(Node::new("f1", NodeKind::Fn, "f1"))
3004            .with_node(Node::new("f2", NodeKind::Fn, "f2"))
3005            .with_node(Node::new("s1", NodeKind::Struct, "s1"))
3006            .with_edge(Edge::derived("f1", "s1", EdgeKind::References))
3007            .with_edge(Edge::derived("f2", "s1", EdgeKind::References));
3008        store.apply_factset(&facts).expect("apply");
3009
3010        let fns = store.nodes_by_kind(&NodeKind::Fn).expect("fns");
3011        assert_eq!(
3012            fns.iter().map(|n| n.key.as_str()).collect::<Vec<_>>(),
3013            ["f1", "f2"]
3014        );
3015        assert!(
3016            store
3017                .nodes_by_kind(&NodeKind::Enum)
3018                .expect("enums")
3019                .is_empty()
3020        );
3021
3022        let into_s1 = store.edges_to("s1").expect("edges_to");
3023        assert_eq!(into_s1.len(), 2);
3024        assert!(into_s1.iter().all(|e| e.dst == "s1"));
3025    }
3026
3027    #[test]
3028    fn open_persists_across_reopen() {
3029        let path =
3030            std::env::temp_dir().join(format!("roteiro-open-test-{}.db", std::process::id()));
3031        std::fs::remove_file(&path).ok();
3032        {
3033            let store = Store::open(&path).expect("open");
3034            store
3035                .upsert_node(&sample_node("persisted"))
3036                .expect("upsert");
3037        }
3038        {
3039            let store = Store::open(&path).expect("reopen");
3040            assert_eq!(store.node_count().expect("count"), 1);
3041            // The subject here is that a *reopen* is at the same schema as a
3042            // fresh open — not what number that happens to be. See
3043            // `open_in_memory_applies_schema` on why the literal went.
3044            assert_eq!(
3045                store.schema_version().expect("version"),
3046                crate::migrations::latest_version(),
3047                "reopening applies any migration added since the file was written"
3048            );
3049            assert!(store.get_node("persisted").expect("get").is_some());
3050        }
3051        std::fs::remove_file(&path).expect("cleanup");
3052    }
3053
3054    fn graphify_layer(dst: &str) -> FactSet {
3055        FactSet::new()
3056            .with_node(Node::new("graphify:doc1", NodeKind::Doc, "Doc 1"))
3057            .with_edge({
3058                let mut e = Edge::inferred("graphify:doc1", dst, EdgeKind::References, 0.9);
3059                e.src_ref = Some("import:graphify".to_owned());
3060                e
3061            })
3062    }
3063
3064    /// A **withdrawn** concept goes, and a node another layer also asserts stays.
3065    ///
3066    /// This is the half of idempotence that was missing. Re-importing could never
3067    /// duplicate — nodes upsert by key, edges are deleted by `src_ref` first — so
3068    /// a re-run always *looked* idempotent, while a node the source had since
3069    /// deleted survived for ever as an orphan carrying the layer's provenance.
3070    /// Nothing appeared twice, which is exactly why nobody would notice.
3071    ///
3072    /// Three nodes, and each one is a different rule:
3073    ///
3074    /// * `okf:acme/gone.md` — withdrawn, and only this layer's. It must go.
3075    /// * `okf:acme/kept.md` — still asserted. Untouched.
3076    /// * `extref:acme::shared` — withdrawn *by this layer*, and asserted by
3077    ///   another. It must survive, because two layers naming one node is a
3078    ///   supported arrangement rather than an anomaly: `import:links` and
3079    ///   `import:links/authored` share every placeholder between them.
3080    #[test]
3081    fn re_importing_a_smaller_bundle_removes_what_was_withdrawn_and_nothing_else() {
3082        let mut store = Store::open_in_memory().expect("open");
3083
3084        let okf_node = |key: &str| {
3085            Node::new(key, NodeKind::Doc, key).with_provenance(Provenance::ExternalAuthored)
3086        };
3087        // Another producer, which also asserts the shared placeholder.
3088        let other = FactSet::new().with_node(okf_node("extref:acme::shared"));
3089        store
3090            .apply_import_layer("import:links", &other)
3091            .expect("other layer");
3092
3093        let first = FactSet::new()
3094            .with_node(okf_node("okf:acme/gone.md"))
3095            .with_node(okf_node("okf:acme/kept.md"))
3096            .with_node(okf_node("extref:acme::shared"));
3097        let applied = store
3098            .apply_import_layer("import:okf/acme", &first)
3099            .expect("first import");
3100        assert_eq!(
3101            applied.nodes_removed, 0,
3102            "nothing is withdrawn on a first run"
3103        );
3104        assert_eq!(store.node_count().expect("count"), 3);
3105
3106        // The peer deletes one concept and stops asserting the placeholder.
3107        let smaller = FactSet::new().with_node(okf_node("okf:acme/kept.md"));
3108        let applied = store
3109            .apply_import_layer("import:okf/acme", &smaller)
3110            .expect("re-import");
3111
3112        // Which nodes, before how many. The count alone catches both directions
3113        // of failure but names only one of them: removing the shared placeholder
3114        // as well would fail a `nodes_removed == 1` assertion with a message
3115        // about a concept surviving, which is the opposite of what went wrong.
3116        assert!(
3117            store
3118                .get_node("extref:acme::shared")
3119                .expect("get")
3120                .is_some(),
3121            "a node another layer also asserts must not be removed by this one"
3122        );
3123        assert!(
3124            store.get_node("okf:acme/kept.md").expect("get").is_some(),
3125            "a concept still asserted must survive"
3126        );
3127        assert!(
3128            store.get_node("okf:acme/gone.md").expect("get").is_none(),
3129            "a concept the peer deleted must not survive a re-import"
3130        );
3131        assert_eq!(
3132            applied.nodes_removed, 1,
3133            "the withdrawn concept must be removed, and only it"
3134        );
3135
3136        // And it is genuinely idempotent now: re-running the same smaller
3137        // bundle removes nothing further.
3138        let again = store
3139            .apply_import_layer("import:okf/acme", &smaller)
3140            .expect("third run");
3141        assert_eq!(again.nodes_removed, 0);
3142        assert_eq!(store.node_count().expect("count"), 2);
3143    }
3144
3145    /// A withdrawn node that something is **still attached to** survives, in
3146    /// either direction.
3147    ///
3148    /// The layer stopped asserting the node, but another producer's edge still
3149    /// relates it to the graph. Removing it would take that relationship with it
3150    /// — a deletion nobody asked for, wearing a cleanup's clothes — and would
3151    /// violate the foreign key on the way out.
3152    ///
3153    /// Both ends are exercised on purpose: the probe is an `OR` over `src` and
3154    /// `dst`, so a fixture attaching the node only as a destination leaves the
3155    /// other arm free to be deleted with nothing going red.
3156    #[test]
3157    fn a_withdrawn_node_another_producer_still_points_at_is_kept() {
3158        let mut store = Store::open_in_memory().expect("open");
3159        let derived = FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"));
3160        store.rebuild(&derived, Some("tree1")).expect("rebuild");
3161
3162        // Two withdrawn nodes, one for each **direction** an edge can attach.
3163        // The probe is an `OR` over `src` and `dst`, and a test that only ever
3164        // put the node on one end would leave the other arm unguarded: deleting
3165        // `src = …` from the query changed no test until this second node
3166        // existed, which is how the gap was found.
3167        let inbound = Node::new("okf:acme/pointed-at.md", NodeKind::Doc, "pointed at")
3168            .with_provenance(Provenance::ExternalDerived);
3169        let outbound = Node::new("okf:acme/points-from.md", NodeKind::Doc, "points from")
3170            .with_provenance(Provenance::ExternalDerived);
3171        let first = FactSet::new().with_node(inbound).with_node(outbound);
3172        store
3173            .apply_import_layer("import:okf/acme", &first)
3174            .expect("first");
3175
3176        // A different layer relates the derived file to one imported concept,
3177        // and the other imported concept to the derived file.
3178        let linking = FactSet::new()
3179            .with_edge({
3180                let mut e = Edge::inferred(
3181                    "file:a.rs",
3182                    "okf:acme/pointed-at.md",
3183                    EdgeKind::References,
3184                    0.9,
3185                );
3186                e.src_ref = Some("import:links".to_owned());
3187                e
3188            })
3189            .with_edge({
3190                let mut e = Edge::inferred(
3191                    "okf:acme/points-from.md",
3192                    "file:a.rs",
3193                    EdgeKind::References,
3194                    0.9,
3195                );
3196                e.src_ref = Some("import:links".to_owned());
3197                e
3198            });
3199        store
3200            .apply_import_layer("import:links", &linking)
3201            .expect("link layer");
3202
3203        let applied = store
3204            .apply_import_layer("import:okf/acme", &FactSet::new())
3205            .expect("withdraw everything");
3206        assert!(
3207            store
3208                .get_node("okf:acme/pointed-at.md")
3209                .expect("get")
3210                .is_some(),
3211            "a node another producer's edge points *at* must not be removed"
3212        );
3213        assert!(
3214            store
3215                .get_node("okf:acme/points-from.md")
3216                .expect("get")
3217                .is_some(),
3218            "nor one another producer's edge points *from*"
3219        );
3220        assert_eq!(applied.nodes_removed, 0);
3221    }
3222
3223    /// Removal takes the node's cached context with it. A bundle assembled for a
3224    /// node that no longer exists can only ever be served as a stale answer, and
3225    /// `node_context` has no foreign key to clean it up for us.
3226    #[test]
3227    fn removing_a_withdrawn_node_drops_its_cached_context() {
3228        let mut store = Store::open_in_memory().expect("open");
3229        let first = FactSet::new().with_node(
3230            Node::new("okf:acme/gone.md", NodeKind::Doc, "gone")
3231                .with_provenance(Provenance::ExternalInferred),
3232        );
3233        store
3234            .apply_import_layer("import:okf/acme", &first)
3235            .expect("first");
3236        store
3237            .context_cache_put("okf:acme/gone.md", "fp1", "{}")
3238            .expect("cache");
3239        assert_eq!(
3240            store.context_cache_keys().expect("keys"),
3241            vec!["okf:acme/gone.md".to_owned()]
3242        );
3243
3244        store
3245            .apply_import_layer("import:okf/acme", &FactSet::new())
3246            .expect("withdraw");
3247        assert!(
3248            store.context_cache_keys().expect("keys").is_empty(),
3249            "a cached bundle for a deleted node is a stale answer waiting to be served"
3250        );
3251    }
3252
3253    /// A persisted import layer is re-applied after a `rebuild` wipes the graph,
3254    /// so imported facts survive a code-changing sync.
3255    #[test]
3256    fn imports_survive_rebuild() {
3257        let mut store = Store::open_in_memory().expect("open");
3258        let derived = FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a.rs"));
3259        store.rebuild(&derived, Some("tree1")).expect("rebuild");
3260
3261        // apply_import_layer applies to the live graph and persists in one step.
3262        let applied = store
3263            .apply_import_layer("import:graphify", &graphify_layer("file:a.rs"))
3264            .expect("apply import");
3265        assert_eq!(applied.edges_applied, 1);
3266        assert_eq!(applied.edges_pruned, 0);
3267        assert_eq!(store.import_refs().expect("refs"), vec!["import:graphify"]);
3268
3269        // Simulate a code-changing sync: the derived graph is rebuilt (still has
3270        // file:a.rs), which drops the imported doc + edge from the live graph.
3271        store.rebuild(&derived, Some("tree2")).expect("rebuild2");
3272        assert!(store.get_node("graphify:doc1").expect("get").is_none());
3273
3274        // Re-applying imports restores them; nothing is pruned (target present).
3275        let applied = store.reapply_imports().expect("reapply");
3276        assert_eq!(applied.layers, 1);
3277        assert_eq!(applied.nodes, 1);
3278        assert_eq!(applied.edges_applied, 1);
3279        assert_eq!(applied.edges_pruned, 0);
3280        assert!(store.get_node("graphify:doc1").expect("get").is_some());
3281        assert_eq!(store.edges_from("graphify:doc1").expect("edges").len(), 1);
3282    }
3283
3284    /// When a sync removes an edge's target (e.g. a deleted file), re-applying
3285    /// **prunes** that stale cross-reference from the persisted layer — it is not
3286    /// kept and retried forever. The import node itself is preserved.
3287    #[test]
3288    fn reapply_prunes_stale_cross_references() {
3289        let mut store = Store::open_in_memory().expect("open");
3290        let derived = FactSet::new().with_node(Node::new("file:gone.rs", NodeKind::File, "g"));
3291        store.rebuild(&derived, Some("t1")).expect("rebuild");
3292        store
3293            .apply_import_layer("import:graphify", &graphify_layer("file:gone.rs"))
3294            .expect("import");
3295
3296        // Code-changing sync: file:gone.rs is deleted from the derived graph.
3297        store
3298            .rebuild(&FactSet::new(), Some("t2"))
3299            .expect("rebuild2");
3300        let applied = store.reapply_imports().expect("reapply");
3301        assert_eq!(applied.nodes, 1);
3302        assert_eq!(applied.edges_applied, 0);
3303        assert_eq!(
3304            applied.edges_pruned, 1,
3305            "the edge to the deleted file is pruned"
3306        );
3307        assert!(store.get_node("graphify:doc1").expect("get").is_some());
3308
3309        // The prune is durable: a second reapply finds nothing left to prune,
3310        // proving the stale edge was removed from the persisted layer.
3311        let again = store.reapply_imports().expect("reapply2");
3312        assert_eq!(again.edges_applied, 0);
3313        assert_eq!(again.edges_pruned, 0, "already pruned; not retried");
3314    }
3315
3316    /// `apply_import_layer` validates on import: a dangling edge in the incoming
3317    /// layer is dropped and never persisted.
3318    #[test]
3319    fn apply_import_layer_prunes_on_import() {
3320        let mut store = Store::open_in_memory().expect("open");
3321        let present = || FactSet::new().with_node(Node::new("file:a.rs", NodeKind::File, "a"));
3322        store.rebuild(&present(), Some("t")).expect("rebuild");
3323
3324        let layer = graphify_layer("file:a.rs").with_edge({
3325            // Points at a file that does not exist → pruned on import.
3326            let mut e = Edge::inferred("graphify:doc1", "file:ghost.rs", EdgeKind::References, 0.9);
3327            e.src_ref = Some("import:graphify".to_owned());
3328            e
3329        });
3330        let applied = store
3331            .apply_import_layer("import:graphify", &layer)
3332            .expect("import");
3333        assert_eq!(applied.edges_applied, 1);
3334        assert_eq!(applied.edges_pruned, 1);
3335
3336        // A rebuild + reapply confirms only the valid edge was persisted.
3337        store.rebuild(&present(), Some("t2")).expect("rebuild2");
3338        let re = store.reapply_imports().expect("reapply");
3339        assert_eq!(re.edges_applied, 1);
3340        assert_eq!(re.edges_pruned, 0, "ghost edge was not persisted");
3341    }
3342
3343    #[test]
3344    fn legacy_import_layer_nodes_are_retagged_non_derived() {
3345        // A layer persisted before nodes carried provenance: its node objects have
3346        // no `provenance` field, so serde defaults them to Derived. On reapply the
3347        // store must repair them — a Graphify node to Inferred, a lat node to
3348        // Authored — so a later derived-only sync never mistakes them for derived.
3349        let mut store = Store::open_in_memory().expect("open");
3350        let legacy = r#"{"nodes":[
3351            {"key":"graphify:doc1","kind":"doc","name":"d","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null},
3352            {"key":"lat:lat.md/a.md","kind":"doc","name":"a","path":null,"lang":null,"blob_hash":null,"span":null,"meta":null}
3353        ],"edges":[]}"#;
3354        store
3355            .conn
3356            .execute(
3357                "INSERT INTO imports (src_ref, facts) VALUES ('import:legacy', ?1)",
3358                [legacy],
3359            )
3360            .expect("seed legacy import row");
3361
3362        store.reapply_imports().expect("reapply");
3363
3364        let g = store
3365            .get_node("graphify:doc1")
3366            .expect("get")
3367            .expect("graphify node");
3368        assert_eq!(
3369            g.provenance,
3370            Provenance::Inferred,
3371            "graphify import node repaired to inferred"
3372        );
3373        let l = store
3374            .get_node("lat:lat.md/a.md")
3375            .expect("get")
3376            .expect("lat node");
3377        assert_eq!(
3378            l.provenance,
3379            Provenance::Authored,
3380            "lat import node repaired to authored"
3381        );
3382    }
3383
3384    /// `apply_import_layer` replaces the layer for a ref; `delete_import` removes.
3385    #[test]
3386    fn apply_import_replaces_and_delete_removes() {
3387        let mut store = Store::open_in_memory().expect("open");
3388        let a = FactSet::new().with_node(Node::new("graphify:x", NodeKind::Doc, "x"));
3389        let b = FactSet::new().with_node(Node::new("graphify:y", NodeKind::Doc, "y"));
3390        store.apply_import_layer("import:graphify", &a).expect("a");
3391        store.apply_import_layer("import:graphify", &b).expect("b");
3392        assert_eq!(
3393            store.import_refs().expect("refs").len(),
3394            1,
3395            "same ref replaced"
3396        );
3397        assert!(store.delete_import("import:graphify").expect("del"));
3398        assert!(store.import_refs().expect("refs").is_empty());
3399        assert!(!store.delete_import("import:graphify").expect("del again"));
3400    }
3401}