Skip to main content

aft/views/
materialization.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet, HashMap};
3use std::path::Path;
4use std::sync::Arc;
5use std::time::Duration;
6
7use rusqlite::{params, Connection, OptionalExtension};
8
9use crate::callgraph_store::{
10    initialize_schema, join, set_meta_ready, CallGraphStoreError, Result, PROVENANCE_TREESITTER,
11};
12
13/// Cold materialization, also used to upgrade databases without diff metadata.
14pub fn materialize_manifest_view_database(
15    database_path: &Path,
16    callgraph_blob_database: &Path,
17    manifest: &crate::views::Manifest,
18) -> Result<()> {
19    materialize(database_path, callgraph_blob_database, manifest, None).map(|_| ())
20}
21
22/// Counts affected graph and dependency-cache rows, excluding readiness and metadata.
23/// Replacements count both the deletion and insertion; relinks include refs and edges.
24/// Resolution counters describe work performed, not SQLite writes.
25#[derive(Debug, Default, Clone, PartialEq, Eq)]
26pub struct MaterializeStats {
27    pub delete_paths_touched: usize,
28    pub deleted: usize,
29    pub inserted: usize,
30    pub relinked_deleted: usize,
31    pub relinked_inserted: usize,
32    pub dependency_deleted: usize,
33    pub dependency_inserted: usize,
34    pub surface_deleted: usize,
35    pub surface_inserted: usize,
36    pub dependent_files: usize,
37    pub resolved_files: usize,
38    pub resolved_refs: usize,
39    pub resolved_bindings: usize,
40    pub emission_lookup_queries: usize,
41    pub rebuilt_surface_entries: usize,
42    pub decoded_caller_blobs: usize,
43    pub full_resolution: bool,
44    pub unattributed_callers: usize,
45}
46
47impl MaterializeStats {
48    pub fn graph_rows_written(&self) -> usize {
49        self.deleted + self.inserted + self.relinked_deleted + self.relinked_inserted
50    }
51
52    pub fn rows_written(&self) -> usize {
53        self.graph_rows_written()
54            + self.dependency_deleted
55            + self.dependency_inserted
56            + self.surface_deleted
57            + self.surface_inserted
58    }
59}
60
61/// Apply to a private copy of the base generation's database, never a published file.
62/// The caller owns copying, durability and pointer publication. All graph changes and
63/// the fingerprint commit atomically; errors roll back to the supplied base. An old
64/// binding-cache schema takes the cold path; a mismatched manifest is refused.
65pub fn apply_manifest_diff(
66    database_path: &Path,
67    base_manifest: &crate::views::Manifest,
68    new_manifest: &crate::views::Manifest,
69    callgraph_blob_database: &Path,
70) -> Result<MaterializeStats> {
71    apply_manifest_diff_profiled(
72        database_path,
73        base_manifest,
74        new_manifest,
75        callgraph_blob_database,
76    )
77    .map(|(stats, _)| stats)
78}
79
80pub(crate) fn apply_manifest_diff_profiled(
81    database_path: &Path,
82    base_manifest: &crate::views::Manifest,
83    new_manifest: &crate::views::Manifest,
84    callgraph_blob_database: &Path,
85) -> Result<(MaterializeStats, profile::PhaseTimings)> {
86    materialize(
87        database_path,
88        callgraph_blob_database,
89        new_manifest,
90        Some(base_manifest),
91    )
92}
93
94pub(crate) mod profile;
95mod resolution_facts;
96
97const MATERIALIZATION_VERSION: &str = "6";
98
99fn fingerprint(manifest: &crate::views::Manifest) -> Result<String> {
100    let bytes = manifest
101        .to_json_bytes()
102        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
103    Ok(blake3::hash(&bytes).to_hex().to_string())
104}
105
106pub(super) fn manifest_callgraph_equivalent(
107    left: &crate::views::Manifest,
108    right: &crate::views::Manifest,
109) -> bool {
110    let mut left = left.entries();
111    let mut right = right.entries();
112    loop {
113        match (left.next(), right.next()) {
114            (None, None) => return true,
115            (Some((left_path, left_entry)), Some((right_path, right_entry)))
116                if left_path == right_path
117                    && match (left_entry, right_entry) {
118                        (
119                            crate::views::ManifestEntry::Regular {
120                                mode: left_mode,
121                                planes: left_planes,
122                                resolution_input: left_resolution_input,
123                            },
124                            crate::views::ManifestEntry::Regular {
125                                mode: right_mode,
126                                planes: right_planes,
127                                resolution_input: right_resolution_input,
128                            },
129                        ) => {
130                            left_mode == right_mode
131                                && left_resolution_input == right_resolution_input
132                                && left_planes.callgraph == right_planes.callgraph
133                        }
134                        _ => left_entry == right_entry,
135                    } => {}
136            _ => return false,
137        }
138    }
139}
140
141fn materialize(
142    database_path: &Path,
143    callgraph_blob_database: &Path,
144    manifest: &crate::views::Manifest,
145    mut base: Option<&crate::views::Manifest>,
146) -> Result<(MaterializeStats, profile::PhaseTimings)> {
147    let write_probe = profile::WriteProbe::start(database_path);
148    let mut profile = profile::PhaseTimer::new(if base.is_some() {
149        "incremental"
150    } else {
151        "cold"
152    });
153    let mut connection = if base.is_some() {
154        Connection::open_with_flags(database_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?
155    } else {
156        Connection::open(database_path)?
157    };
158    configure_materialization_connection(&connection)?;
159    if base.is_none() {
160        initialize_schema(&connection)?;
161    }
162    let transaction =
163        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
164    if let Some(base_manifest) = base {
165        let recorded: Option<String> = transaction
166            .query_row(
167                "SELECT v FROM meta WHERE k = 'view_manifest_fingerprint'",
168                [],
169                |row| row.get(0),
170            )
171            .optional()?;
172        let version: Option<String> = transaction
173            .query_row(
174                "SELECT v FROM meta WHERE k = 'view_materialization_version'",
175                [],
176                |row| row.get(0),
177            )
178            .optional()?;
179        if recorded.is_none() && version.is_none() {
180            // Cloned generations written before diff metadata existed must be
181            // rebuilt; their graph cannot safely seed dependency invalidation.
182            base = None;
183        } else if recorded.as_deref() != Some(fingerprint(base_manifest)?.as_str()) {
184            return Err(CallGraphStoreError::Unavailable(
185                "derived manifest fingerprint mismatch; cold materialization required".into(),
186            ));
187        } else if version.as_deref() != Some(MATERIALIZATION_VERSION) {
188            base = None;
189        } else if base_manifest == manifest {
190            profile.finish("load_bindings_select");
191            return Ok((MaterializeStats::default(), profile.into_timings()));
192        } else if manifest_callgraph_equivalent(base_manifest, manifest) {
193            profile.finish("load_bindings_select");
194            transaction.execute(
195                "INSERT OR REPLACE INTO meta(k, v) VALUES('view_manifest_fingerprint', ?1)",
196                [fingerprint(manifest)?],
197            )?;
198            transaction.commit()?;
199            profile.finish("commit");
200            return Ok((MaterializeStats::default(), profile.into_timings()));
201        }
202    }
203    transaction.execute_batch(
204        "CREATE TABLE IF NOT EXISTS view_bindings (
205             file_path TEXT PRIMARY KEY,
206             payload TEXT NOT NULL
207         );
208         CREATE TABLE IF NOT EXISTS view_file_surfaces (
209             file_path TEXT PRIMARY KEY,
210             payload TEXT NOT NULL
211         )",
212    )?;
213    let changed = base.map(|base| {
214        base.entries()
215            .chain(manifest.entries())
216            .filter(|(path, _)| base.get(path) != manifest.get(path))
217            .map(|(path, _)| path.as_bytes().to_vec())
218            .collect::<BTreeSet<_>>()
219    });
220    let blob_connection = Connection::open(callgraph_blob_database)?;
221    let reader = ManifestViewBlobReader::new(&blob_connection);
222    let mut cached_for_invalidation = None;
223    let mut fact_invalidated = BTreeSet::new();
224    let mut fallback_count = 0;
225    let selected = match (&changed, base) {
226        (Some(changed), Some(base)) if !requires_full_resolution(base, manifest, changed) => {
227            let diff = resolution_facts::diff_inputs(base, manifest, changed, &blob_connection)?;
228            if diff.unknown == 0 && (!diff.changed.is_empty() || diff.inputs_changed) {
229                let cached = load_bindings(&transaction)?;
230                for (caller, binding) in &cached {
231                    if !binding.consulted_facts.is_disjoint(&diff.changed) {
232                        fact_invalidated.insert(caller.clone());
233                    }
234                    if diff.inputs_changed && binding.unattributed {
235                        fallback_count += 1;
236                    }
237                }
238                cached_for_invalidation = Some(cached);
239            }
240            if diff.unknown != 0 || fallback_count != 0 {
241                fallback_count += diff.unknown;
242                log::warn!("views materialization: full resolution (reason=unattributed_reads count={fallback_count})");
243                None
244            } else {
245                // Configuration bytes are invalidated by their consulted fields.
246                // Presence changes still seed ordinary missing-path dependencies.
247                let mut seeds = changed
248                    .iter()
249                    .filter(|path| {
250                        !join::view_resolution_config(path) || {
251                            let rel =
252                                crate::views::RelPath::new((*path).clone()).expect("manifest path");
253                            base.get(&rel).is_some() != manifest.get(&rel).is_some()
254                        }
255                    })
256                    .cloned()
257                    .collect::<BTreeSet<_>>();
258                seeds.extend(fact_invalidated.iter().map(|path| path.as_bytes().to_vec()));
259                if changed.iter().any(|path| {
260                    join::view_resolution_config(path) && {
261                        let rel = crate::views::RelPath::new(path.clone()).expect("manifest path");
262                        base.get(&rel).is_some() != manifest.get(&rel).is_some()
263                    }
264                }) {
265                    seeds.insert(join::VIEW_CONFIG_MEMBERSHIP_DOMAIN.as_bytes().to_vec());
266                }
267                Some(dependent_closure(&transaction, &seeds)?)
268            }
269        }
270        _ => None,
271    };
272    let cached = if base.is_none() {
273        BTreeMap::new()
274    } else if let Some(cached) = cached_for_invalidation {
275        cached
276    } else if let Some(selected) = &selected {
277        load_bindings_for_selection(
278            &transaction,
279            selected,
280            changed.as_ref().expect("incremental selection has a diff"),
281        )?
282    } else {
283        load_bindings(&transaction)?
284    };
285    let mut stats = MaterializeStats {
286        full_resolution: selected.is_none(),
287        unattributed_callers: fallback_count,
288        ..MaterializeStats::default()
289    };
290    profile.finish("load_bindings_select");
291    if let Some(changed) = &changed {
292        transaction.execute_batch(
293            "CREATE TEMP TABLE changed_view_paths (
294                 path TEXT PRIMARY KEY
295             ) WITHOUT ROWID",
296        )?;
297        {
298            let mut insert =
299                transaction.prepare("INSERT INTO changed_view_paths(path) VALUES(?1)")?;
300            for path in changed {
301                let Ok(path) = std::str::from_utf8(path) else {
302                    // Non-UTF-8 entries cannot have rows in the cold materialization.
303                    continue;
304                };
305                stats.delete_paths_touched += insert.execute([path])?;
306            }
307        }
308        // Edges are owned through their ref_id, not their target. Delete them
309        // before refs so cross-file incoming edges remain available. Starting
310        // every lookup from the bounded path table keeps deletion on the diff.
311        stats.deleted += transaction.execute(
312            "DELETE FROM edges WHERE ref_id IN (
313                 SELECT refs.ref_id FROM changed_view_paths
314                 CROSS JOIN refs INDEXED BY idx_refs_caller_file
315                     ON refs.caller_file = changed_view_paths.path
316             )",
317            [],
318        )?;
319        stats.deleted += transaction.execute(
320            "DELETE FROM refs WHERE caller_file IN (SELECT path FROM changed_view_paths)",
321            [],
322        )?;
323        stats.deleted += transaction.execute(
324            "DELETE FROM nodes WHERE file_path IN (SELECT path FROM changed_view_paths)",
325            [],
326        )?;
327        stats.deleted += transaction.execute(
328            "DELETE FROM files WHERE path IN (SELECT path FROM changed_view_paths)",
329            [],
330        )?;
331        stats.dependency_deleted += transaction.execute(
332            "DELETE FROM file_dependencies WHERE file_path IN (SELECT path FROM changed_view_paths)",
333            [],
334        )?;
335        stats.dependency_deleted += transaction.execute(
336            "DELETE FROM view_bindings WHERE file_path IN (SELECT path FROM changed_view_paths)",
337            [],
338        )?;
339        stats.surface_deleted += transaction.execute(
340            "DELETE FROM view_file_surfaces WHERE file_path IN (SELECT path FROM changed_view_paths)",
341            [],
342        )?;
343    } else {
344        for table in ["edges", "refs", "nodes", "files"] {
345            stats.deleted += transaction.execute(&format!("DELETE FROM {table}"), [])?;
346        }
347    }
348    if changed.is_none() {
349        for table in ["file_dependencies", "view_bindings"] {
350            stats.dependency_deleted += transaction.execute(&format!("DELETE FROM {table}"), [])?;
351        }
352        stats.surface_deleted += transaction.execute("DELETE FROM view_file_surfaces", [])?;
353    }
354    profile.finish("delete_rows");
355    let mut parsed = BTreeMap::new();
356    let mut nodes = HashMap::<String, HashMap<String, String>>::new();
357    let mut loaded_paths = BTreeSet::new();
358    for (path, entry) in manifest.entries() {
359        if changed
360            .as_ref()
361            .is_some_and(|paths| !paths.contains(path.as_bytes()))
362        {
363            continue;
364        }
365        let crate::views::ManifestEntry::Regular {
366            planes,
367            resolution_input,
368            ..
369        } = entry
370        else {
371            continue;
372        };
373        let Some(key) = planes.callgraph.as_deref() else {
374            continue;
375        };
376        let blob = reader
377            .read_decoded(key)
378            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
379            .ok_or_else(|| {
380                CallGraphStoreError::Unavailable(format!("missing manifest callgraph blob {key}"))
381            })?;
382        let Some(parse) = blob.parse() else {
383            continue;
384        };
385        let path = String::from_utf8(path.as_bytes().to_vec()).map_err(|_| {
386            CallGraphStoreError::Unavailable("non-UTF-8 manifest callgraph path".to_string())
387        })?;
388        loaded_paths.insert(path.clone());
389        let write_owned = changed
390            .as_ref()
391            .is_none_or(|paths| paths.contains(path.as_bytes()));
392        if write_owned {
393            stats.inserted += transaction.execute(
394                "INSERT OR REPLACE INTO files
395             (path, content_hash, mtime_ns, size, lang, is_dead_code_root, is_public_api,
396              surface_fingerprint, indexed_at)
397             VALUES (?1, ?2, 0, 0, ?3, 0, 0, '', 0)",
398                params![path, key, parse.language],
399            )?;
400        }
401        let file_nodes = nodes.entry(path.clone()).or_default();
402        for symbol in &parse.symbols {
403            let id = format!("view:{path}:{}:{}", symbol.scoped_name, symbol.ordinal);
404            if write_owned {
405                stats.inserted += transaction.execute(
406                    "INSERT OR REPLACE INTO nodes
407                 (id, file_path, name, scoped_name, kind, start_line, start_col, end_line,
408                  end_col, range_ordinal, signature, exported, is_default_export,
409                  is_type_like, is_callgraph_entry_point, provenance)
410                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, ?12, ?14)",
411                    params![
412                        id,
413                        path,
414                        symbol.name,
415                        symbol.scoped_name,
416                        symbol.kind,
417                        i64::from(symbol.start_line),
418                        i64::from(symbol.start_col),
419                        i64::from(symbol.end_line),
420                        i64::from(symbol.end_col),
421                        i64::from(symbol.ordinal),
422                        symbol.signature,
423                        i64::from(symbol.exported),
424                        i64::from(symbol.is_default_export),
425                        PROVENANCE_TREESITTER,
426                    ],
427                )?;
428            }
429            file_nodes.insert(symbol.scoped_name.clone(), id.clone());
430            file_nodes.entry(symbol.name.clone()).or_insert(id);
431        }
432        if !resolution_input {
433            parsed.insert(path, EmissionParse::new(blob));
434        }
435    }
436
437    profile.finish("owned_blob_decode_and_insert");
438    let changed_strings = changed.as_ref().map_or_else(BTreeSet::new, |paths| {
439        paths
440            .iter()
441            .filter_map(|path| String::from_utf8(path.clone()).ok())
442            .collect()
443    });
444    let mut membership_changed = base.map_or_else(BTreeSet::new, |base| {
445        base.entries()
446            .chain(manifest.entries())
447            .filter(|(path, _)| {
448                base.get(path).map(crate::views::ManifestEntry::kind)
449                    != manifest.get(path).map(crate::views::ManifestEntry::kind)
450            })
451            .filter_map(|(path, _)| String::from_utf8(path.as_bytes().to_vec()).ok())
452            .collect()
453    });
454    if membership_changed
455        .iter()
456        .any(|path| join::view_resolution_config(path.as_bytes()))
457    {
458        membership_changed.insert(join::VIEW_CONFIG_MEMBERSHIP_DOMAIN.into());
459    }
460    let joined = join::join_selected_manifest_reusing_surfaces(
461        manifest,
462        &reader,
463        selected.as_ref(),
464        &cached,
465        &changed_strings,
466        &membership_changed,
467        &fact_invalidated,
468    )
469    .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
470    profile.finish("selected_join");
471    stats.unattributed_callers = joined
472        .bindings
473        .values()
474        .filter(|binding| binding.unattributed)
475        .count()
476        .max(stats.unattributed_callers);
477    stats.rebuilt_surface_entries = joined.rebuilt_surface_entries;
478    stats.decoded_caller_blobs = joined.decoded_caller_blobs;
479    stats.resolved_refs = joined.result.rows.len();
480    stats.resolved_bindings = joined.resolved_bindings;
481    stats.resolved_files = joined.resolved_callers.len();
482    stats.dependent_files = joined
483        .resolved_callers
484        .iter()
485        .filter(|path| !changed_strings.contains(*path) && changed.is_some())
486        .count();
487    for (path, binding) in &joined.bindings {
488        if joined.rebuilt_surface_paths.contains(path) {
489            if let Some(payload) = binding
490                .surface_json()
491                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
492            {
493                stats.surface_inserted += transaction.execute(
494                    "INSERT OR REPLACE INTO view_file_surfaces(file_path, payload) VALUES(?1, ?2)",
495                    params![path, payload],
496                )?;
497            }
498        }
499        let old = cached.get(path).filter(|_| {
500            changed
501                .as_ref()
502                .is_some_and(|paths| !paths.contains(path.as_bytes()))
503        });
504        if old == Some(binding) {
505            continue;
506        }
507        let empty = BTreeSet::new();
508        let previous = old.map_or(&empty, |old| &old.dependencies);
509        for dependency in previous.difference(&binding.dependencies) {
510            stats.dependency_deleted += transaction.execute(
511                "DELETE FROM file_dependencies WHERE file_path=?1 AND dep_file=?2",
512                params![path, dependency],
513            )?;
514        }
515        for dependency in binding.dependencies.difference(previous) {
516            stats.dependency_inserted += transaction.execute(
517                "INSERT INTO file_dependencies(file_path, dep_file) VALUES(?1, ?2)",
518                params![path, dependency],
519            )?;
520        }
521        stats.dependency_deleted +=
522            transaction.execute("DELETE FROM view_bindings WHERE file_path=?1", [path])?;
523        let payload = serde_json::to_string(binding)
524            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
525        stats.dependency_inserted += transaction.execute(
526            "INSERT INTO view_bindings(file_path, payload) VALUES(?1, ?2)",
527            params![path, payload],
528        )?;
529    }
530    profile.finish("write_bindings");
531    // Retain prepared statements across the fan-out. Preparing each statement
532    // again costs more than binding many of these small reference rows.
533    {
534        type ExistingRef = (
535            Option<String>,
536            String,
537            Option<String>,
538            Option<String>,
539            Option<String>,
540        );
541        let mut identical_refs = 0;
542        let mut existing = HashMap::<String, HashMap<String, ExistingRef>>::new();
543        let mut load_refs = transaction.prepare("SELECT ref_id, caller_node, status, target_node, target_file, target_symbol FROM refs WHERE caller_file = ?1")?;
544        let mut delete_edge = transaction.prepare("DELETE FROM edges WHERE ref_id = ?1")?;
545        let mut delete_ref = transaction.prepare("DELETE FROM refs WHERE ref_id = ?1")?;
546        let mut insert_ref = transaction.prepare(
547            "INSERT OR REPLACE INTO refs
548             (ref_id, caller_node, caller_file, kind, short_name, full_ref, module_path,
549              import_kind, local_name, requested_name, namespace_alias, wildcard, line,
550              byte_start, byte_end, status, target_node, target_file, target_symbol, provenance)
551             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14,
552                     ?15, ?16, ?17, ?18, ?19, ?20)",
553        )?;
554        let mut insert_edge = transaction.prepare(
555            "INSERT OR REPLACE INTO edges
556                     (edge_id, ref_id, source_node, target_node, target_file, target_symbol,
557                      kind, line, provenance)
558                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'call', ?7, ?8)",
559        )?;
560        #[cfg(test)]
561        let mut control_lookup = transaction.prepare("SELECT EXISTS(SELECT 1 FROM refs WHERE ref_id=?1 AND caller_node IS ?2 AND status=?3 AND target_node IS ?4 AND target_file IS ?5 AND target_symbol IS ?6)")?;
562        for row in joined.result.rows {
563            let caller_path = String::from_utf8(row.caller_path).map_err(|_| {
564                CallGraphStoreError::Unavailable("non-UTF-8 manifest caller path".to_string())
565            })?;
566            ensure_manifest_path(
567                &caller_path,
568                manifest,
569                &reader,
570                &mut loaded_paths,
571                &mut parsed,
572                &mut nodes,
573            )?;
574            let target_path = row
575                .target_path
576                .and_then(|path| String::from_utf8(path).ok());
577            if let Some(target) = target_path.as_deref() {
578                ensure_manifest_path(
579                    target,
580                    manifest,
581                    &reader,
582                    &mut loaded_paths,
583                    &mut parsed,
584                    &mut nodes,
585                )?;
586            }
587            let Some(parse) = parsed.get(&caller_path) else {
588                continue;
589            };
590            let Some(reference) = parse.reference(row.ref_ordinal) else {
591                continue;
592            };
593            let caller_node = reference
594                .caller_symbol
595                .as_ref()
596                .and_then(|symbol| nodes.get(&caller_path)?.get(symbol))
597                .cloned();
598            let target_symbol = row.target_symbol;
599            let target_node = target_path
600                .as_ref()
601                .zip(target_symbol.as_ref())
602                .and_then(|(path, symbol)| nodes.get(path)?.get(symbol))
603                .cloned();
604            let ref_id = format!("view:{caller_path}:{}", row.ref_ordinal);
605            let relink = changed
606                .as_ref()
607                .is_some_and(|paths| !paths.contains(caller_path.as_bytes()));
608            let status = if row.status == join::ResolutionStatus::Resolved {
609                "resolved"
610            } else {
611                "unresolved"
612            };
613            if relink {
614                // Symbol IDs encode path, scoped name and ordinal. Even an unchanged
615                // caller must be re-linked when target ordinals or resolution change.
616                // Resolve against the complete new manifest: additions, reexports and
617                // configuration changes can affect callers with no previous target.
618                #[cfg(test)]
619                let control_same = if tests::PER_REFERENCE_LOOKUP.with(|enabled| enabled.get()) {
620                    stats.emission_lookup_queries += 1;
621                    Some(control_lookup.query_row(
622                        params![
623                            ref_id,
624                            caller_node,
625                            status,
626                            target_node,
627                            target_path,
628                            target_symbol
629                        ],
630                        |row| row.get::<_, bool>(0),
631                    )?)
632                } else {
633                    None
634                };
635                #[cfg(not(test))]
636                let control_same: Option<bool> = None;
637                if control_same.is_none() && !existing.contains_key(&caller_path) {
638                    stats.emission_lookup_queries += 1;
639                    let rows = load_refs
640                        .query_map([&caller_path], |row| {
641                            Ok((
642                                row.get::<_, String>(0)?,
643                                (
644                                    row.get(1)?,
645                                    row.get(2)?,
646                                    row.get(3)?,
647                                    row.get(4)?,
648                                    row.get(5)?,
649                                ),
650                            ))
651                        })?
652                        .collect::<std::result::Result<HashMap<String, ExistingRef>, _>>()?;
653                    existing.insert(caller_path.clone(), rows);
654                }
655                let same = control_same.unwrap_or_else(|| {
656                    existing[&caller_path].get(&ref_id).is_some_and(|old| {
657                        old.0 == caller_node
658                            && old.1 == status
659                            && old.2 == target_node
660                            && old.3 == target_path
661                            && old.4 == target_symbol
662                    })
663                });
664                if same {
665                    identical_refs += 1;
666                    continue;
667                }
668                stats.relinked_deleted += delete_edge.execute([&ref_id])?;
669                stats.relinked_deleted += delete_ref.execute([&ref_id])?;
670            }
671            let inserted = if relink {
672                &mut stats.relinked_inserted
673            } else {
674                &mut stats.inserted
675            };
676            *inserted += insert_ref.execute(params![
677                ref_id,
678                caller_node,
679                caller_path,
680                manifest_ref_kind(row.kind),
681                reference.short_name,
682                reference.full_ref,
683                reference.module_path,
684                reference.import_kind,
685                reference.local_name,
686                reference.requested_name,
687                reference.namespace_alias,
688                i64::from(reference.wildcard),
689                i64::from(reference.line),
690                reference.byte_start as i64,
691                reference.byte_end as i64,
692                if row.status == join::ResolutionStatus::Resolved {
693                    "resolved"
694                } else {
695                    "unresolved"
696                },
697                target_node,
698                target_path,
699                target_symbol,
700                PROVENANCE_TREESITTER,
701            ])?;
702            if row.kind == join::BlobRefKind::Call {
703                if let (Some(source_node), Some(target_file), Some(target_symbol)) =
704                    (caller_node, target_path, target_symbol)
705                {
706                    *inserted += insert_edge.execute(params![
707                        format!("edge:{ref_id}"),
708                        ref_id,
709                        source_node,
710                        target_node,
711                        target_file,
712                        target_symbol,
713                        i64::from(reference.line),
714                        PROVENANCE_TREESITTER,
715                    ])?;
716                }
717            }
718        }
719        if std::env::var_os("AFT_VIEW_PROFILE").is_some() {
720            eprintln!(
721                "view_profile emission identical_refs_skipped={identical_refs} stats={stats:?}"
722            );
723        }
724    }
725    profile.finish("emit_refs_edges");
726    set_meta_ready(&transaction, true)?;
727    transaction.execute(
728        "INSERT OR REPLACE INTO meta(k, v) VALUES('view_manifest_fingerprint', ?1)",
729        [fingerprint(manifest)?],
730    )?;
731    transaction.execute(
732        "INSERT OR REPLACE INTO meta(k, v) VALUES('view_materialization_version', ?1)",
733        [MATERIALIZATION_VERSION],
734    )?;
735    transaction.commit()?;
736    profile.finish("commit");
737    if let Some(probe) = write_probe {
738        probe.finish(&connection, database_path);
739    }
740    // Include destruction in the bracket: freeing decoded blobs and closing
741    // SQLite handles happens before the caller observes materialization complete.
742    drop((reader, parsed, nodes, cached));
743    drop(joined.bindings);
744    profile.finish("cleanup_memory");
745    drop((blob_connection, connection));
746    profile.finish("cleanup_connections");
747    Ok((stats, profile.into_timings()))
748}
749
750struct ManifestViewBlobReader<'a> {
751    connection: &'a Connection,
752    decoded: RefCell<HashMap<String, Arc<join::CallgraphBlob>>>,
753}
754
755impl<'a> ManifestViewBlobReader<'a> {
756    fn new(connection: &'a Connection) -> Self {
757        Self {
758            connection,
759            decoded: RefCell::new(HashMap::new()),
760        }
761    }
762
763    fn read_payload(
764        &self,
765        full_key: &str,
766    ) -> std::result::Result<Option<Vec<u8>>, join::ManifestJoinError> {
767        let Some(key) = decode_manifest_full_key(full_key) else {
768            return Ok(None);
769        };
770        self.connection
771            .query_row(
772                "SELECT payload FROM blob_payloads WHERE full_key = ?1",
773                [key],
774                |row| row.get(0),
775            )
776            .optional()
777            .map_err(|error| join::ManifestJoinError::InvalidBlob(error.to_string()))
778    }
779
780    fn read_decoded(
781        &self,
782        full_key: &str,
783    ) -> std::result::Result<Option<Arc<join::CallgraphBlob>>, join::ManifestJoinError> {
784        if let Some(blob) = self.decoded.borrow().get(full_key) {
785            return Ok(Some(blob.clone()));
786        }
787        let Some(payload) = self.read_payload(full_key)? else {
788            return Ok(None);
789        };
790        let blob = Arc::new(join::CallgraphBlob::from_bytes(&payload)?);
791        self.decoded
792            .borrow_mut()
793            .insert(full_key.to_string(), blob.clone());
794        Ok(Some(blob))
795    }
796}
797
798impl join::ManifestBlobReader for ManifestViewBlobReader<'_> {
799    fn read_callgraph_blob(
800        &self,
801        full_key: &str,
802    ) -> std::result::Result<Option<Vec<u8>>, join::ManifestJoinError> {
803        self.read_payload(full_key)
804    }
805
806    fn read_callgraph_blob_decoded(
807        &self,
808        full_key: &str,
809    ) -> std::result::Result<Option<Arc<join::CallgraphBlob>>, join::ManifestJoinError> {
810        self.read_decoded(full_key)
811    }
812}
813
814fn decode_manifest_full_key(value: &str) -> Option<Vec<u8>> {
815    if value.len() != 64 {
816        return None;
817    }
818    (0..value.len())
819        .step_by(2)
820        .map(|index| u8::from_str_radix(&value[index..index + 2], 16).ok())
821        .collect()
822}
823
824const fn manifest_ref_kind(kind: join::BlobRefKind) -> &'static str {
825    match kind {
826        join::BlobRefKind::Call => "call",
827        join::BlobRefKind::ValueRef => "value_ref",
828        join::BlobRefKind::Import => "import",
829        join::BlobRefKind::Module => "module",
830        join::BlobRefKind::Reexport => "reexport",
831        join::BlobRefKind::ExportAlias => "export_alias",
832    }
833}
834
835fn configure_materialization_connection(connection: &Connection) -> Result<()> {
836    connection.busy_timeout(Duration::from_secs(5))?;
837    connection.pragma_update(None, "journal_mode", "WAL")?;
838    // Publication can expose the generation while its pages remain in the WAL,
839    // so the transaction commit itself must survive power loss.
840    connection.pragma_update(None, "synchronous", "FULL")?;
841    // A detached checkpoint moves these pages into the main file. Keeping the
842    // automatic threshold disabled makes that work observable and off-path.
843    connection.pragma_update(None, "wal_autocheckpoint", 0)?;
844    // The page cache is left at SQLite's default on purpose. Measured on the
845    // real 300-file replay in release, two runs per setting (mean incremental
846    // wall / process peak RSS): default 4.245 s / 3,327 MiB; 64 MiB 4.370 s /
847    // 3,358 MiB; 256 MiB 4.145 s / 3,571 MiB. A 256 MiB cache buys ~2% wall for
848    // ~240 MiB of resident memory per publishing root, and the daemon holds
849    // dozens of roots that can publish in the same minute; 64 MiB was no better
850    // than the default on either axis. The mmap window is file-backed and
851    // reclaimable, so it is the one knob worth keeping.
852    connection.pragma_update(None, "mmap_size", 268_435_456)?;
853    connection.pragma_update(None, "temp_store", "MEMORY")?;
854    Ok(())
855}
856
857#[cfg(test)]
858mod tests;
859
860fn load_bindings(
861    connection: &Connection,
862) -> Result<BTreeMap<String, join::ViewBindingDependencies>> {
863    let mut statement = connection.prepare("SELECT file_path, payload FROM view_bindings")?;
864    let rows = statement.query_map([], |row| {
865        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
866    })?;
867    let mut bindings = rows
868        .map(|row| {
869            let (path, payload) = row?;
870            let binding: join::ViewBindingDependencies = serde_json::from_str(&payload)
871                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
872            Ok((path, binding))
873        })
874        .collect::<Result<BTreeMap<_, _>>>()?;
875    for (path, surface) in load_surfaces(connection)? {
876        if let Some(binding) = bindings.get_mut(&path) {
877            binding.copy_surface_from(&surface);
878        }
879    }
880    Ok(bindings)
881}
882
883fn load_surfaces(
884    connection: &Connection,
885) -> Result<BTreeMap<String, join::ViewBindingDependencies>> {
886    let mut statement = connection.prepare("SELECT file_path, payload FROM view_file_surfaces")?;
887    let rows = statement.query_map([], |row| {
888        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
889    })?;
890    rows.map(|row| {
891        let (path, surface) = row?;
892        let binding = join::ViewBindingDependencies::from_surface_json(&surface)
893            .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
894        Ok((path, binding))
895    })
896    .collect()
897}
898
899fn load_bindings_for_selection(
900    connection: &Connection,
901    selected: &BTreeSet<String>,
902    changed: &BTreeSet<Vec<u8>>,
903) -> Result<BTreeMap<String, join::ViewBindingDependencies>> {
904    connection.execute_batch(
905        "CREATE TEMP TABLE selected_view_paths (
906             path TEXT PRIMARY KEY
907         ) WITHOUT ROWID",
908    )?;
909    {
910        let mut insert = connection.prepare("INSERT INTO selected_view_paths(path) VALUES(?1)")?;
911        for path in selected {
912            if !changed.contains(path.as_bytes()) {
913                insert.execute([path])?;
914            }
915        }
916    }
917
918    let mut bindings = load_surfaces(connection)?;
919    for path in changed {
920        if let Ok(path) = std::str::from_utf8(path) {
921            bindings.remove(path);
922        }
923    }
924    {
925        let mut statement = connection.prepare(
926            "SELECT bindings.file_path, bindings.payload
927             FROM selected_view_paths
928             CROSS JOIN view_bindings AS bindings INDEXED BY sqlite_autoindex_view_bindings_1
929                 ON bindings.file_path = selected_view_paths.path",
930        )?;
931        let rows = statement.query_map([], |row| {
932            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
933        })?;
934        for row in rows {
935            let (path, payload) = row?;
936            let mut binding: join::ViewBindingDependencies = serde_json::from_str(&payload)
937                .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?;
938            if let Some(surface) = bindings.get(&path) {
939                binding.copy_surface_from(surface);
940            }
941            bindings.insert(path, binding);
942        }
943    }
944    Ok(bindings)
945}
946
947fn dependent_closure(
948    connection: &Connection,
949    changed: &BTreeSet<Vec<u8>>,
950) -> Result<BTreeSet<String>> {
951    connection.execute_batch(
952        "CREATE TEMP TABLE dependency_seed_paths (
953             path TEXT PRIMARY KEY
954         ) WITHOUT ROWID",
955    )?;
956    {
957        let mut insert =
958            connection.prepare("INSERT OR IGNORE INTO dependency_seed_paths(path) VALUES(?1)")?;
959        for path in changed {
960            if let Ok(path) = std::str::from_utf8(path) {
961                insert.execute([path])?;
962            }
963        }
964        if !changed.is_empty() {
965            // Rust inline-module and parent queries can inspect the crate-wide index.
966            // Recheck that domain even for non-.rs paths: explicit module paths may
967            // name other extensions, and membership changes can expose new modules.
968            insert.execute([join::VIEW_RUST_MODULE_DOMAIN])?;
969        }
970    }
971    let mut statement = connection.prepare(
972        "WITH RECURSIVE selected(path) AS (
973             SELECT path FROM dependency_seed_paths
974             UNION
975             SELECT dependencies.file_path
976             FROM selected
977             JOIN file_dependencies AS dependencies INDEXED BY idx_file_dependencies_dep_file
978                 ON dependencies.dep_file = selected.path
979         )
980         SELECT path FROM selected",
981    )?;
982    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
983    rows.collect::<std::result::Result<BTreeSet<_>, _>>()
984        .map_err(Into::into)
985}
986
987fn requires_full_resolution(
988    base: &crate::views::Manifest,
989    next: &crate::views::Manifest,
990    changed: &BTreeSet<Vec<u8>>,
991) -> bool {
992    changed.iter().any(|path| {
993        base.entries()
994            .chain(next.entries())
995            .any(|(candidate, entry)| {
996                candidate.as_bytes() == path
997                    && matches!(
998                        entry,
999                        crate::views::ManifestEntry::Synthetic { .. }
1000                            | crate::views::ManifestEntry::Symlink { .. }
1001                            | crate::views::ManifestEntry::Gitlink { .. }
1002                    )
1003            })
1004    })
1005}
1006
1007struct EmissionParse {
1008    blob: Arc<join::CallgraphBlob>,
1009    refs_by_ordinal: HashMap<u32, usize>,
1010}
1011
1012impl EmissionParse {
1013    fn new(blob: Arc<join::CallgraphBlob>) -> Self {
1014        let refs_by_ordinal = blob
1015            .parse()
1016            .into_iter()
1017            .flat_map(|parse| parse.refs.iter().enumerate())
1018            .fold(HashMap::new(), |mut by_ordinal, (position, reference)| {
1019                // Match the cold writer's original first-reference lookup when
1020                // structural references share an AST ordinal.
1021                by_ordinal.entry(reference.ordinal).or_insert(position);
1022                by_ordinal
1023            });
1024        Self {
1025            blob,
1026            refs_by_ordinal,
1027        }
1028    }
1029
1030    fn reference(&self, ordinal: u32) -> Option<&join::BlobRef> {
1031        self.blob
1032            .parse()?
1033            .refs
1034            .get(*self.refs_by_ordinal.get(&ordinal)?)
1035    }
1036}
1037
1038/// Unchanged blobs are decoded for row emission only when a selected reference
1039/// actually needs their caller data or target IDs. The join builds its own index;
1040/// decoding every blob again here would erase much of the incremental saving.
1041fn ensure_manifest_path(
1042    path: &str,
1043    manifest: &crate::views::Manifest,
1044    blobs: &ManifestViewBlobReader<'_>,
1045    loaded: &mut BTreeSet<String>,
1046    parsed: &mut BTreeMap<String, EmissionParse>,
1047    nodes: &mut HashMap<String, HashMap<String, String>>,
1048) -> Result<()> {
1049    if !loaded.insert(path.to_string()) {
1050        return Ok(());
1051    }
1052    let Ok(rel) = crate::views::RelPath::new(path.as_bytes().to_vec()) else {
1053        return Ok(());
1054    };
1055    let Some(crate::views::ManifestEntry::Regular {
1056        planes,
1057        resolution_input,
1058        ..
1059    }) = manifest.get(&rel)
1060    else {
1061        return Ok(());
1062    };
1063    let Some(key) = &planes.callgraph else {
1064        return Ok(());
1065    };
1066    let blob = blobs
1067        .read_decoded(key)
1068        .map_err(|error| CallGraphStoreError::Unavailable(error.to_string()))?
1069        .ok_or_else(|| {
1070            CallGraphStoreError::Unavailable(format!("missing manifest callgraph blob {key}"))
1071        })?;
1072    let Some(parse) = blob.parse() else {
1073        return Ok(());
1074    };
1075    let file_nodes = nodes.entry(path.to_string()).or_default();
1076    for symbol in &parse.symbols {
1077        let id = format!("view:{path}:{}:{}", symbol.scoped_name, symbol.ordinal);
1078        file_nodes.insert(symbol.scoped_name.clone(), id.clone());
1079        file_nodes.entry(symbol.name.clone()).or_insert(id);
1080    }
1081    if !resolution_input {
1082        parsed.insert(path.to_string(), EmissionParse::new(blob));
1083    }
1084    Ok(())
1085}