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 ¤t_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 ¤t_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}