Skip to main content

fallow_graph/cache/
mod.rs

1//! Persisted graph-cache identity contracts and on-disk store.
2//!
3//! The manifest types here define the invalidation surface a persisted graph
4//! cache must satisfy before a cached graph can be trusted; the store implements
5//! the coarse all-or-nothing load / save of a previously-built `ModuleGraph`
6//! keyed by that manifest.
7
8use std::path::Path;
9
10use fallow_types::discover::{DiscoveredFile, StableFileKey};
11use fallow_types::source_fingerprint::SourceFingerprint;
12
13mod store;
14
15pub use store::GraphCacheStore;
16
17/// Persisted graph cache schema version.
18///
19/// Bump this whenever the serialized shape of the persisted graph (any of the
20/// graph types that derive serde for the cache, the manifest types, or the
21/// store envelope) changes, so a stale `graph-cache.bin` written by an older
22/// binary is rejected rather than deserialized into the wrong shape.
23pub const GRAPH_CACHE_VERSION: u32 = 1;
24
25/// Serialize an [`oxc_span::Span`] as a `[start, end]` `u32` pair.
26///
27/// `oxc_span::Span` does not enable its own serde feature in this workspace, so
28/// the graph types that carry spans route them through this module via
29/// `#[serde(with = "crate::cache::span_serde")]`. A 2-element array keeps the
30/// postcard encoding compact (two varints) and is trivially lossless: a `Span`
31/// is fully described by its `start` / `end` offsets.
32pub(crate) mod span_serde {
33    use oxc_span::Span;
34    use serde::{Deserialize, Deserializer, Serialize, Serializer};
35
36    #[expect(
37        clippy::trivially_copy_pass_by_ref,
38        reason = "serde `serialize_with` / `with` requires a `&T` signature"
39    )]
40    pub fn serialize<S: Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
41        [span.start, span.end].serialize(serializer)
42    }
43
44    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Span, D::Error> {
45        let [start, end] = <[u32; 2]>::deserialize(deserializer)?;
46        Ok(Span::new(start, end))
47    }
48}
49
50/// Lossless cache (de)serialization for `Vec<MemberInfo>`.
51///
52/// `fallow_types::extract::MemberInfo` derives only `serde::Serialize`, and its
53/// `span` field uses `serialize_with` with no matching deserializer, so it
54/// cannot be deserialized through a plain derive. Rather than change the shared
55/// type's serde shape (which would ripple into JSON output), the cache mirrors
56/// it field-for-field into a dedicated `CachedMemberInfo` and converts both
57/// ways. Every `MemberInfo` field is carried, so the round-trip is lossless.
58pub(crate) mod member_serde {
59    use fallow_types::extract::{MemberInfo, MemberKind};
60    use oxc_span::Span;
61    use serde::{Deserialize, Deserializer, Serialize, Serializer};
62
63    #[derive(Serialize, Deserialize)]
64    struct CachedMemberInfo {
65        name: String,
66        kind: MemberKind,
67        span: [u32; 2],
68        has_decorator: bool,
69        decorator_names: Vec<String>,
70        is_instance_returning_static: bool,
71        is_self_returning: bool,
72    }
73
74    impl From<&MemberInfo> for CachedMemberInfo {
75        fn from(member: &MemberInfo) -> Self {
76            Self {
77                name: member.name.clone(),
78                kind: member.kind,
79                span: [member.span.start, member.span.end],
80                has_decorator: member.has_decorator,
81                decorator_names: member.decorator_names.clone(),
82                is_instance_returning_static: member.is_instance_returning_static,
83                is_self_returning: member.is_self_returning,
84            }
85        }
86    }
87
88    impl From<CachedMemberInfo> for MemberInfo {
89        fn from(cached: CachedMemberInfo) -> Self {
90            Self {
91                name: cached.name,
92                kind: cached.kind,
93                span: Span::new(cached.span[0], cached.span[1]),
94                has_decorator: cached.has_decorator,
95                decorator_names: cached.decorator_names,
96                is_instance_returning_static: cached.is_instance_returning_static,
97                is_self_returning: cached.is_self_returning,
98            }
99        }
100    }
101
102    pub fn serialize<S: Serializer>(
103        members: &[MemberInfo],
104        serializer: S,
105    ) -> Result<S::Ok, S::Error> {
106        let mirror: Vec<CachedMemberInfo> = members.iter().map(CachedMemberInfo::from).collect();
107        mirror.serialize(serializer)
108    }
109
110    pub fn deserialize<'de, D: Deserializer<'de>>(
111        deserializer: D,
112    ) -> Result<Vec<MemberInfo>, D::Error> {
113        let mirror = Vec::<CachedMemberInfo>::deserialize(deserializer)?;
114        Ok(mirror.into_iter().map(MemberInfo::from).collect())
115    }
116}
117
118/// Option dimensions that affect graph construction.
119///
120/// The hashes are intentionally opaque to this crate. Callers decide which
121/// resolver/plugin/entry-point inputs feed each hash, while this contract keeps
122/// graph-cache validation explicit and typed.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
124pub struct GraphCacheMode {
125    /// Import resolver and tsconfig-relevant options.
126    pub resolver_options_hash: u64,
127    /// Entry point set and reachability root options.
128    pub entry_points_hash: u64,
129    /// Plugin-derived graph-affecting configuration.
130    pub plugin_config_hash: u64,
131}
132
133impl GraphCacheMode {
134    /// Build a mode from explicit hash dimensions.
135    #[must_use]
136    pub const fn new(
137        resolver_options_hash: u64,
138        entry_points_hash: u64,
139        plugin_config_hash: u64,
140    ) -> Self {
141        Self {
142            resolver_options_hash,
143            entry_points_hash,
144            plugin_config_hash,
145        }
146    }
147}
148
149/// Source freshness for one file in a graph-cache manifest.
150#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
151pub struct GraphCacheFile {
152    /// Persistable identity for the file.
153    pub key: StableFileKey,
154    /// Metadata fingerprint for cache invalidation.
155    pub fingerprint: SourceFingerprint,
156}
157
158impl GraphCacheFile {
159    /// Build a graph-cache file row from a discovered file and fingerprint.
160    #[must_use]
161    pub fn from_discovered_file(
162        root: &Path,
163        file: &DiscoveredFile,
164        fingerprint: SourceFingerprint,
165    ) -> Self {
166        Self {
167            key: StableFileKey::from_root_relative(root, &file.path),
168            fingerprint,
169        }
170    }
171}
172
173/// Manifest inputs required to trust a persisted graph cache entry.
174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175pub struct GraphCacheManifest {
176    /// Schema version used by the persisted graph-cache entry.
177    pub version: u32,
178    /// Graph-affecting option dimensions.
179    pub mode: GraphCacheMode,
180    /// Stable file identities and freshness metadata.
181    pub files: Vec<GraphCacheFile>,
182}
183
184impl GraphCacheManifest {
185    /// Build a manifest and sort files by stable key for deterministic compare.
186    #[must_use]
187    pub fn new(mode: GraphCacheMode, mut files: Vec<GraphCacheFile>) -> Self {
188        sort_files(&mut files);
189        Self {
190            version: GRAPH_CACHE_VERSION,
191            mode,
192            files,
193        }
194    }
195
196    /// Build a manifest from discovered files plus a fingerprint provider.
197    pub fn from_discovered_files(
198        root: &Path,
199        files: &[DiscoveredFile],
200        mode: GraphCacheMode,
201        mut fingerprint_for_path: impl FnMut(&Path) -> SourceFingerprint,
202    ) -> Self {
203        let rows = files
204            .iter()
205            .map(|file| {
206                GraphCacheFile::from_discovered_file(root, file, fingerprint_for_path(&file.path))
207            })
208            .collect();
209        Self::new(mode, rows)
210    }
211
212    /// True when a persisted manifest matches the current graph inputs.
213    #[must_use]
214    pub fn matches_inputs(&self, current: &Self) -> bool {
215        self.version == GRAPH_CACHE_VERSION
216            && current.version == GRAPH_CACHE_VERSION
217            && self.mode == current.mode
218            && self.files == current.files
219    }
220}
221
222fn sort_files(files: &mut [GraphCacheFile]) {
223    files.sort_unstable_by(|a, b| a.key.cmp(&b.key));
224}
225
226#[cfg(test)]
227mod tests {
228    use std::path::{Path, PathBuf};
229
230    use fallow_types::discover::FileId;
231    use rustc_hash::FxHashMap;
232
233    use super::*;
234
235    fn file(id: u32, path: &str) -> DiscoveredFile {
236        DiscoveredFile {
237            id: FileId(id),
238            path: PathBuf::from(path),
239            size_bytes: 1,
240        }
241    }
242
243    fn mode() -> GraphCacheMode {
244        GraphCacheMode::new(1, 2, 3)
245    }
246
247    fn fingerprints(pairs: &[(&str, SourceFingerprint)]) -> FxHashMap<PathBuf, SourceFingerprint> {
248        pairs
249            .iter()
250            .map(|(path, fingerprint)| (PathBuf::from(path), *fingerprint))
251            .collect()
252    }
253
254    fn manifest(
255        files: &[DiscoveredFile],
256        mode: GraphCacheMode,
257        map: &FxHashMap<PathBuf, SourceFingerprint>,
258    ) -> GraphCacheManifest {
259        GraphCacheManifest::from_discovered_files(Path::new("/project"), files, mode, |path| {
260            *map.get(path).unwrap()
261        })
262    }
263
264    #[test]
265    fn manifest_sorts_by_stable_file_key() {
266        let files = vec![file(0, "/project/src/z.ts"), file(1, "/project/src/a.ts")];
267        let map = fingerprints(&[
268            ("/project/src/z.ts", SourceFingerprint::new(10, 1)),
269            ("/project/src/a.ts", SourceFingerprint::new(20, 1)),
270        ]);
271
272        let manifest = manifest(&files, mode(), &map);
273
274        let keys: Vec<&str> = manifest
275            .files
276            .iter()
277            .map(|file| file.key.as_str())
278            .collect();
279        assert_eq!(keys, vec!["src/a.ts", "src/z.ts"]);
280    }
281
282    #[test]
283    fn manifest_matches_across_file_id_shift() {
284        let before = vec![file(0, "/project/src/a.ts"), file(1, "/project/src/c.ts")];
285        let after = vec![file(9, "/project/src/c.ts"), file(2, "/project/src/a.ts")];
286        let map = fingerprints(&[
287            ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
288            ("/project/src/c.ts", SourceFingerprint::new(20, 1)),
289        ]);
290
291        let cached = manifest(&before, mode(), &map);
292        let current = manifest(&after, mode(), &map);
293
294        assert!(cached.matches_inputs(&current));
295    }
296
297    #[test]
298    fn manifest_misses_on_fingerprint_change() {
299        let files = vec![file(0, "/project/src/a.ts")];
300        let cached_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
301        let current_map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(11, 1))]);
302
303        let cached = manifest(&files, mode(), &cached_map);
304        let current = manifest(&files, mode(), &current_map);
305
306        assert!(!cached.matches_inputs(&current));
307    }
308
309    #[test]
310    fn manifest_misses_on_file_deletion() {
311        let before = vec![
312            file(0, "/project/src/a.ts"),
313            file(1, "/project/src/deleted.ts"),
314        ];
315        let after = vec![file(0, "/project/src/a.ts")];
316        let map = fingerprints(&[
317            ("/project/src/a.ts", SourceFingerprint::new(10, 1)),
318            ("/project/src/deleted.ts", SourceFingerprint::new(20, 1)),
319        ]);
320
321        let cached = manifest(&before, mode(), &map);
322        let current = manifest(&after, mode(), &map);
323
324        assert!(!cached.matches_inputs(&current));
325    }
326
327    #[test]
328    fn manifest_misses_on_mode_change() {
329        let files = vec![file(0, "/project/src/a.ts")];
330        let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
331
332        let cached = manifest(&files, mode(), &map);
333        let current = manifest(&files, GraphCacheMode::new(1, 99, 3), &map);
334
335        assert!(!cached.matches_inputs(&current));
336    }
337
338    #[test]
339    fn manifest_misses_on_version_change() {
340        let files = vec![file(0, "/project/src/a.ts")];
341        let map = fingerprints(&[("/project/src/a.ts", SourceFingerprint::new(10, 1))]);
342        let mut cached = manifest(&files, mode(), &map);
343        let current = manifest(&files, mode(), &map);
344
345        cached.version = GRAPH_CACHE_VERSION + 1;
346
347        assert!(!cached.matches_inputs(&current));
348    }
349}