Skip to main content

json_eval_rs/jsoneval/
eval_cache.rs

1use indexmap::IndexSet;
2use serde_json::Value;
3use std::collections::HashMap;
4
5/// Token-version tracker for json paths
6#[derive(Default, Clone)]
7pub struct VersionTracker {
8    versions: HashMap<String, u64>,
9}
10
11impl VersionTracker {
12    pub fn new() -> Self {
13        Self {
14            versions: HashMap::new(),
15        }
16    }
17
18    #[inline]
19    pub fn get(&self, path: &str) -> u64 {
20        self.versions.get(path).copied().unwrap_or(0)
21    }
22
23    #[inline]
24    pub fn bump(&mut self, path: &str, source: &str) {
25        let current = self.get(path);
26        if crate::utils::is_debug_cache_enabled() {
27            println!(
28                "[store_cache] BUMPING for {} -> {} ({})",
29                path,
30                current + 1,
31                source
32            );
33        }
34        // We use actual data pointers here
35        self.versions.insert(path.to_string(), current + 1);
36    }
37
38    /// Merge version counters from `other`, taking the **maximum** for each path.
39    /// Using max (not insert) ensures that if this tracker already saw a higher version
40    /// for a path (e.g., from a previous subform evaluation round), it is never downgraded.
41    pub fn merge_from(&mut self, other: &VersionTracker) {
42        for (k, v) in &other.versions {
43            let current = self.versions.get(k).copied().unwrap_or(0);
44            self.versions.insert(k.clone(), current.max(*v));
45        }
46    }
47
48    /// Merge only `/$params`-prefixed version counters from `other` (max strategy).
49    /// Used when giving a per-item tracker the latest schema-level param versions
50    /// without absorbing data-path bumps that belong to other items.
51    pub fn merge_from_params(&mut self, other: &VersionTracker) {
52        for (k, v) in &other.versions {
53            if k.starts_with("/$params") {
54                let current = self.versions.get(k).copied().unwrap_or(0);
55                self.versions.insert(k.clone(), current.max(*v));
56            }
57        }
58    }
59
60    /// Merge counters except paths local to a different active subform item.
61    pub(crate) fn merge_excluding_prefix(&mut self, other: &VersionTracker, excluded_prefix: &str) {
62        for (k, v) in &other.versions {
63            if !k.starts_with(excluded_prefix) {
64                let current = self.versions.get(k).copied().unwrap_or(0);
65                self.versions.insert(k.clone(), current.max(*v));
66            }
67        }
68    }
69
70    /// Returns true if any tracked path with the given prefix has been bumped (version > 0).
71    /// Used to gate table re-evaluation when item fields change without the item being new.
72    pub fn any_bumped_with_prefix(&self, prefix: &str) -> bool {
73        self.versions
74            .iter()
75            .any(|(k, &v)| k.starts_with(prefix) && v > 0)
76    }
77
78    /// Returns true if any path with the given prefix has a **higher** version than in `baseline`.
79    /// Unlike `any_bumped_with_prefix`, this detects only brand-new bumps from a specific diff
80    /// pass, ignoring historical bumps that were already present in the baseline.
81    pub fn any_newly_bumped_with_prefix(&self, prefix: &str, baseline: &VersionTracker) -> bool {
82        self.versions
83            .iter()
84            .any(|(k, &v)| k.starts_with(prefix) && v > baseline.get(k))
85    }
86
87    /// Returns an iterator over all (path, version) pairs, for targeted bump enumeration.
88    pub fn versions(&self) -> impl Iterator<Item = (&str, &u64)> {
89        self.versions.iter().map(|(k, v)| (k.as_str(), v))
90    }
91}
92
93/// A cached evaluation result with the specific dependency versions it was evaluated against
94#[derive(Clone)]
95pub struct CacheEntry {
96    pub dep_versions: HashMap<String, u64>,
97    pub result: std::sync::Arc<Value>,
98    /// The `active_item_index` this entry was computed under.
99    /// `None` = computed during main-form evaluation (safe to reuse across all items
100    /// provided the dep versions match). `Some(idx)` = computed for a specific item;
101    /// Tier-2 reuse is restricted to entries whose deps are entirely `$params`-scoped.
102    pub computed_for_item: Option<usize>,
103}
104
105/// Independent cache state for a single item in a subform array
106#[derive(Default, Clone)]
107pub struct SubformItemCache {
108    pub data_versions: VersionTracker,
109    pub entries: HashMap<String, CacheEntry>,
110    pub item_snapshot: Value,
111    /// Per-item snapshot of the evaluated schema captured after each evaluate_subform_item.
112    /// Allows get_evaluated_schema_subform to return the correct per-item values without
113    /// re-running the full evaluation pipeline in a shared subform context.
114    pub evaluated_schema: Option<Value>,
115}
116
117impl SubformItemCache {
118    pub fn new() -> Self {
119        Self {
120            data_versions: VersionTracker::new(),
121            entries: HashMap::new(),
122            item_snapshot: Value::Null,
123            evaluated_schema: None,
124        }
125    }
126}
127
128/// Primary cache structure for a JSON evaluation instance
129#[derive(Clone)]
130pub struct EvalCache {
131    pub data_versions: VersionTracker,
132    pub params_versions: VersionTracker,
133    pub entries: HashMap<String, CacheEntry>,
134
135    pub active_item_index: Option<usize>,
136    pub subform_caches: HashMap<usize, SubformItemCache>,
137
138    /// Monotonically increasing counter bumped whenever data_versions or params_versions change.
139    /// When `eval_generation == last_evaluated_generation`, all cache entries are guaranteed valid
140    /// and `evaluate_internal` can skip the full tree traversal.
141    pub eval_generation: u64,
142    pub last_evaluated_generation: u64,
143
144    /// Snapshot of the last fully-diffed main-form data payload.
145    /// Stored after each successful `evaluate_internal_with_new_data` call so the next
146    /// invocation can avoid an extra `snapshot_data_clone()` when computing the diff.
147    pub main_form_snapshot: Option<std::sync::Arc<Value>>,
148    pub subform_roots: Vec<String>,
149}
150
151impl Default for EvalCache {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl EvalCache {
158    pub fn new() -> Self {
159        Self {
160            data_versions: VersionTracker::new(),
161            params_versions: VersionTracker::new(),
162            entries: HashMap::new(),
163            active_item_index: None,
164            subform_caches: HashMap::new(),
165            eval_generation: 0,
166            last_evaluated_generation: u64::MAX, // force first evaluate_internal to run
167            main_form_snapshot: None,
168            subform_roots: Vec::new(),
169        }
170    }
171
172    pub fn is_subform_dep(&self, path: &str) -> bool {
173        self.subform_roots
174            .iter()
175            .any(|root| path == root || path.starts_with(&format!("{}/", root)))
176    }
177
178    pub fn clear(&mut self) {
179        self.data_versions = VersionTracker::new();
180        self.params_versions = VersionTracker::new();
181        self.entries.clear();
182        self.active_item_index = None;
183        self.subform_caches.clear();
184        self.eval_generation = 0;
185        self.last_evaluated_generation = u64::MAX;
186        self.main_form_snapshot = None;
187    }
188
189    /// Remove item caches for indices >= `current_count`.
190    /// Call this whenever the subform array length is known to have shrunk so that
191    /// stale per-item version trackers and cached entries do not linger in memory.
192    pub fn prune_subform_caches(&mut self, current_count: usize) {
193        self.subform_caches.retain(|&idx, _| idx < current_count);
194    }
195
196    /// Invalidate all `$params`-scoped table cache entries for a specific item.
197    ///
198    /// Called when a brand-new subform item is introduced so that `$params` tables
199    /// that aggregate array data (e.g. aggregate tables) are forced to recompute instead
200    /// of returning stale results cached from a prior main-form evaluation that ran
201    /// when the item was absent (and thus saw zero/null for that item's values).
202    pub fn invalidate_params_tables_for_item(&mut self, idx: usize, table_keys: &[String]) {
203        // Bump params_versions so T2 global entries for these tables are stale.
204        for key in table_keys {
205            let data_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(key);
206            self.params_versions
207                .bump(&data_path, "invalidate_params_tables_for_item");
208            self.eval_generation += 1;
209        }
210
211        // Evict matching T1 (item-level) entries so they are not reused.
212        if let Some(item_cache) = self.subform_caches.get_mut(&idx) {
213            for key in table_keys {
214                item_cache.entries.remove(key);
215            }
216        }
217    }
218
219    /// Returns true if evaluate_internal must run (versions changed since last full evaluation)
220    pub fn needs_full_evaluation(&self) -> bool {
221        self.eval_generation != self.last_evaluated_generation
222    }
223
224    /// Call after evaluate_internal completes successfully to mark the generation stable
225    pub fn mark_evaluated(&mut self) {
226        self.last_evaluated_generation = self.eval_generation;
227    }
228
229    pub(crate) fn ensure_active_item_cache(&mut self, idx: usize) {
230        self.subform_caches
231            .entry(idx)
232            .or_insert_with(SubformItemCache::new);
233    }
234
235    pub fn set_active_item(&mut self, idx: usize) {
236        self.active_item_index = Some(idx);
237        self.ensure_active_item_cache(idx);
238    }
239
240    pub fn clear_active_item(&mut self) {
241        self.active_item_index = None;
242    }
243
244    /// Recursively diffs `old` against `new` and bumps version for every changed data path scalar.
245    pub fn store_snapshot_and_diff_versions(&mut self, old: &Value, new: &Value) {
246        if let Some(idx) = self.active_item_index {
247            self.ensure_active_item_cache(idx);
248            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
249            diff_and_update_versions(
250                &mut sub_cache.data_versions,
251                "",
252                old,
253                new,
254                "subform store_snapshot_and_diff_versions",
255            );
256            sub_cache.item_snapshot = new.clone();
257        } else {
258            diff_and_update_versions(
259                &mut self.data_versions,
260                "",
261                old,
262                new,
263                "store_snapshot_and_diff_versions",
264            );
265        }
266    }
267
268    pub fn get_active_snapshot(&self) -> Value {
269        if let Some(idx) = self.active_item_index {
270            self.subform_caches
271                .get(&idx)
272                .map(|c| c.item_snapshot.clone())
273                .unwrap_or(Value::Null)
274        } else {
275            Value::Null
276        }
277    }
278
279    pub fn diff_active_item(
280        &mut self,
281        field_key: &str,
282        old_sub_data: &Value,
283        new_sub_data: &Value,
284    ) {
285        if let Some(idx) = self.active_item_index {
286            self.ensure_active_item_cache(idx);
287            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
288
289            // Diff ONLY the localized item part, skipping the massive parent tree
290            let empty = Value::Null;
291            let old_item = old_sub_data.get(field_key).unwrap_or(&empty);
292            let new_item = new_sub_data.get(field_key).unwrap_or(&empty);
293
294            diff_and_update_versions(
295                &mut sub_cache.data_versions,
296                &format!("/{}", field_key),
297                old_item,
298                new_item,
299                format!("diff_active_item {}", field_key).as_str(),
300            );
301            sub_cache.item_snapshot = new_sub_data.clone();
302        }
303    }
304
305    pub fn bump_data_version(&mut self, data_path: &str) {
306        // Always signal that something changed so the parent's needs_full_evaluation()
307        // returns true even when the bump was item-scoped.
308        self.eval_generation += 1;
309        if let Some(idx) = self.active_item_index {
310            if let Some(cache) = self.subform_caches.get_mut(&idx) {
311                cache.data_versions.bump(data_path, "bump_data_version1");
312            }
313        } else {
314            self.data_versions.bump(data_path, "bump_data_version2");
315        }
316    }
317
318    pub fn bump_params_version(&mut self, data_path: &str) {
319        self.params_versions.bump(data_path, "bump_params_version");
320        self.eval_generation += 1;
321    }
322
323    /// Check if the `eval_key` result can be safely bypassed because dependencies are unchanged.
324    ///
325    /// Two-tier lookup:
326    /// - Tier 1: item-scoped entries in `subform_caches[idx]` — checked first when an active item is set
327    /// - Tier 2: global `self.entries` — allows Run 1 (main form) results to be reused in Run 2 (subform)
328    pub fn check_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
329        self.check_cache_arc(eval_key, deps)
330            .map(|arc| (*arc).clone())
331    }
332
333    pub fn check_cache_arc(
334        &self,
335        eval_key: &str,
336        deps: &IndexSet<String>,
337    ) -> Option<std::sync::Arc<Value>> {
338        if let Some(idx) = self.active_item_index {
339            // Tier 1: item-specific entries (always safe to reuse for the same index)
340            if let Some(cache) = self.subform_caches.get(&idx) {
341                if let Some(hit) =
342                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
343                {
344                    if crate::utils::is_debug_cache_enabled() {
345                        println!("Cache HIT [T1 idx={}] {}", idx, eval_key);
346                    }
347                    return Some(hit);
348                }
349            }
350
351            // Reuse only index-safe T2 entries.
352            let item_data_versions = self
353                .subform_caches
354                .get(&idx)
355                .map(|c| &c.data_versions)
356                .unwrap_or(&self.data_versions);
357
358            if let Some(entry) = self.entries.get(eval_key) {
359                let depends_on_subform_item =
360                    entry.dep_versions.keys().any(|p| self.is_subform_dep(p));
361                if depends_on_subform_item && entry.computed_for_item != Some(idx) {
362                    return None;
363                }
364
365                let has_changed_item_dep = self.subform_caches.get(&idx).is_some_and(|cache| {
366                    entry.dep_versions.keys().any(|p| {
367                        if p.starts_with("/$params") {
368                            false
369                        } else {
370                            cache.data_versions.get(p) > self.data_versions.get(p)
371                        }
372                    })
373                });
374                let index_safe = match entry.computed_for_item {
375                    None => !has_changed_item_dep,
376                    Some(stored_idx) if stored_idx == idx => true,
377                    _ => !has_changed_item_dep,
378                };
379                if index_safe {
380                    let result =
381                        self.validate_entry(eval_key, deps, &self.entries, item_data_versions);
382                    if result.is_some() {
383                        if crate::utils::is_debug_cache_enabled() {
384                            println!(
385                                "Cache HIT [T2 idx={} for={:?}] {}",
386                                idx, entry.computed_for_item, eval_key
387                            );
388                        }
389                    }
390                    return result;
391                }
392            }
393
394            None
395        } else {
396            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
397        }
398    }
399
400    /// Specialized cache check for `$params`-scoped table evaluations.
401    ///
402    /// Checks global cache for `$params` tables.
403    pub fn check_table_cache(
404        &self,
405        eval_key: &str,
406        deps: &IndexSet<String>,
407    ) -> Option<std::sync::Arc<Value>> {
408        if let Some(idx) = self.active_item_index {
409            // Tier 1: item-scoped entries first (unlikely for $params tables but check anyway)
410            if let Some(cache) = self.subform_caches.get(&idx) {
411                if let Some(hit) =
412                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
413                {
414                    if crate::utils::is_debug_cache_enabled() {
415                        println!("Cache HIT [T1 table idx={}] {}", idx, eval_key);
416                    }
417                    return Some(hit);
418                }
419            }
420
421            // If the table has an item dependency that was bumped for this active item,
422            // the active item must not reuse the global T2 table.
423            let has_changed_item_dep = self.subform_caches.get(&idx).is_some_and(|cache| {
424                if let Some(entry) = self.entries.get(eval_key) {
425                    entry.dep_versions.keys().any(|p| {
426                        if p.starts_with("/$params") {
427                            false
428                        } else {
429                            cache.data_versions.get(p) > self.data_versions.get(p)
430                        }
431                    })
432                } else {
433                    deps.iter().any(|dep| {
434                        let p = crate::jsoneval::path_utils::schema_path_to_data_pointer(dep);
435                        if p.starts_with("/$params") {
436                            false
437                        } else {
438                            cache.data_versions.get(&p) > self.data_versions.get(&p)
439                        }
440                    })
441                }
442            });
443            if has_changed_item_dep {
444                return None;
445            }
446
447            let result = self.validate_entry(eval_key, deps, &self.entries, &self.data_versions);
448            if result.is_some() {
449                if crate::utils::is_debug_cache_enabled() {
450                    println!("Cache HIT [T2 table idx={}] {}", idx, eval_key);
451                }
452            }
453            result
454        } else {
455            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
456        }
457    }
458
459    fn validate_entry(
460        &self,
461        eval_key: &str,
462        _deps: &IndexSet<String>,
463        entries: &HashMap<String, CacheEntry>,
464        data_versions: &VersionTracker,
465    ) -> Option<std::sync::Arc<Value>> {
466        let entry = entries.get(eval_key)?;
467        for (data_dep_path, &cached_ver) in &entry.dep_versions {
468            let current_ver = if data_dep_path.starts_with("/$params") {
469                self.params_versions.get(data_dep_path)
470            } else if let Some(idx) = self.active_item_index {
471                self.subform_caches
472                    .get(&idx)
473                    .map(|c| c.data_versions.get(data_dep_path))
474                    .filter(|&v| v > 0)
475                    .unwrap_or_else(|| data_versions.get(data_dep_path))
476            } else {
477                data_versions.get(data_dep_path)
478            };
479
480            if current_ver != cached_ver {
481                if crate::utils::is_debug_cache_enabled() {
482                    println!(
483                        "Cache MISS {}: dep {} changed ({} -> {})",
484                        eval_key, data_dep_path, cached_ver, current_ver
485                    );
486                }
487                return None;
488            }
489        }
490        if crate::utils::is_debug_cache_enabled() {
491            println!("Cache HIT {}", eval_key);
492        }
493        Some(std::sync::Arc::clone(&entry.result))
494    }
495
496    /// Store the newly evaluated value and snapshot the dependency versions.
497    ///
498    /// Stores result in active cache tier.
499    pub fn store_cache(&mut self, eval_key: &str, deps: &IndexSet<String>, result: Value) {
500        self.store_cache_arc(eval_key, deps, std::sync::Arc::new(result));
501    }
502
503    /// Store the newly evaluated value and snapshot the dependency versions (zero-copy Arc).
504    pub fn store_cache_arc(
505        &mut self,
506        eval_key: &str,
507        deps: &IndexSet<String>,
508        result: std::sync::Arc<Value>,
509    ) {
510        // Snapshot dependency versions.
511        let mut dep_versions = HashMap::with_capacity(deps.len());
512        {
513            let data_versions = if let Some(idx) = self.active_item_index {
514                self.ensure_active_item_cache(idx);
515                &self.subform_caches[&idx].data_versions
516            } else {
517                &self.data_versions
518            };
519
520            for dep in deps {
521                let data_dep_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(dep);
522                let ver = if data_dep_path.starts_with("/$params") {
523                    self.params_versions.get(&data_dep_path)
524                } else {
525                    data_versions.get(&data_dep_path)
526                };
527                dep_versions.insert(data_dep_path.into_owned(), ver);
528            }
529        }
530
531        // Store with current item scope.
532        let computed_for_item = self.active_item_index;
533
534        // Bump $params versions only when result changes.
535        if eval_key.starts_with("#/$params") {
536            let existing_result: Option<&Value> = if let Some(idx) = self.active_item_index {
537                // Prefer canonical T2 result.
538                self.entries
539                    .get(eval_key)
540                    .map(|e| e.result.as_ref())
541                    .or_else(|| {
542                        self.subform_caches
543                            .get(&idx)
544                            .and_then(|c| c.entries.get(eval_key))
545                            .map(|e| e.result.as_ref())
546                    })
547            } else {
548                self.entries.get(eval_key).map(|e| e.result.as_ref())
549            };
550
551            let value_changed = existing_result.map_or(true, |r| r != result.as_ref());
552
553            if value_changed {
554                let data_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(eval_key);
555
556                // Bump the explicit path and its table-level parent.
557                // Stop at slash_count < 3 — never bump /$params/others or /$params itself.
558                let mut current_path = data_path.as_ref();
559                let mut slash_count = current_path.matches('/').count();
560
561                while slash_count >= 3 {
562                    self.params_versions.bump(current_path, "store_cache");
563                    if let Some(last_slash) = current_path.rfind('/') {
564                        current_path = &current_path[..last_slash];
565                        slash_count -= 1;
566                    } else {
567                        break;
568                    }
569                }
570
571                self.eval_generation += 1;
572            }
573        }
574
575        let entry = CacheEntry {
576            dep_versions,
577            result: std::sync::Arc::clone(&result),
578            computed_for_item,
579        };
580
581        if let Some(idx) = self.active_item_index {
582            // Store item-scoped: isolates per-rider entries so riders with different data don't collide
583            self.subform_caches
584                .get_mut(&idx)
585                .unwrap()
586                .entries
587                .insert(eval_key.to_string(), entry.clone());
588
589            // Promote $params tables to T2 with parent versions.
590            if eval_key.starts_with("#/$params") {
591                let t2_dep_versions: HashMap<String, u64> = entry
592                    .dep_versions
593                    .iter()
594                    .map(|(path, &item_ver)| {
595                        let parent_ver = if path.starts_with("/$params") {
596                            item_ver // params_versions are global — same for both
597                        } else {
598                            // Use parent version.
599                            self.data_versions.get(path)
600                        };
601                        (path.clone(), parent_ver)
602                    })
603                    .collect();
604
605                let t2_entry = CacheEntry {
606                    dep_versions: t2_dep_versions,
607                    result: std::sync::Arc::clone(&entry.result),
608                    computed_for_item,
609                };
610                self.entries.insert(eval_key.to_string(), t2_entry);
611            }
612        } else {
613            self.entries.insert(eval_key.to_string(), entry);
614        }
615    }
616}
617
618/// Recursive helper to walk JSON structures and bump specific leaf versions where they differ
619pub(crate) fn diff_and_update_versions(
620    tracker: &mut VersionTracker,
621    pointer: &str,
622    old: &Value,
623    new: &Value,
624    source: &str,
625) {
626    let mut pointer_buf = String::with_capacity(128);
627    pointer_buf.push_str(pointer);
628    diff_and_update_versions_internal(tracker, &mut pointer_buf, old, new, source);
629}
630
631#[cfg(test)]
632mod cache_tests {
633    use super::{CacheEntry, EvalCache};
634    use indexmap::IndexSet;
635    use serde_json::json;
636    use std::collections::HashMap;
637    use std::sync::Arc;
638
639    #[test]
640    fn unchanged_active_item_reuses_global_table_with_item_dependency() {
641        let mut cache = EvalCache::new();
642        cache.set_active_item(1);
643
644        let eval_key = "#/$params/references/LOOKUP_RATE";
645        let deps = IndexSet::from_iter(["#/items/properties/benefit".to_string()]);
646        cache.entries.insert(
647            eval_key.to_string(),
648            CacheEntry {
649                dep_versions: HashMap::from([("/items/benefit".to_string(), 0)]),
650                result: Arc::new(json!([{"rate": 97}])),
651                computed_for_item: None,
652            },
653        );
654
655        assert_eq!(
656            cache.check_table_cache(eval_key, &deps),
657            Some(Arc::new(json!([{"rate": 97}]))),
658            "a scoped alias may reuse the parent result for its unchanged canonical item"
659        );
660    }
661
662    #[test]
663    fn changed_active_item_does_not_reuse_global_table_with_item_dependency() {
664        let mut cache = EvalCache::new();
665        cache.set_active_item(1);
666        cache
667            .subform_caches
668            .get_mut(&1)
669            .expect("active item cache must exist")
670            .data_versions
671            .bump("/items/benefit", "test item input change");
672
673        let eval_key = "#/$params/references/LOOKUP_RATE";
674        let deps = IndexSet::from_iter(["#/items/properties/benefit".to_string()]);
675        cache.entries.insert(
676            eval_key.to_string(),
677            CacheEntry {
678                dep_versions: HashMap::from([("/items/benefit".to_string(), 0)]),
679                result: Arc::new(json!([{"rate": 97}])),
680                computed_for_item: None,
681            },
682        );
683
684        assert!(
685            cache.check_table_cache(eval_key, &deps).is_none(),
686            "a changed item input must force item-scoped table recomputation"
687        );
688    }
689
690    #[test]
691    fn active_item_reuses_global_table_with_only_params_dependencies() {
692        let mut cache = EvalCache::new();
693        cache.set_active_item(1);
694
695        let eval_key = "#/$params/references/SHARED_RATE";
696        let deps = IndexSet::from_iter(["#/$params/others/currency".to_string()]);
697        cache.entries.insert(
698            eval_key.to_string(),
699            CacheEntry {
700                dep_versions: HashMap::from([("/$params/others/currency".to_string(), 0)]),
701                result: Arc::new(json!([{"rate": 10}])),
702                computed_for_item: None,
703            },
704        );
705
706        assert_eq!(
707            cache.check_table_cache(eval_key, &deps),
708            Some(Arc::new(json!([{"rate": 10}])))
709        );
710    }
711}
712
713fn diff_and_update_versions_internal(
714    tracker: &mut VersionTracker,
715    pointer: &mut String,
716    old: &Value,
717    new: &Value,
718    source: &str,
719) {
720    if old == new {
721        return;
722    }
723
724    if crate::utils::is_debug_cache_enabled() {
725        println!(
726            "[diff_and_update_versions_internal] {} pointer={}, old={:?}, new={:?}",
727            source, pointer, old, new
728        );
729    }
730
731    match (old, new) {
732        (Value::Object(a), Value::Object(b)) => {
733            for (key, a_val) in a {
734                if key == "$params" {
735                    continue;
736                }
737                let b_val = b.get(key).unwrap_or(&Value::Null);
738                if a_val == b_val {
739                    continue;
740                }
741
742                let old_len = pointer.len();
743                pointer.push('/');
744                if key.contains('~') || key.contains('/') {
745                    let escaped_key = key.replace('~', "~0").replace('/', "~1");
746                    pointer.push_str(&escaped_key);
747                } else {
748                    pointer.push_str(key);
749                }
750                diff_and_update_versions_internal(tracker, pointer, a_val, b_val, source);
751                pointer.truncate(old_len);
752            }
753
754            for (key, b_val) in b {
755                if key == "$params" || a.contains_key(key) {
756                    continue;
757                }
758                if b_val.is_null() {
759                    continue;
760                }
761
762                let old_len = pointer.len();
763                pointer.push('/');
764                if key.contains('~') || key.contains('/') {
765                    let escaped_key = key.replace('~', "~0").replace('/', "~1");
766                    pointer.push_str(&escaped_key);
767                } else {
768                    pointer.push_str(key);
769                }
770                diff_and_update_versions_internal(tracker, pointer, &Value::Null, b_val, source);
771                pointer.truncate(old_len);
772            }
773        }
774        (Value::Array(a), Value::Array(b)) => {
775            if a != b {
776                tracker.bump(pointer, source);
777            }
778            let max_len = a.len().max(b.len());
779            for i in 0..max_len {
780                let a_val = a.get(i).unwrap_or(&Value::Null);
781                let b_val = b.get(i).unwrap_or(&Value::Null);
782                if a_val == b_val {
783                    continue;
784                }
785                let old_len = pointer.len();
786                use std::fmt::Write;
787                write!(pointer, "/{}", i).unwrap();
788                diff_and_update_versions_internal(tracker, pointer, a_val, b_val, source);
789                pointer.truncate(old_len);
790            }
791        }
792        (old_val, new_val) => {
793            if old_val != new_val {
794                if crate::utils::is_debug_cache_enabled() {
795                    println!(
796                        "[store_cache] Catch-all for {}: old={}, new={}",
797                        pointer,
798                        match old_val {
799                            Value::Null => "Null",
800                            Value::Bool(_) => "Bool",
801                            Value::Number(_) => "Number",
802                            Value::String(_) => "String",
803                            Value::Array(_) => "Array",
804                            Value::Object(_) => "Object",
805                        },
806                        match new_val {
807                            Value::Null => "Null",
808                            Value::Bool(_) => "Bool",
809                            Value::Number(_) => "Number",
810                            Value::String(_) => "String",
811                            Value::Array(_) => "Array",
812                            Value::Object(_) => "Object",
813                        }
814                    );
815                }
816                tracker.bump(pointer, "diff_and_update_versions_internal");
817
818                // If either side contains nested structures (e.g. Object replaced by Null, or vice versa)
819                // we must recursively bump all paths inside them so targeted cache entries invalidate.
820                if old_val.is_object() || old_val.is_array() {
821                    traverse_and_bump(tracker, pointer, old_val);
822                }
823                if new_val.is_object() || new_val.is_array() {
824                    traverse_and_bump(tracker, pointer, new_val);
825                }
826            }
827        }
828    }
829}
830
831/// Recursively traverses a value and bumps the version for every nested path.
832/// Used when a structural type mismatch occurs (e.g., Object -> Null) so that
833/// cache entries depending on nested fields are correctly invalidated.
834#[cfg(test)]
835mod tests {
836    use super::VersionTracker;
837
838    #[test]
839    fn merge_excluding_prefix_keeps_item_versions_isolated() {
840        let mut item = VersionTracker::new();
841        item.bump("/items/flag", "test");
842
843        let mut parent = VersionTracker::new();
844        parent.bump("/users/profile/name", "test");
845        parent.bump("/items/flag", "test");
846
847        item.merge_excluding_prefix(&parent, "/items/");
848
849        assert_eq!(item.get("/users/profile/name"), 1);
850        assert_eq!(
851            item.get("/items/flag"),
852            1,
853            "another item's parent-tracker bump must not alter this item's version"
854        );
855    }
856}
857
858fn traverse_and_bump(tracker: &mut VersionTracker, pointer: &mut String, val: &Value) {
859    match val {
860        Value::Object(map) => {
861            for (key, v) in map {
862                if key == "$params" {
863                    continue; // Skip the special top-level params branch if it leaked here
864                }
865                let escaped_key = key.replace('~', "~0").replace('/', "~1");
866                let old_len = pointer.len();
867                pointer.push('/');
868                pointer.push_str(&escaped_key);
869                tracker.bump(pointer, "traverse_and_bump1");
870                traverse_and_bump(tracker, pointer, v);
871                pointer.truncate(old_len);
872            }
873        }
874        Value::Array(arr) => {
875            for (i, v) in arr.iter().enumerate() {
876                let old_len = pointer.len();
877                use std::fmt::Write;
878                write!(pointer, "/{}", i).unwrap();
879                tracker.bump(pointer, "traverse_and_bump2");
880                traverse_and_bump(tracker, pointer, v);
881                pointer.truncate(old_len);
882            }
883        }
884        _ => {}
885    }
886}