Skip to main content

aft/views/
materialization.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::Path;
3use std::time::Duration;
4
5use rusqlite::{params, Connection, OptionalExtension};
6
7use crate::callgraph_store::{
8    initialize_schema, join, set_meta_ready, CallGraphStoreError, Result, PROVENANCE_TREESITTER,
9};
10
11/// Cold materialization, also used to upgrade databases without diff metadata.
12pub fn materialize_manifest_view_database(
13    database_path: &Path,
14    callgraph_blob_database: &Path,
15    manifest: &crate::views::Manifest,
16) -> Result<()> {
17    materialize(database_path, callgraph_blob_database, manifest, None).map(|_| ())
18}
19
20/// Counts affected graph and dependency-cache rows, excluding readiness and metadata.
21/// Replacements count both the deletion and insertion; relinks include refs and edges.
22/// Resolution counters describe work performed, not SQLite writes.
23#[derive(Debug, Default, Clone, PartialEq, Eq)]
24pub struct MaterializeStats {
25    pub deleted: usize,
26    pub inserted: usize,
27    pub relinked_deleted: usize,
28    pub relinked_inserted: usize,
29    pub dependency_deleted: usize,
30    pub dependency_inserted: usize,
31    pub dependent_files: usize,
32    pub resolved_files: usize,
33    pub resolved_refs: usize,
34    pub resolved_bindings: usize,
35    pub rebuilt_surface_entries: usize,
36    pub decoded_caller_blobs: usize,
37    pub full_resolution: bool,
38    pub unattributed_callers: usize,
39}
40
41impl MaterializeStats {
42    pub fn graph_rows_written(&self) -> usize {
43        self.deleted + self.inserted + self.relinked_deleted + self.relinked_inserted
44    }
45
46    pub fn rows_written(&self) -> usize {
47        self.graph_rows_written() + self.dependency_deleted + self.dependency_inserted
48    }
49}
50
51/// Apply to a private copy of the base generation's database, never a published file.
52/// The caller owns copying, durability and pointer publication. All graph changes and
53/// the fingerprint commit atomically; errors roll back to the supplied base. An old
54/// binding-cache schema takes the cold path; a mismatched manifest is refused.
55pub fn apply_manifest_diff(
56    database_path: &Path,
57    base_manifest: &crate::views::Manifest,
58    new_manifest: &crate::views::Manifest,
59    callgraph_blob_database: &Path,
60) -> Result<MaterializeStats> {
61    apply_manifest_diff_profiled(
62        database_path,
63        base_manifest,
64        new_manifest,
65        callgraph_blob_database,
66    )
67    .map(|(stats, _)| stats)
68}
69
70pub(crate) fn apply_manifest_diff_profiled(
71    database_path: &Path,
72    base_manifest: &crate::views::Manifest,
73    new_manifest: &crate::views::Manifest,
74    callgraph_blob_database: &Path,
75) -> Result<(MaterializeStats, profile::PhaseTimings)> {
76    materialize(
77        database_path,
78        callgraph_blob_database,
79        new_manifest,
80        Some(base_manifest),
81    )
82}
83
84pub(crate) mod profile;
85mod resolution_facts;
86
87const MATERIALIZATION_VERSION: &str = "5";
88
89fn fingerprint(manifest: &crate::views::Manifest) -> Result<String> {
90    let bytes = manifest
91        .to_json_bytes()
92        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
93    Ok(blake3::hash(&bytes).to_hex().to_string())
94}
95
96fn manifest_callgraph_equivalent(
97    left: &crate::views::Manifest,
98    right: &crate::views::Manifest,
99) -> bool {
100    let mut left = left.entries();
101    let mut right = right.entries();
102    loop {
103        match (left.next(), right.next()) {
104            (None, None) => return true,
105            (Some((left_path, left_entry)), Some((right_path, right_entry)))
106                if left_path == right_path
107                    && match (left_entry, right_entry) {
108                        (
109                            crate::views::ManifestEntry::Regular {
110                                mode: left_mode,
111                                planes: left_planes,
112                                resolution_input: left_resolution_input,
113                            },
114                            crate::views::ManifestEntry::Regular {
115                                mode: right_mode,
116                                planes: right_planes,
117                                resolution_input: right_resolution_input,
118                            },
119                        ) => {
120                            left_mode == right_mode
121                                && left_resolution_input == right_resolution_input
122                                && left_planes.callgraph == right_planes.callgraph
123                        }
124                        _ => left_entry == right_entry,
125                    } => {}
126            _ => return false,
127        }
128    }
129}
130
131fn materialize(
132    database_path: &Path,
133    callgraph_blob_database: &Path,
134    manifest: &crate::views::Manifest,
135    mut base: Option<&crate::views::Manifest>,
136) -> Result<(MaterializeStats, profile::PhaseTimings)> {
137    let mut profile = profile::PhaseTimer::new(if base.is_some() {
138        "incremental"
139    } else {
140        "cold"
141    });
142    let mut connection = if base.is_some() {
143        Connection::open_with_flags(database_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?
144    } else {
145        Connection::open(database_path)?
146    };
147    configure_materialization_connection(&connection)?;
148    if base.is_none() {
149        initialize_schema(&connection)?;
150    }
151    let transaction =
152        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
153    if let Some(base_manifest) = base {
154        let recorded: Option<String> = transaction
155            .query_row(
156                "SELECT v FROM meta WHERE k = 'view_manifest_fingerprint'",
157                [],
158                |row| row.get(0),
159            )
160            .optional()?;
161        let version: Option<String> = transaction
162            .query_row(
163                "SELECT v FROM meta WHERE k = 'view_materialization_version'",
164                [],
165                |row| row.get(0),
166            )
167            .optional()?;
168        if recorded.is_none() && version.is_none() {
169            // Cloned generations written before diff metadata existed must be
170            // rebuilt; their graph cannot safely seed dependency invalidation.
171            base = None;
172        } else if recorded.as_deref() != Some(fingerprint(base_manifest)?.as_str()) {
173            return Err(CallGraphStoreError::Unavailable(
174                "derived manifest fingerprint mismatch; cold materialization required".into(),
175            ));
176        } else if version.as_deref() != Some(MATERIALIZATION_VERSION) {
177            base = None;
178        } else if base_manifest == manifest {
179            profile.finish("load_bindings_select");
180            return Ok((MaterializeStats::default(), profile.into_timings()));
181        } else if manifest_callgraph_equivalent(base_manifest, manifest) {
182            profile.finish("load_bindings_select");
183            transaction.execute(
184                "INSERT OR REPLACE INTO meta(k, v) VALUES('view_manifest_fingerprint', ?1)",
185                [fingerprint(manifest)?],
186            )?;
187            transaction.commit()?;
188            profile.finish("commit");
189            return Ok((MaterializeStats::default(), profile.into_timings()));
190        }
191    }
192    transaction.execute_batch("CREATE TABLE IF NOT EXISTS view_bindings (file_path TEXT PRIMARY KEY, payload TEXT NOT NULL)")?;
193    let changed = base.map(|base| {
194        base.entries()
195            .chain(manifest.entries())
196            .filter(|(path, _)| base.get(path) != manifest.get(path))
197            .map(|(path, _)| path.as_bytes().to_vec())
198            .collect::<BTreeSet<_>>()
199    });
200    let cached = if base.is_some() {
201        load_bindings(&transaction)?
202    } else {
203        BTreeMap::new()
204    };
205    let blob_connection = Connection::open(callgraph_blob_database)?;
206    let mut fact_invalidated = BTreeSet::new();
207    let mut fallback_count = 0;
208    let selected = match (&changed, base) {
209        (Some(changed), Some(base)) if !requires_full_resolution(base, manifest, changed) => {
210            let diff = resolution_facts::diff_inputs(base, manifest, changed, &blob_connection)?;
211            for (caller, binding) in &cached {
212                if !binding.consulted_facts.is_disjoint(&diff.changed) {
213                    fact_invalidated.insert(caller.clone());
214                }
215                if diff.inputs_changed && binding.unattributed {
216                    fallback_count += 1;
217                }
218            }
219            if diff.unknown != 0 || fallback_count != 0 {
220                fallback_count += diff.unknown;
221                log::warn!("views materialization: full resolution (reason=unattributed_reads count={fallback_count})");
222                None
223            } else {
224                // Configuration bytes are invalidated by their consulted fields.
225                // Presence changes still seed ordinary missing-path dependencies.
226                let mut seeds = changed
227                    .iter()
228                    .filter(|path| {
229                        !join::view_resolution_config(path) || {
230                            let rel =
231                                crate::views::RelPath::new((*path).clone()).expect("manifest path");
232                            base.get(&rel).is_some() != manifest.get(&rel).is_some()
233                        }
234                    })
235                    .cloned()
236                    .collect::<BTreeSet<_>>();
237                seeds.extend(fact_invalidated.iter().map(|path| path.as_bytes().to_vec()));
238                if changed.iter().any(|path| {
239                    join::view_resolution_config(path) && {
240                        let rel = crate::views::RelPath::new(path.clone()).expect("manifest path");
241                        base.get(&rel).is_some() != manifest.get(&rel).is_some()
242                    }
243                }) {
244                    seeds.insert(join::VIEW_CONFIG_MEMBERSHIP_DOMAIN.as_bytes().to_vec());
245                }
246                Some(dependent_closure(&transaction, &seeds)?)
247            }
248        }
249        _ => None,
250    };
251    let mut stats = MaterializeStats {
252        full_resolution: selected.is_none(),
253        unattributed_callers: fallback_count,
254        ..MaterializeStats::default()
255    };
256    profile.finish("load_bindings_select");
257    if let Some(changed) = &changed {
258        for path in changed {
259            let Ok(path) = std::str::from_utf8(path) else {
260                // Non-UTF-8 entries cannot have rows in the cold materialization.
261                continue;
262            };
263            // Edges are owned through their ref_id, not their target. Delete them
264            // before the refs so that cross-file incoming edges remain available.
265            stats.deleted += transaction.execute("DELETE FROM edges WHERE ref_id IN (SELECT ref_id FROM refs WHERE caller_file = ?1)", [path])?;
266            stats.deleted +=
267                transaction.execute("DELETE FROM refs WHERE caller_file = ?1", [path])?;
268            stats.deleted +=
269                transaction.execute("DELETE FROM nodes WHERE file_path = ?1", [path])?;
270            stats.deleted += transaction.execute("DELETE FROM files WHERE path = ?1", [path])?;
271            stats.dependency_deleted += transaction
272                .execute("DELETE FROM file_dependencies WHERE file_path = ?1", [path])?;
273            stats.dependency_deleted +=
274                transaction.execute("DELETE FROM view_bindings WHERE file_path = ?1", [path])?;
275        }
276    } else {
277        for table in ["edges", "refs", "nodes", "files"] {
278            stats.deleted += transaction.execute(&format!("DELETE FROM {table}"), [])?;
279        }
280    }
281    if changed.is_none() {
282        for table in ["file_dependencies", "view_bindings"] {
283            stats.dependency_deleted += transaction.execute(&format!("DELETE FROM {table}"), [])?;
284        }
285    }
286    profile.finish("delete_rows");
287    let mut parsed = BTreeMap::new();
288    let mut nodes = HashMap::new();
289    let mut loaded_paths = BTreeSet::new();
290    for (path, entry) in manifest.entries() {
291        if changed
292            .as_ref()
293            .is_some_and(|paths| !paths.contains(path.as_bytes()))
294        {
295            continue;
296        }
297        let crate::views::ManifestEntry::Regular {
298            planes,
299            resolution_input,
300            ..
301        } = entry
302        else {
303            continue;
304        };
305        let Some(key) = planes.callgraph.as_deref() else {
306            continue;
307        };
308        let key_bytes = decode_manifest_full_key(key).ok_or_else(|| {
309            CallGraphStoreError::Unavailable(format!("invalid manifest callgraph key {key}"))
310        })?;
311        let payload = blob_connection
312            .query_row(
313                "SELECT payload FROM blob_payloads WHERE full_key = ?1",
314                [key_bytes],
315                |row| row.get::<_, Vec<u8>>(0),
316            )
317            .optional()?
318            .ok_or_else(|| {
319                CallGraphStoreError::Unavailable(format!("missing manifest callgraph blob {key}"))
320            })?;
321        let blob = join::CallgraphBlob::from_bytes(&payload)
322            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
323        let Some(parse) = blob.parse() else {
324            continue;
325        };
326        let path = String::from_utf8(path.as_bytes().to_vec()).map_err(|_| {
327            CallGraphStoreError::Unavailable("non-UTF-8 manifest callgraph path".to_string())
328        })?;
329        loaded_paths.insert(path.clone());
330        let write_owned = changed
331            .as_ref()
332            .is_none_or(|paths| paths.contains(path.as_bytes()));
333        if write_owned {
334            stats.inserted += transaction.execute(
335                "INSERT OR REPLACE INTO files
336             (path, content_hash, mtime_ns, size, lang, is_dead_code_root, is_public_api,
337              surface_fingerprint, indexed_at)
338             VALUES (?1, ?2, 0, 0, ?3, 0, 0, '', 0)",
339                params![path, key, parse.language],
340            )?;
341        }
342        for symbol in &parse.symbols {
343            let id = format!("view:{path}:{}:{}", symbol.scoped_name, symbol.ordinal);
344            if write_owned {
345                stats.inserted += transaction.execute(
346                    "INSERT OR REPLACE INTO nodes
347                 (id, file_path, name, scoped_name, kind, start_line, start_col, end_line,
348                  end_col, range_ordinal, signature, exported, is_default_export,
349                  is_type_like, is_callgraph_entry_point, provenance)
350                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, ?12, ?14)",
351                    params![
352                        id,
353                        path,
354                        symbol.name,
355                        symbol.scoped_name,
356                        symbol.kind,
357                        i64::from(symbol.start_line),
358                        i64::from(symbol.start_col),
359                        i64::from(symbol.end_line),
360                        i64::from(symbol.end_col),
361                        i64::from(symbol.ordinal),
362                        symbol.signature,
363                        i64::from(symbol.exported),
364                        i64::from(symbol.is_default_export),
365                        PROVENANCE_TREESITTER,
366                    ],
367                )?;
368            }
369            nodes.insert((path.clone(), symbol.scoped_name.clone()), id.clone());
370            nodes
371                .entry((path.clone(), symbol.name.clone()))
372                .or_insert(id);
373        }
374        if !resolution_input {
375            parsed.insert(
376                path,
377                parse
378                    .refs
379                    .iter()
380                    .fold(BTreeMap::new(), |mut by_ordinal, reference| {
381                        // Match the cold writer's original first-reference lookup
382                        // when structural references share an AST ordinal.
383                        by_ordinal
384                            .entry(reference.ordinal)
385                            .or_insert_with(|| reference.clone());
386                        by_ordinal
387                    }),
388            );
389        }
390    }
391
392    profile.finish("owned_blob_decode_and_insert");
393    let reader = ManifestViewBlobReader {
394        connection: &blob_connection,
395    };
396    let changed_strings = changed.as_ref().map_or_else(BTreeSet::new, |paths| {
397        paths
398            .iter()
399            .filter_map(|path| String::from_utf8(path.clone()).ok())
400            .collect()
401    });
402    let mut membership_changed = base.map_or_else(BTreeSet::new, |base| {
403        base.entries()
404            .chain(manifest.entries())
405            .filter(|(path, _)| {
406                base.get(path).map(crate::views::ManifestEntry::kind)
407                    != manifest.get(path).map(crate::views::ManifestEntry::kind)
408            })
409            .filter_map(|(path, _)| String::from_utf8(path.as_bytes().to_vec()).ok())
410            .collect()
411    });
412    if membership_changed
413        .iter()
414        .any(|path| join::view_resolution_config(path.as_bytes()))
415    {
416        membership_changed.insert(join::VIEW_CONFIG_MEMBERSHIP_DOMAIN.into());
417    }
418    let joined = join::join_selected_manifest_reusing_surfaces(
419        manifest,
420        &reader,
421        selected.as_ref(),
422        &cached,
423        &changed_strings,
424        &membership_changed,
425        &fact_invalidated,
426    )
427    .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
428    profile.finish("selected_join");
429    stats.unattributed_callers = joined
430        .bindings
431        .values()
432        .filter(|binding| binding.unattributed)
433        .count()
434        .max(stats.unattributed_callers);
435    stats.rebuilt_surface_entries = joined.rebuilt_surface_entries;
436    stats.decoded_caller_blobs = joined.decoded_caller_blobs;
437    stats.resolved_refs = joined.result.rows.len();
438    stats.resolved_bindings = joined.resolved_bindings;
439    stats.resolved_files = joined.resolved_callers.len();
440    stats.dependent_files = joined
441        .resolved_callers
442        .iter()
443        .filter(|path| !changed_strings.contains(*path) && changed.is_some())
444        .count();
445    for (path, binding) in &joined.bindings {
446        let old = cached.get(path).filter(|_| {
447            changed
448                .as_ref()
449                .is_some_and(|paths| !paths.contains(path.as_bytes()))
450        });
451        if old == Some(binding) {
452            continue;
453        }
454        let empty = BTreeSet::new();
455        let previous = old.map_or(&empty, |old| &old.dependencies);
456        for dependency in previous.difference(&binding.dependencies) {
457            stats.dependency_deleted += transaction.execute(
458                "DELETE FROM file_dependencies WHERE file_path=?1 AND dep_file=?2",
459                params![path, dependency],
460            )?;
461        }
462        for dependency in binding.dependencies.difference(previous) {
463            stats.dependency_inserted += transaction.execute(
464                "INSERT INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
465                params![path, dependency],
466            )?;
467        }
468        stats.dependency_deleted +=
469            transaction.execute("DELETE FROM view_bindings WHERE file_path=?1", [path])?;
470        let payload = serde_json::to_string(binding)
471            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
472        stats.dependency_inserted += transaction.execute(
473            "INSERT INTO view_bindings(file_path, payload) VALUES(?1, ?2)",
474            params![path, payload],
475        )?;
476    }
477    profile.finish("write_bindings");
478    // Retain prepared statements across the fan-out. Preparing each statement
479    // again costs more than binding many of these small reference rows.
480    {
481        let mut same_ref = transaction.prepare("SELECT EXISTS(SELECT 1 FROM refs WHERE ref_id = ?1 AND caller_node IS ?2
482                 AND status = ?3 AND target_node IS ?4 AND target_file IS ?5 AND target_symbol IS ?6)")?;
483        let mut delete_edge = transaction.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
484        let mut delete_ref = transaction.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
485        let mut insert_ref = transaction.prepare(
486            "INSERT OR REPLACE INTO refs
487             (ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
488              import_kind, local_name, requested_name, namespace_alias, wildcard, line,
489              byte_start, byte_end, status, target_node, target_file, target_symbol, provenance)
490             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
491                     ?15, ?16, ?17, ?18, ?19, ?20)",
492        )?;
493        let mut insert_edge = transaction.prepare(
494            "INSERT OR REPLACE INTO edges
495                     (edge_id, ref_id, source_node, target_node, target_file, target_symbol,
496                      kind, line, provenance)
497                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
498        )?;
499        for row in joined.result.rows {
500            let caller_path = String::from_utf8(row.caller_path.clone()).map_err(|_| {
501                CallGraphStoreError::Unavailable("non-UTF-8 manifest caller path".to_string())
502            })?;
503            ensure_manifest_path(
504                &caller_path,
505                manifest,
506                &blob_connection,
507                &mut loaded_paths,
508                &mut parsed,
509                &mut nodes,
510            )?;
511            if let Some(target) = row
512                .target_path
513                .as_ref()
514                .and_then(|path| std::str::from_utf8(path).ok())
515            {
516                ensure_manifest_path(
517                    target,
518                    manifest,
519                    &blob_connection,
520                    &mut loaded_paths,
521                    &mut parsed,
522                    &mut nodes,
523                )?;
524            }
525            let Some(parse) = parsed.get(&caller_path) else {
526                continue;
527            };
528            let Some(reference) = parse.get(&row.ref_ordinal) else {
529                continue;
530            };
531            let caller_node = reference
532                .caller_symbol
533                .as_ref()
534                .and_then(|symbol| nodes.get(&(caller_path.clone(), symbol.clone())))
535                .cloned();
536            let target_path = row
537                .target_path
538                .as_ref()
539                .and_then(|path| String::from_utf8(path.clone()).ok());
540            let target_symbol = row.target_symbol.clone();
541            let target_node = target_path
542                .as_ref()
543                .zip(target_symbol.as_ref())
544                .and_then(|(path, symbol)| nodes.get(&(path.clone(), symbol.clone())))
545                .cloned();
546            let ref_id = format!("view:{caller_path}:{}", row.ref_ordinal);
547            let relink = changed
548                .as_ref()
549                .is_some_and(|paths| !paths.contains(caller_path.as_bytes()));
550            let status = if row.status == join::ResolutionStatus::Resolved {
551                "resolved"
552            } else {
553                "unresolved"
554            };
555            if relink {
556                // Symbol IDs encode path, scoped name and ordinal. Even an unchanged
557                // caller must be re-linked when target ordinals or resolution change.
558                // Resolve against the complete new manifest: additions, reexports and
559                // configuration changes can affect callers with no previous target.
560                let same: bool = same_ref.query_row(
561                    params![
562                        ref_id,
563                        caller_node,
564                        status,
565                        target_node,
566                        target_path,
567                        target_symbol
568                    ],
569                    |row| row.get(0),
570                )?;
571                if same {
572                    continue;
573                }
574                stats.relinked_deleted += delete_edge.execute([&ref_id])?;
575                stats.relinked_deleted += delete_ref.execute([&ref_id])?;
576            }
577            let inserted = if relink {
578                &mut stats.relinked_inserted
579            } else {
580                &mut stats.inserted
581            };
582            *inserted += insert_ref.execute(params![
583                ref_id,
584                caller_node,
585                caller_path,
586                manifest_ref_kind(row.kind),
587                reference.short_name,
588                reference.full_ref,
589                reference.module_path,
590                reference.import_kind,
591                reference.local_name,
592                reference.requested_name,
593                reference.namespace_alias,
594                i64::from(reference.wildcard),
595                i64::from(reference.line),
596                reference.byte_start as i64,
597                reference.byte_end as i64,
598                if row.status == join::ResolutionStatus::Resolved {
599                    "resolved"
600                } else {
601                    "unresolved"
602                },
603                target_node,
604                target_path,
605                target_symbol,
606                PROVENANCE_TREESITTER,
607            ])?;
608            if row.kind == join::BlobRefKind::Call {
609                if let (Some(source_node), Some(target_file), Some(target_symbol)) =
610                    (caller_node, target_path, target_symbol)
611                {
612                    *inserted += insert_edge.execute(params![
613                        format!("edge:{ref_id}"),
614                        ref_id,
615                        source_node,
616                        target_node,
617                        target_file,
618                        target_symbol,
619                        i64::from(reference.line),
620                        PROVENANCE_TREESITTER,
621                    ])?;
622                }
623            }
624        }
625    }
626    profile.finish("emit_refs_edges");
627    set_meta_ready(&transaction, true)?;
628    transaction.execute(
629        "INSERT OR REPLACE INTO meta(k, v) VALUES('view_manifest_fingerprint', ?1)",
630        [fingerprint(manifest)?],
631    )?;
632    transaction.execute(
633        "INSERT OR REPLACE INTO meta(k, v) VALUES('view_materialization_version', ?1)",
634        [MATERIALIZATION_VERSION],
635    )?;
636    transaction.commit()?;
637    profile.finish("commit");
638    Ok((stats, profile.into_timings()))
639}
640
641struct ManifestViewBlobReader<'a> {
642    connection: &'a Connection,
643}
644
645impl join::ManifestBlobReader for ManifestViewBlobReader<'_> {
646    fn read_callgraph_blob(
647        &self,
648        full_key: &str,
649    ) -> std::result::Result<Option<Vec<u8>>, join::ManifestJoinError> {
650        let Some(key) = decode_manifest_full_key(full_key) else {
651            return Ok(None);
652        };
653        self.connection
654            .query_row(
655                "SELECT payload FROM blob_payloads WHERE full_key = ?1",
656                [key],
657                |row| row.get(0),
658            )
659            .optional()
660            .map_err(|error| join::ManifestJoinError::InvalidBlob(error.to_string()))
661    }
662}
663
664fn decode_manifest_full_key(value: &str) -> Option<Vec<u8>> {
665    if value.len() != 64 {
666        return None;
667    }
668    (0..value.len())
669        .step_by(2)
670        .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
671        .collect()
672}
673
674const fn manifest_ref_kind(kind: join::BlobRefKind) -> &'static str {
675    match kind {
676        join::BlobRefKind::Call => "call",
677        join::BlobRefKind::ValueRef => "value_ref",
678        join::BlobRefKind::Import => "import",
679        join::BlobRefKind::Module => "module",
680        join::BlobRefKind::Reexport => "reexport",
681        join::BlobRefKind::ExportAlias => "export_alias",
682    }
683}
684
685fn configure_materialization_connection(connection: &Connection) -> Result<()> {
686    connection.busy_timeout(Duration::from_secs(5))?;
687    connection.pragma_update(None, "journal_mode", "WAL")?;
688    // Publication can expose the generation while its pages remain in the WAL,
689    // so the transaction commit itself must survive power loss.
690    connection.pragma_update(None, "synchronous", "FULL")?;
691    // A detached checkpoint moves these pages into the main file. Keeping the
692    // automatic threshold disabled makes that work observable and off-path.
693    connection.pragma_update(None, "wal_autocheckpoint", 0)?;
694    Ok(())
695}
696
697#[cfg(test)]
698mod tests;
699
700fn load_bindings(
701    connection: &Connection,
702) -> Result<BTreeMap<String, join::ViewBindingDependencies>> {
703    let mut statement = connection.prepare("SELECT file_path, payload FROM view_bindings")?;
704    let rows = statement.query_map([], |row| {
705        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
706    })?;
707    rows.map(|row| {
708        let (path, payload) = row?;
709        let binding = serde_json::from_str(&payload)
710            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
711        Ok((path, binding))
712    })
713    .collect()
714}
715
716fn dependent_closure(
717    connection: &Connection,
718    changed: &BTreeSet<Vec<u8>>,
719) -> Result<BTreeSet<String>> {
720    let mut selected = changed
721        .iter()
722        .filter_map(|path| String::from_utf8(path.clone()).ok())
723        .collect::<BTreeSet<_>>();
724    if !changed.is_empty() {
725        // Rust inline-module and parent queries can inspect the crate-wide index.
726        // Recheck that domain even for non-.rs paths: explicit module paths may
727        // name other extensions, and membership changes can expose new modules.
728        selected.insert(join::VIEW_RUST_MODULE_DOMAIN.to_string());
729    }
730    let mut pending = selected.iter().cloned().collect::<Vec<_>>();
731    let mut dependents =
732        connection.prepare("SELECT file_path FROM file_dependencies WHERE dep_file = ?1")?;
733    while let Some(path) = pending.pop() {
734        for caller in dependents.query_map([path], |row| row.get::<_, String>(0))? {
735            let caller = caller?;
736            if selected.insert(caller.clone()) {
737                pending.push(caller);
738            }
739        }
740    }
741    Ok(selected)
742}
743
744fn requires_full_resolution(
745    base: &crate::views::Manifest,
746    next: &crate::views::Manifest,
747    changed: &BTreeSet<Vec<u8>>,
748) -> bool {
749    changed.iter().any(|path| {
750        base.entries()
751            .chain(next.entries())
752            .any(|(candidate, entry)| {
753                candidate.as_bytes() == path
754                    && matches!(
755                        entry,
756                        crate::views::ManifestEntry::Synthetic { .. }
757                            | crate::views::ManifestEntry::Symlink { .. }
758                            | crate::views::ManifestEntry::Gitlink { .. }
759                    )
760            })
761    })
762}
763
764/// Unchanged blobs are decoded for row emission only when a selected reference
765/// actually needs their caller data or target IDs. The join builds its own index;
766/// decoding every blob again here would erase much of the incremental saving.
767fn ensure_manifest_path(
768    path: &str,
769    manifest: &crate::views::Manifest,
770    blobs: &Connection,
771    loaded: &mut BTreeSet<String>,
772    parsed: &mut BTreeMap<String, BTreeMap<u32, join::BlobRef>>,
773    nodes: &mut HashMap<(String, String), String>,
774) -> Result<()> {
775    if !loaded.insert(path.to_string()) {
776        return Ok(());
777    }
778    let Ok(rel) = crate::views::RelPath::new(path.as_bytes().to_vec()) else {
779        return Ok(());
780    };
781    let Some(crate::views::ManifestEntry::Regular {
782        planes,
783        resolution_input,
784        ..
785    }) = manifest.get(&rel)
786    else {
787        return Ok(());
788    };
789    let Some(key) = &planes.callgraph else {
790        return Ok(());
791    };
792    let key_bytes = decode_manifest_full_key(key).ok_or_else(|| {
793        CallGraphStoreError::Unavailable(format!("invalid manifest callgraph key {key}"))
794    })?;
795    let payload = blobs
796        .query_row(
797            "SELECT payload FROM blob_payloads WHERE full_key=?1",
798            [key_bytes],
799            |row| row.get::<_, Vec<u8>>(0),
800        )
801        .optional()?
802        .ok_or_else(|| {
803            CallGraphStoreError::Unavailable(format!("missing manifest callgraph blob {key}"))
804        })?;
805    let blob = join::CallgraphBlob::from_bytes(&payload)
806        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
807    let Some(parse) = blob.parse() else {
808        return Ok(());
809    };
810    for symbol in &parse.symbols {
811        let id = format!("view:{path}:{}:{}", symbol.scoped_name, symbol.ordinal);
812        nodes.insert((path.to_string(), symbol.scoped_name.clone()), id.clone());
813        nodes
814            .entry((path.to_string(), symbol.name.clone()))
815            .or_insert(id);
816    }
817    if !resolution_input {
818        parsed.insert(
819            path.to_string(),
820            parse
821                .refs
822                .iter()
823                .fold(BTreeMap::new(), |mut by_ordinal, reference| {
824                    by_ordinal
825                        .entry(reference.ordinal)
826                        .or_insert_with(|| reference.clone());
827                    by_ordinal
828                }),
829        );
830    }
831    Ok(())
832}