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