Skip to main content

sinter_store/
store.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use redb::{
5    Database, MultimapTableDefinition, ReadableDatabase, ReadableMultimapTable, ReadableTable,
6    ReadableTableMetadata, TableDefinition,
7};
8use sinter_core::{
9    CorpusScope, Edge, FileFacts, Graph, Node, NodeId, Reference, UnresolvedReference,
10};
11
12use crate::error::StoreError;
13
14pub(crate) const NODES: TableDefinition<&str, &[u8]> = TableDefinition::new("nodes");
15/// Adjacency, keyed by src id. Values are postcard-encoded edges; the
16/// multimap holds parallel edges and dedups byte-identical ones, matching
17/// `Graph` semantics.
18pub(crate) const OUT_EDGES: MultimapTableDefinition<&str, &[u8]> =
19    MultimapTableDefinition::new("out_edges");
20/// Reverse adjacency, keyed by dst id — reverse blast radius reads this.
21pub(crate) const IN_EDGES: MultimapTableDefinition<&str, &[u8]> =
22    MultimapTableDefinition::new("in_edges");
23/// Unresolved references, keyed by file. First-class outcome (R2): stored
24/// and countable, replaced per file on re-resolution.
25pub(crate) const UNRESOLVED: MultimapTableDefinition<&str, &[u8]> =
26    MultimapTableDefinition::new("unresolved");
27/// Per-file extraction truth, content-addressed. Every derived table
28/// (nodes, edges, indexes) rebuilds from here for exactly the changed files.
29pub(crate) const FILE_FACTS: TableDefinition<&str, &[u8]> = TableDefinition::new("file_facts");
30/// file -> content hash, decoded without touching the facts blob.
31pub(crate) const FILE_HASH: TableDefinition<&str, &str> = TableDefinition::new("file_hash");
32/// Repo-relative file -> corpus role. Nodes inherit their file's scope at
33/// query time, avoiding duplicated metadata in every node blob.
34pub(crate) const FILE_SCOPE: TableDefinition<&str, &str> = TableDefinition::new("file_scope");
35/// node id -> corpus role override for nodes whose role differs from their
36/// file's (inline test modules, generated banners). Sparse; see `scope`.
37pub(crate) const NODE_SCOPE: TableDefinition<&str, &str> = TableDefinition::new("node_scope");
38/// reference name -> files containing a reference with that name; the
39/// resolution invalidation index.
40pub(crate) const NAME_REFS: MultimapTableDefinition<&str, &str> =
41    MultimapTableDefinition::new("name_refs");
42/// symbol name -> interned node ids, exact-match query index.
43pub(crate) const NAME_NODES: MultimapTableDefinition<&str, u32> =
44    MultimapTableDefinition::new("name_nodes");
45/// lowercased trigram -> interned node ids, fuzzy query index.
46pub(crate) const TRIGRAMS: MultimapTableDefinition<&str, u32> =
47    MultimapTableDefinition::new("trigrams");
48/// lowercased word -> interned node ids: recall index over name subwords,
49/// doc, signature, and path segments (see `search::node_tokens`).
50/// Values are interned (u32) — node-id strings repeated dozens of times
51/// were 58% of stored bytes before interning (bench finding).
52pub(crate) const TOKENS_WORDS: MultimapTableDefinition<&str, u32> =
53    MultimapTableDefinition::new("tokens_words");
54/// lowercased body-only word -> interned node ids (see
55/// `FileFacts::body_terms`): evidence for concept questions whose terms
56/// appear only inside a function body.
57pub(crate) const BODY_TERMS: MultimapTableDefinition<&str, u32> =
58    MultimapTableDefinition::new("body_terms");
59/// Interner: u32 -> node id string and its reverse. Index tables store the
60/// u32; readers translate back on materialization.
61pub(crate) const INTERN: TableDefinition<u32, &str> = TableDefinition::new("intern");
62pub(crate) const INTERN_REV: TableDefinition<&str, u32> = TableDefinition::new("intern_rev");
63/// file -> import references only (compact). Re-export chain walking needs
64/// every file's imports without decoding full facts corpus-wide.
65pub(crate) const IMPORTS: MultimapTableDefinition<&str, &[u8]> =
66    MultimapTableDefinition::new("imports");
67/// Single-row schema stamp; a mismatch on open wipes the database (facts
68/// are derivable, a stale-format db is not worth migrating).
69pub(crate) const META: TableDefinition<&str, u32> = TableDefinition::new("meta");
70/// Fingerprints of non-source resolution inputs (SCIP index, manifest
71/// module roots), keyed by input kind. A change re-resolves the corpus
72/// without re-extracting. Additive table — absent in older dbs, which
73/// makes the first build after upgrade re-resolve once.
74pub(crate) const RESOLVE_META: TableDefinition<&str, &str> = TableDefinition::new("resolve_meta");
75/// Single-row crash-recovery intent: the union of update deltas not yet
76/// followed by a completed resolution pass (see `Store::update_files` /
77/// `Store::clear_pending_delta`). Additive table — absent in older dbs,
78/// read as empty.
79pub(crate) const PENDING: TableDefinition<&str, &[u8]> = TableDefinition::new("pending_delta");
80// v10: explicit per-file corpus scope. Older graphs are derived state and
81// rebuild so every query observes classified metadata, never a mixed corpus.
82// v11: node-level scope overrides (node_scope table, FileFacts.scopes).
83// v12: body-identifier terms (body_terms table, FileFacts.body_terms).
84// v13: SQL-specific symbol kinds and read/write relations extend postcard
85// enums; derived graphs rebuild before decoding their old discriminants.
86const SCHEMA_VERSION: u32 = 13;
87
88/// Per-file freshness record: content hash plus the stat identity it was
89/// hashed at. On Unix the identity combines modification and change time,
90/// so rewriting bytes while restoring mtime cannot hide an edit. Encoded
91/// in FILE_HASH as `hash|identity_nanos|len`; old mtime-only rows decode
92/// but miss once against the new identity and are refreshed.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct FileStamp {
95    pub hash: String,
96    pub identity_nanos: u128,
97    pub len: u64,
98}
99
100impl FileStamp {
101    pub(crate) fn encode(&self) -> String {
102        format!("{}|{}|{}", self.hash, self.identity_nanos, self.len)
103    }
104
105    pub(crate) fn decode(value: &str) -> Self {
106        let mut parts = value.split('|');
107        let hash = parts.next().unwrap_or_default().to_string();
108        Self {
109            hash,
110            identity_nanos: parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
111            // len 0 never matches: scan only stamps non-empty files.
112            len: parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
113        }
114    }
115}
116
117impl Store {
118    /// The schema version this binary writes.
119    pub const CURRENT_SCHEMA: u32 = SCHEMA_VERSION;
120
121    /// Read a database's schema stamp without opening for write and
122    /// without triggering the wipe-on-mismatch in [`Store::create`].
123    pub fn schema_of(path: impl AsRef<Path>) -> Result<Option<u32>, StoreError> {
124        let path = path.as_ref();
125        match Self::open_read_only(path) {
126            Ok(store) => store.schema(),
127            // Unclean shutdown: only a writable open can repair it.
128            Err(_) => Self::open(path)?.schema(),
129        }
130    }
131}
132
133/// Persistent graph store. Point queries never load the whole graph.
134pub struct Store {
135    pub(crate) db: Db,
136}
137
138/// A store is opened for writing or for reading. redb's writable
139/// `Database` rewrites the file header on open and persists allocator
140/// state on close — a read-only query that opens one mutates 16MB of
141/// content-addressed graph and takes the exclusive lock while doing it.
142/// `ReadOnlyDatabase` takes a shared lock and writes nothing at all.
143pub(crate) enum Db {
144    Writable(Database),
145    ReadOnly(redb::ReadOnlyDatabase),
146}
147
148impl Db {
149    pub(crate) fn begin_read(&self) -> Result<redb::ReadTransaction, redb::TransactionError> {
150        match self {
151            Self::Writable(db) => db.begin_read(),
152            Self::ReadOnly(db) => db.begin_read(),
153        }
154    }
155
156    pub(crate) fn begin_write(&self) -> Result<redb::WriteTransaction, StoreError> {
157        match self {
158            Self::Writable(db) => Ok(db.begin_write()?),
159            Self::ReadOnly(_) => Err(StoreError::ReadOnly),
160        }
161    }
162
163    fn compact(&mut self) -> Result<bool, StoreError> {
164        match self {
165            Self::Writable(db) => Ok(db.compact()?),
166            Self::ReadOnly(_) => Err(StoreError::ReadOnly),
167        }
168    }
169}
170
171/// redb opens are exclusive, so a query racing a short-lived build (or a
172/// queue of sibling queries — parallel agents fan out dozens) sees
173/// AlreadyOpen. Backoff rides out the queue; a handle held by a
174/// long-lived process still errors after the budget. Windows gets a
175/// longer budget: file locks release lazily after process exit (handle
176/// teardown + AV scans), so a waiter there can outlive 5s of real
177/// contention that unix clears instantly.
178pub(crate) fn open_retrying<D>(
179    path: &Path,
180    open: fn(&Path) -> Result<D, redb::DatabaseError>,
181) -> Result<D, redb::DatabaseError> {
182    let budget = std::time::Duration::from_secs(if cfg!(windows) { 20 } else { 5 });
183    let started = std::time::Instant::now();
184    let mut delay = std::time::Duration::from_millis(10);
185    loop {
186        match open(path) {
187            Err(redb::DatabaseError::DatabaseAlreadyOpen) if started.elapsed() < budget => {
188                std::thread::sleep(delay);
189                delay = (delay * 2).min(std::time::Duration::from_millis(200));
190            }
191            other => return other,
192        }
193    }
194}
195
196/// Create (or open) any redb database under sinter's contention policy —
197/// the one named owner of open-retry behavior for auxiliary databases
198/// (workspace link store) that are not the repository [`Store`].
199pub fn create_database(path: &Path) -> Result<Database, StoreError> {
200    Ok(open_retrying(path, |p| Database::create(p))?)
201}
202
203impl Store {
204    /// Create or open the database and ensure all tables exist. An
205    /// existing database with a different schema version is deleted and
206    /// recreated — the next build re-derives everything from source.
207    pub fn create(path: impl AsRef<Path>) -> Result<Self, StoreError> {
208        let path = path.as_ref();
209        if path.exists() {
210            let db = open_retrying(path, |p| Database::open(p))?;
211            let txn = db.begin_read()?;
212            let stored = match txn.open_table(META) {
213                Ok(table) => table.get("schema")?.map(|g| g.value()),
214                Err(redb::TableError::TableDoesNotExist(_)) => None,
215                Err(e) => return Err(e.into()),
216            };
217            // Older schema: wipe and rebuild forward from source. Newer:
218            // refuse — an outdated binary must never destroy a graph it
219            // cannot rebuild equivalently.
220            if let Some(v) = stored
221                && v > SCHEMA_VERSION
222            {
223                return Err(StoreError::NewerSchema {
224                    stored: v,
225                    supported: SCHEMA_VERSION,
226                });
227            }
228            if stored != Some(SCHEMA_VERSION) {
229                drop(txn);
230                drop(db);
231                std::fs::remove_file(path).map_err(StoreError::Reset)?;
232            }
233        }
234        let store = Self {
235            db: Db::Writable(open_retrying(path, |p| Database::create(p))?),
236        };
237        let txn = store.db.begin_write()?;
238        {
239            let mut meta = txn.open_table(META)?;
240            meta.insert("schema", SCHEMA_VERSION)?;
241            drop(meta);
242            txn.open_table(NODES)?;
243            txn.open_table(FILE_FACTS)?;
244            txn.open_table(FILE_HASH)?;
245            txn.open_table(FILE_SCOPE)?;
246            txn.open_table(NODE_SCOPE)?;
247            txn.open_multimap_table(OUT_EDGES)?;
248            txn.open_multimap_table(IN_EDGES)?;
249            txn.open_multimap_table(UNRESOLVED)?;
250            txn.open_multimap_table(NAME_REFS)?;
251            txn.open_multimap_table(NAME_NODES)?;
252            txn.open_multimap_table(TRIGRAMS)?;
253            txn.open_multimap_table(TOKENS_WORDS)?;
254            txn.open_multimap_table(BODY_TERMS)?;
255            txn.open_multimap_table(IMPORTS)?;
256            txn.open_table(INTERN)?;
257            txn.open_table(INTERN_REV)?;
258            txn.open_table(RESOLVE_META)?;
259            txn.open_table(PENDING)?;
260        }
261        txn.commit()?;
262        Ok(store)
263    }
264
265    pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
266        Ok(Self {
267            db: Db::Writable(open_retrying(path.as_ref(), |p| Database::open(p))?),
268        })
269    }
270
271    /// Open for reading only: shared lock, and not one byte written — no
272    /// header stamp on open, no allocator flush on close. Every read verb
273    /// wants this. Errors if the database needs repair (an unclean
274    /// shutdown); the caller falls back to [`Store::open`], which repairs.
275    pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self, StoreError> {
276        Ok(Self {
277            db: Db::ReadOnly(open_retrying(path.as_ref(), |p| {
278                redb::ReadOnlyDatabase::open(p)
279            })?),
280        })
281    }
282
283    /// True when this handle cannot write; the build path upgrades to a
284    /// writable handle only once it has real work.
285    pub fn is_read_only(&self) -> bool {
286        matches!(self.db, Db::ReadOnly(_))
287    }
288
289    /// The schema stamp of this open database, if any.
290    pub fn schema(&self) -> Result<Option<u32>, StoreError> {
291        let txn = self.db.begin_read()?;
292        match txn.open_table(META) {
293            Ok(table) => Ok(table.get("schema")?.map(|g| g.value())),
294            Err(redb::TableError::TableDoesNotExist(_)) => Ok(None),
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    /// Persist a whole graph in one transaction (test/export convenience;
300    /// the incremental path goes through `update_files`).
301    pub fn write_graph(&self, graph: &Graph) -> Result<(), StoreError> {
302        let txn = self.db.begin_write()?;
303        {
304            let mut nodes = txn.open_table(NODES)?;
305            let mut scopes = txn.open_table(FILE_SCOPE)?;
306            let mut out = txn.open_multimap_table(OUT_EDGES)?;
307            let mut inn = txn.open_multimap_table(IN_EDGES)?;
308            for node in graph.nodes() {
309                nodes.insert(node.id.as_str(), postcard::to_allocvec(node)?.as_slice())?;
310                scopes.insert(
311                    node.file.as_str(),
312                    CorpusScope::classify_path(&node.file).as_str(),
313                )?;
314            }
315            for edge in graph.edges() {
316                let bytes = postcard::to_allocvec(edge)?;
317                out.insert(edge.src.as_str(), bytes.as_slice())?;
318                inn.insert(edge.dst.as_str(), bytes.as_slice())?;
319            }
320        }
321        txn.commit()?;
322        Ok(())
323    }
324
325    /// Total stored unresolved references.
326    pub fn unresolved_count(&self) -> Result<u64, StoreError> {
327        let txn = self.db.begin_read()?;
328        let table = match txn.open_multimap_table(UNRESOLVED) {
329            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
330            other => other?,
331        };
332        Ok(table.len()?)
333    }
334
335    /// Every stored unresolved reference — cross-repo boundary resolution
336    /// input (a workspace resolves these against other members' symbols).
337    pub fn all_unresolved(&self) -> Result<Vec<Reference>, StoreError> {
338        Ok(self
339            .all_unresolved_details()?
340            .into_iter()
341            .map(|u| u.reference)
342            .collect())
343    }
344
345    /// Every unresolved outcome including why the graph could not prove a
346    /// target. Query surfaces use this; workspace linking consumes the raw
347    /// references through [`Store::all_unresolved`].
348    pub fn all_unresolved_details(&self) -> Result<Vec<UnresolvedReference>, StoreError> {
349        let txn = self.db.begin_read()?;
350        let table = match txn.open_multimap_table(UNRESOLVED) {
351            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
352            other => other?,
353        };
354        let mut refs = Vec::new();
355        for entry in table.iter()? {
356            let (_, values) = entry?;
357            for guard in values {
358                refs.push(postcard::from_bytes(guard?.value())?);
359            }
360        }
361        Ok(refs)
362    }
363
364    /// Stored unresolved references, optionally narrowed to one file
365    /// and/or a name (final-segment match, same rule as
366    /// [`Store::unresolved_named`]). The `sinter unresolved` listing.
367    pub fn unresolved_refs(
368        &self,
369        file: Option<&str>,
370        name: Option<&str>,
371    ) -> Result<Vec<Reference>, StoreError> {
372        let mut refs = match file {
373            Some(file) => self.references_in(file)?,
374            None => self.all_unresolved()?,
375        };
376        if let Some(name) = name {
377            refs.retain(|r| name_tail_matches(&r.name, name));
378        }
379        Ok(refs)
380    }
381
382    pub fn unresolved_details(
383        &self,
384        file: Option<&str>,
385        name: Option<&str>,
386    ) -> Result<Vec<UnresolvedReference>, StoreError> {
387        let mut refs = match file {
388            Some(file) => self.unresolved_details_in(file)?,
389            None => self.all_unresolved_details()?,
390        };
391        if let Some(name) = name {
392            refs.retain(|u| name_tail_matches(&u.reference.name, name));
393        }
394        Ok(refs)
395    }
396
397    /// Unresolved references recorded for one file.
398    pub fn references_in(&self, file: &str) -> Result<Vec<Reference>, StoreError> {
399        Ok(self
400            .unresolved_details_in(file)?
401            .into_iter()
402            .map(|u| u.reference)
403            .collect())
404    }
405
406    pub fn unresolved_details_in(
407        &self,
408        file: &str,
409    ) -> Result<Vec<UnresolvedReference>, StoreError> {
410        let txn = self.db.begin_read()?;
411        let table = match txn.open_multimap_table(UNRESOLVED) {
412            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
413            other => other?,
414        };
415        let mut refs = Vec::new();
416        for guard in table.get(file)? {
417            refs.push(postcard::from_bytes(guard?.value())?);
418        }
419        Ok(refs)
420    }
421
422    /// The fingerprint a non-source resolution input (key: "scip",
423    /// "module_roots") was last resolved against, if any.
424    pub fn resolve_fingerprint(&self, key: &str) -> Result<Option<String>, StoreError> {
425        let txn = self.db.begin_read()?;
426        let table = match txn.open_table(RESOLVE_META) {
427            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
428            other => other?,
429        };
430        Ok(table.get(key)?.map(|g| g.value().to_string()))
431    }
432
433    /// Idempotent: an unchanged fingerprint opens no write transaction,
434    /// keeping a clean build write-free (parallel readers never queue
435    /// behind redb's exclusive writer for a no-op).
436    pub fn set_resolve_fingerprint(
437        &self,
438        key: &str,
439        fingerprint: Option<&str>,
440    ) -> Result<(), StoreError> {
441        if self.resolve_fingerprint(key)?.as_deref() == fingerprint {
442            return Ok(());
443        }
444        let txn = self.db.begin_write()?;
445        {
446            let mut table = txn.open_table(RESOLVE_META)?;
447            match fingerprint {
448                Some(f) => {
449                    table.insert(key, f)?;
450                }
451                None => {
452                    table.remove(key)?;
453                }
454            }
455        }
456        txn.commit()?;
457        Ok(())
458    }
459
460    /// Unresolved references whose written name ends in this name — the
461    /// honest-empty signal for blast-radius queries: a nonzero count means
462    /// the graph may be missing dependents of a symbol with that name.
463    pub fn unresolved_named(&self, name: &str) -> Result<usize, StoreError> {
464        let files = self.ref_files(&std::collections::BTreeSet::from([name.to_string()]))?;
465        let mut count = 0;
466        for file in files {
467            count += self
468                .references_in(&file)?
469                .iter()
470                .filter(|r| name_tail_matches(&r.name, name))
471                .count();
472        }
473        Ok(count)
474    }
475
476    pub fn node(&self, id: &NodeId) -> Result<Option<Node>, StoreError> {
477        let txn = self.db.begin_read()?;
478        let table = txn.open_table(NODES)?;
479        match table.get(id.as_str())? {
480            Some(guard) => Ok(Some(postcard::from_bytes(guard.value())?)),
481            None => Ok(None),
482        }
483    }
484
485    pub fn node_count(&self) -> Result<u64, StoreError> {
486        let txn = self.db.begin_read()?;
487        Ok(txn.open_table(NODES)?.len()?)
488    }
489
490    pub fn edge_count(&self) -> Result<u64, StoreError> {
491        let txn = self.db.begin_read()?;
492        Ok(txn.open_multimap_table(OUT_EDGES)?.len()?)
493    }
494
495    /// Edges leaving `id`.
496    pub fn out_edges(&self, id: &NodeId) -> Result<Vec<Edge>, StoreError> {
497        self.adjacent(OUT_EDGES, id)
498    }
499
500    /// Edges arriving at `id`.
501    pub fn in_edges(&self, id: &NodeId) -> Result<Vec<Edge>, StoreError> {
502        self.adjacent(IN_EDGES, id)
503    }
504
505    /// Incoming edges for several nodes under one read transaction. Query
506    /// ranking uses this instead of opening one redb snapshot per candidate.
507    pub fn in_edges_many(&self, ids: &[NodeId]) -> Result<HashMap<NodeId, Vec<Edge>>, StoreError> {
508        self.adjacent_many(IN_EDGES, ids)
509    }
510
511    /// Outgoing edges for several nodes under one read transaction.
512    pub fn out_edges_many(&self, ids: &[NodeId]) -> Result<HashMap<NodeId, Vec<Edge>>, StoreError> {
513        self.adjacent_many(OUT_EDGES, ids)
514    }
515
516    fn adjacent_many(
517        &self,
518        table: MultimapTableDefinition<&str, &[u8]>,
519        ids: &[NodeId],
520    ) -> Result<HashMap<NodeId, Vec<Edge>>, StoreError> {
521        let txn = self.db.begin_read()?;
522        let table = txn.open_multimap_table(table)?;
523        let mut found = HashMap::with_capacity(ids.len());
524        for id in ids {
525            let mut edges = Vec::new();
526            for guard in table.get(id.as_str())? {
527                edges.push(postcard::from_bytes(guard?.value())?);
528            }
529            found.insert(id.clone(), edges);
530        }
531        Ok(found)
532    }
533
534    fn adjacent(
535        &self,
536        table: MultimapTableDefinition<&str, &[u8]>,
537        id: &NodeId,
538    ) -> Result<Vec<Edge>, StoreError> {
539        let txn = self.db.begin_read()?;
540        let table = txn.open_multimap_table(table)?;
541        let mut edges = Vec::new();
542        for guard in table.get(id.as_str())? {
543            edges.push(postcard::from_bytes(guard?.value())?);
544        }
545        Ok(edges)
546    }
547
548    /// (file, stamp) for every stored file — the changed-set diff base.
549    pub fn file_hashes(&self) -> Result<Vec<(String, FileStamp)>, StoreError> {
550        let txn = self.db.begin_read()?;
551        let table = txn.open_table(FILE_HASH)?;
552        let mut out = Vec::new();
553        for entry in table.iter()? {
554            let (k, v) = entry?;
555            out.push((k.value().to_string(), FileStamp::decode(v.value())));
556        }
557        Ok(out)
558    }
559
560    /// Persist repository classification for already indexed files and
561    /// return how many rows changed. Classification is path-only, so the
562    /// caller passes every known file on every build: a classifier or
563    /// `.sinter.toml` change re-stamps unchanged files too. Clean builds
564    /// remain write-free when every row is unchanged.
565    pub fn set_file_scopes(&self, rows: &[(String, CorpusScope)]) -> Result<usize, StoreError> {
566        if rows.is_empty() {
567            return Ok(0);
568        }
569        let existing = self.file_scopes()?;
570        let stale: Vec<&(String, CorpusScope)> = rows
571            .iter()
572            .filter(|(file, scope)| existing.get(file) != Some(scope))
573            .collect();
574        if stale.is_empty() {
575            return Ok(0);
576        }
577        let txn = self.db.begin_write()?;
578        {
579            let mut table = txn.open_table(FILE_SCOPE)?;
580            for (file, scope) in &stale {
581                table.insert(file.as_str(), scope.as_str())?;
582            }
583        }
584        txn.commit()?;
585        Ok(stale.len())
586    }
587
588    /// Complete persisted scope map. Unknown legacy/malformed values fall
589    /// back to conservative path classification instead of hiding nodes.
590    pub fn file_scopes(&self) -> Result<HashMap<String, CorpusScope>, StoreError> {
591        let txn = self.db.begin_read()?;
592        let table = txn.open_table(FILE_SCOPE)?;
593        let mut scopes = HashMap::new();
594        for entry in table.iter()? {
595            let (file, scope) = entry?;
596            let file = file.value().to_string();
597            scopes.insert(
598                file.clone(),
599                CorpusScope::from_str_opt(scope.value())
600                    .unwrap_or_else(|| CorpusScope::classify_path(&file)),
601            );
602        }
603        Ok(scopes)
604    }
605
606    pub fn file_scope(&self, file: &str) -> Result<CorpusScope, StoreError> {
607        let txn = self.db.begin_read()?;
608        let table = txn.open_table(FILE_SCOPE)?;
609        Ok(table
610            .get(file)?
611            .and_then(|guard| CorpusScope::from_str_opt(guard.value()))
612            .unwrap_or_else(|| CorpusScope::classify_path(file)))
613    }
614
615    pub fn facts(&self, file: &str) -> Result<Option<FileFacts>, StoreError> {
616        let txn = self.db.begin_read()?;
617        let table = txn.open_table(FILE_FACTS)?;
618        match table.get(file)? {
619            Some(guard) => Ok(Some(crate::update::decode_facts(guard.value())?)),
620            None => Ok(None),
621        }
622    }
623
624    /// Files whose most recently extracted syntax tree contained errors.
625    /// Coverage reporting uses the complete persisted set, not only files
626    /// changed by the latest incremental pass.
627    pub fn syntax_error_files(&self) -> Result<Vec<String>, StoreError> {
628        let txn = self.db.begin_read()?;
629        let table = txn.open_table(FILE_FACTS)?;
630        let mut files = Vec::new();
631        for entry in table.iter()? {
632            let (file, bytes) = entry?;
633            let facts = crate::update::decode_facts(bytes.value())?;
634            if facts.has_syntax_errors {
635                files.push(file.value().to_string());
636            }
637        }
638        files.sort();
639        Ok(files)
640    }
641
642    /// Reclaim free pages. Worth running after bulk rebuilds; skipped on
643    /// incremental updates (it rewrites the file and would blow the <1s
644    /// one-file-edit budget). redb compaction is iterative — repeat until
645    /// it reports no further progress (bounded).
646    pub fn compact(&mut self) -> Result<bool, StoreError> {
647        let mut any = false;
648        for _ in 0..16 {
649            if !self.db.compact()? {
650                break;
651            }
652            any = true;
653        }
654        Ok(any)
655    }
656
657    /// Every stored import reference — re-export chain-walking input.
658    pub fn all_imports(&self) -> Result<Vec<Reference>, StoreError> {
659        let txn = self.db.begin_read()?;
660        let table = txn.open_multimap_table(IMPORTS)?;
661        let mut refs = Vec::new();
662        for entry in table.iter()? {
663            let (_, values) = entry?;
664            for guard in values {
665                refs.push(postcard::from_bytes(guard?.value())?);
666            }
667        }
668        Ok(refs)
669    }
670
671    /// Every stored node — resolution index input. Compact scan of the node
672    /// table; queries never need this.
673    pub fn all_nodes(&self) -> Result<Vec<Node>, StoreError> {
674        let txn = self.db.begin_read()?;
675        let table = txn.open_table(NODES)?;
676        let mut nodes = Vec::new();
677        for entry in table.iter()? {
678            nodes.push(postcard::from_bytes(entry?.1.value())?);
679        }
680        Ok(nodes)
681    }
682
683    /// Non-`Contains` in-degree per node id, streamed straight off the
684    /// IN_EDGES table — hub ranking without materializing (and
685    /// re-validating) the whole graph. Nodes with zero such in-edges are
686    /// omitted.
687    pub fn in_degrees(&self) -> Result<Vec<(String, usize)>, StoreError> {
688        let txn = self.db.begin_read()?;
689        let table = txn.open_multimap_table(IN_EDGES)?;
690        let mut out = Vec::new();
691        for entry in table.iter()? {
692            let (key, values) = entry?;
693            let mut n = 0usize;
694            for guard in values {
695                let edge: Edge = postcard::from_bytes(guard?.value())?;
696                if edge.relation != sinter_core::Relation::Contains {
697                    n += 1;
698                }
699            }
700            if n > 0 {
701                out.push((key.value().to_string(), n));
702            }
703        }
704        Ok(out)
705    }
706
707    /// Rebuild the full in-memory graph, re-validating every invariant.
708    /// Export/debug path only — queries must not need this.
709    pub fn read_graph(&self) -> Result<Graph, StoreError> {
710        let txn = self.db.begin_read()?;
711        let mut graph = Graph::new();
712        {
713            let nodes = txn.open_table(NODES)?;
714            for entry in nodes.iter()? {
715                let (_, value) = entry?;
716                graph.add_node(postcard::from_bytes(value.value())?)?;
717            }
718        }
719        {
720            let out = txn.open_multimap_table(OUT_EDGES)?;
721            for entry in out.iter()? {
722                let (_, values) = entry?;
723                for guard in values {
724                    graph.add_edge(postcard::from_bytes(guard?.value())?)?;
725                }
726            }
727        }
728        Ok(graph)
729    }
730}
731
732/// Does a written reference name (`acme_common::connect_grpc_channel`,
733/// `pkg.Func`) end at exactly this name?
734fn name_tail_matches(written: &str, name: &str) -> bool {
735    // Exact final-segment equality across every language's separators —
736    // boundary substring matching over-counted short common names
737    // (`run`, `install`) into the honest-empty note.
738    let tail = written.rsplit("::").next().unwrap_or(written);
739    let tail = tail.rsplit(['/', '.']).next().unwrap_or(tail);
740    tail == name
741}