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