Skip to main content

json_eval_rs/jsoneval/
eval_cache.rs

1use indexmap::IndexSet;
2use serde_json::Value;
3use std::collections::{HashMap, HashSet};
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!("[store_cache] BUMPING for {} -> {} ({})", path, current + 1, source);
28        }
29        // We use actual data pointers here
30        self.versions.insert(path.to_string(), current + 1);
31    }
32
33    /// Merge version counters from `other`, taking the **maximum** for each path.
34    /// Using max (not insert) ensures that if this tracker already saw a higher version
35    /// for a path (e.g., from a previous subform evaluation round), it is never downgraded.
36    pub fn merge_from(&mut self, other: &VersionTracker) {
37        for (k, v) in &other.versions {
38            let current = self.versions.get(k).copied().unwrap_or(0);
39            self.versions.insert(k.clone(), current.max(*v));
40        }
41    }
42
43    /// Merge only `/$params`-prefixed version counters from `other` (max strategy).
44    /// Used when giving a per-item tracker the latest schema-level param versions
45    /// without absorbing data-path bumps that belong to other items.
46    pub fn merge_from_params(&mut self, other: &VersionTracker) {
47        for (k, v) in &other.versions {
48            if k.starts_with("/$params") {
49                let current = self.versions.get(k).copied().unwrap_or(0);
50                self.versions.insert(k.clone(), current.max(*v));
51            }
52        }
53    }
54
55    /// Returns true if any tracked path with the given prefix has been bumped (version > 0).
56    /// Used to gate table re-evaluation when item fields change without the item being new.
57    pub fn any_bumped_with_prefix(&self, prefix: &str) -> bool {
58        self.versions
59            .iter()
60            .any(|(k, &v)| k.starts_with(prefix) && v > 0)
61    }
62
63    /// Returns true if any path with the given prefix has a **higher** version than in `baseline`.
64    /// Unlike `any_bumped_with_prefix`, this detects only brand-new bumps from a specific diff
65    /// pass, ignoring historical bumps that were already present in the baseline.
66    pub fn any_newly_bumped_with_prefix(&self, prefix: &str, baseline: &VersionTracker) -> bool {
67        self.versions
68            .iter()
69            .any(|(k, &v)| k.starts_with(prefix) && v > baseline.get(k))
70    }
71
72    /// Returns an iterator over all (path, version) pairs, for targeted bump enumeration.
73    pub fn versions(&self) -> impl Iterator<Item = (&str, &u64)> {
74        self.versions.iter().map(|(k, v)| (k.as_str(), v))
75    }
76}
77
78/// A cached evaluation result with the specific dependency versions it was evaluated against
79#[derive(Clone)]
80pub struct CacheEntry {
81    pub dep_versions: HashMap<String, u64>,
82    pub result: Value,
83    /// The `active_item_index` this entry was computed under.
84    /// `None` = computed during main-form evaluation (safe to reuse across all items
85    /// provided the dep versions match). `Some(idx)` = computed for a specific item;
86    /// Tier-2 reuse is restricted to entries whose deps are entirely `$params`-scoped.
87    pub computed_for_item: Option<usize>,
88}
89
90/// Independent cache state for a single item in a subform array
91#[derive(Default, Clone)]
92pub struct SubformItemCache {
93    pub data_versions: VersionTracker,
94    pub entries: HashMap<String, CacheEntry>,
95    pub item_snapshot: Value,
96    /// Per-item snapshot of the evaluated schema captured after each evaluate_subform_item.
97    /// Allows get_evaluated_schema_subform to return the correct per-item values without
98    /// re-running the full evaluation pipeline in a shared subform context.
99    pub evaluated_schema: Option<Value>,
100}
101
102impl SubformItemCache {
103    pub fn new() -> Self {
104        Self {
105            data_versions: VersionTracker::new(),
106            entries: HashMap::new(),
107            item_snapshot: Value::Null,
108            evaluated_schema: None,
109        }
110    }
111}
112
113/// Primary cache structure for a JSON evaluation instance
114#[derive(Clone)]
115pub struct EvalCache {
116    pub data_versions: VersionTracker,
117    pub params_versions: VersionTracker,
118    pub entries: HashMap<String, CacheEntry>,
119
120    pub active_item_index: Option<usize>,
121    pub subform_caches: HashMap<usize, SubformItemCache>,
122
123    /// Monotonically increasing counter bumped whenever data_versions or params_versions change.
124    /// When `eval_generation == last_evaluated_generation`, all cache entries are guaranteed valid
125    /// and `evaluate_internal` can skip the full tree traversal.
126    pub eval_generation: u64,
127    pub last_evaluated_generation: u64,
128
129    /// Snapshot of the last fully-diffed main-form data payload.
130    /// Stored after each successful `evaluate_internal_with_new_data` call so the next
131    /// invocation can avoid an extra `snapshot_data_clone()` when computing the diff.
132    pub main_form_snapshot: Option<Value>,
133}
134
135impl Default for EvalCache {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl EvalCache {
142    pub fn new() -> Self {
143        Self {
144            data_versions: VersionTracker::new(),
145            params_versions: VersionTracker::new(),
146            entries: HashMap::new(),
147            active_item_index: None,
148            subform_caches: HashMap::new(),
149            eval_generation: 0,
150            last_evaluated_generation: u64::MAX, // force first evaluate_internal to run
151            main_form_snapshot: None,
152        }
153    }
154
155    pub fn clear(&mut self) {
156        self.data_versions = VersionTracker::new();
157        self.params_versions = VersionTracker::new();
158        self.entries.clear();
159        self.active_item_index = None;
160        self.subform_caches.clear();
161        self.eval_generation = 0;
162        self.last_evaluated_generation = u64::MAX;
163        self.main_form_snapshot = None;
164    }
165
166    /// Remove item caches for indices >= `current_count`.
167    /// Call this whenever the subform array length is known to have shrunk so that
168    /// stale per-item version trackers and cached entries do not linger in memory.
169    pub fn prune_subform_caches(&mut self, current_count: usize) {
170        self.subform_caches.retain(|&idx, _| idx < current_count);
171    }
172
173    /// Invalidate all `$params`-scoped table cache entries for a specific item.
174    ///
175    /// Called when a brand-new subform item is introduced so that `$params` tables
176    /// that aggregate array data (e.g. WOP_RIDERS) are forced to recompute instead
177    /// of returning stale results cached from a prior main-form evaluation that ran
178    /// when the item was absent (and thus saw zero/null for that item's values).
179    pub fn invalidate_params_tables_for_item(&mut self, idx: usize, table_keys: &[String]) {
180        // Bump params_versions so T2 global entries for these tables are stale.
181        for key in table_keys {
182            let data_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(key);
183            self.params_versions.bump(&data_path, "invalidate_params_tables_for_item");
184            self.eval_generation += 1;
185        }
186
187        // Evict matching T1 (item-level) entries so they are not reused.
188        if let Some(item_cache) = self.subform_caches.get_mut(&idx) {
189            for key in table_keys {
190                item_cache.entries.remove(key);
191            }
192        }
193    }
194
195    /// Returns true if evaluate_internal must run (versions changed since last full evaluation)
196    pub fn needs_full_evaluation(&self) -> bool {
197        self.eval_generation != self.last_evaluated_generation
198    }
199
200    /// Call after evaluate_internal completes successfully to mark the generation stable
201    pub fn mark_evaluated(&mut self) {
202        self.last_evaluated_generation = self.eval_generation;
203    }
204
205    pub(crate) fn ensure_active_item_cache(&mut self, idx: usize) {
206        self.subform_caches
207            .entry(idx)
208            .or_insert_with(SubformItemCache::new);
209    }
210
211    pub fn set_active_item(&mut self, idx: usize) {
212        self.active_item_index = Some(idx);
213        self.ensure_active_item_cache(idx);
214    }
215
216    pub fn clear_active_item(&mut self) {
217        self.active_item_index = None;
218    }
219
220    /// Recursively diffs `old` against `new` and bumps version for every changed data path scalar.
221    pub fn store_snapshot_and_diff_versions(&mut self, old: &Value, new: &Value) {
222        if let Some(idx) = self.active_item_index {
223            self.ensure_active_item_cache(idx);
224            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
225            diff_and_update_versions(&mut sub_cache.data_versions, "", old, new, "subform store_snapshot_and_diff_versions");
226            sub_cache.item_snapshot = new.clone();
227        } else {
228            diff_and_update_versions(&mut self.data_versions, "", old, new, "store_snapshot_and_diff_versions");
229        }
230    }
231
232    pub fn get_active_snapshot(&self) -> Value {
233        if let Some(idx) = self.active_item_index {
234            self.subform_caches
235                .get(&idx)
236                .map(|c| c.item_snapshot.clone())
237                .unwrap_or(Value::Null)
238        } else {
239            Value::Null
240        }
241    }
242
243    pub fn diff_active_item(
244        &mut self,
245        field_key: &str,
246        old_sub_data: &Value,
247        new_sub_data: &Value,
248    ) {
249        if let Some(idx) = self.active_item_index {
250            self.ensure_active_item_cache(idx);
251            let sub_cache = self.subform_caches.get_mut(&idx).unwrap();
252
253            // Diff ONLY the localized item part, skipping the massive parent tree
254            let empty = Value::Null;
255            let old_item = old_sub_data.get(field_key).unwrap_or(&empty);
256            let new_item = new_sub_data.get(field_key).unwrap_or(&empty);
257
258            diff_and_update_versions(
259                &mut sub_cache.data_versions,
260                &format!("/{}", field_key),
261                old_item,
262                new_item,
263                format!("diff_active_item {}", field_key).as_str(),
264            );
265            sub_cache.item_snapshot = new_sub_data.clone();
266        }
267    }
268
269    pub fn bump_data_version(&mut self, data_path: &str) {
270        // Always signal that something changed so the parent's needs_full_evaluation()
271        // returns true even when the bump was item-scoped.
272        self.eval_generation += 1;
273        if let Some(idx) = self.active_item_index {
274            if let Some(cache) = self.subform_caches.get_mut(&idx) {
275                cache.data_versions.bump(data_path, "bump_data_version1");
276            }
277        } else {
278            self.data_versions.bump(data_path, "bump_data_version2");
279        }
280    }
281
282    pub fn bump_params_version(&mut self, data_path: &str) {
283        self.params_versions.bump(data_path, "bump_params_version");
284        self.eval_generation += 1;
285    }
286
287    /// Check if the `eval_key` result can be safely bypassed because dependencies are unchanged.
288    ///
289    /// Two-tier lookup:
290    /// - Tier 1: item-scoped entries in `subform_caches[idx]` — checked first when an active item is set
291    /// - Tier 2: global `self.entries` — allows Run 1 (main form) results to be reused in Run 2 (subform)
292    pub fn check_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
293        if let Some(idx) = self.active_item_index {
294            // Tier 1: item-specific entries (always safe to reuse for the same index)
295            if let Some(cache) = self.subform_caches.get(&idx) {
296                if let Some(hit) =
297                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
298                {
299                    if crate::utils::is_debug_cache_enabled() {
300                        println!("Cache HIT [T1 idx={}] {}", idx, eval_key);
301                    }
302                    return Some(hit);
303                }
304            }
305
306            // Tier 2: global entries (may have been stored by main-form Run 1).
307            // Only reuse if the entry is index-safe:
308            //   (a) computed with no active item (main-form result), OR
309            //   (b) computed for the same item index, OR
310            //   (c) all deps are $params-scoped (truly index-independent)
311            let item_data_versions = self
312                .subform_caches
313                .get(&idx)
314                .map(|c| &c.data_versions)
315                .unwrap_or(&self.data_versions);
316
317            if let Some(entry) = self.entries.get(eval_key) {
318                let index_safe = match entry.computed_for_item {
319                    // Main-form entry (no active item when stored): only safe if ALL its deps
320                    // are $params-scoped. Non-$params deps (like /riders/prem_pay_period) mean
321                    // the formula result is rider-specific — using it for a different rider via
322                    // the batch fast path would corrupt eval_data and poison subsequent formulas.
323                    None => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
324                    Some(stored_idx) if stored_idx == idx => true,
325                    _ => entry.dep_versions.keys().all(|p| p.starts_with("/$params")),
326                };
327                if index_safe {
328                    let result =
329                        self.validate_entry(eval_key, deps, &self.entries, item_data_versions);
330                    if result.is_some() {
331                        if crate::utils::is_debug_cache_enabled() {
332                            println!(
333                                "Cache HIT [T2 idx={} for={:?}] {}",
334                                idx, entry.computed_for_item, eval_key
335                            );
336                        }
337                    }
338                    return result;
339                }
340            }
341
342            None
343        } else {
344            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
345        }
346    }
347
348    /// Specialized cache check for `$params`-scoped table evaluations.
349    ///
350    /// Tables in `$params/references/` aggregate cross-item data and produce a single result
351    /// that is independent of which subform item is currently active. The standard `check_cache`
352    /// blocks T2 reuse for entries whose deps include non-`$params` paths (e.g. `/riders/...`),
353    /// because scalar formula results are item-specific. But table results are global: the same
354    /// 734-row array is correct for rider 0, rider 1, and rider 2 alike.
355    ///
356    /// This method validates the global entry directly — using `item_data_versions` for
357    /// non-`$params` deps — without the `index_safe` gate, allowing the expensive table forward/
358    /// backward pass to be skipped when inputs have not changed.
359    pub fn check_table_cache(&self, eval_key: &str, deps: &IndexSet<String>) -> Option<Value> {
360        if let Some(idx) = self.active_item_index {
361            // Tier 1: item-scoped entries first (unlikely for $params tables but check anyway)
362            if let Some(cache) = self.subform_caches.get(&idx) {
363                if let Some(hit) =
364                    self.validate_entry(eval_key, deps, &cache.entries, &cache.data_versions)
365                {
366                    if crate::utils::is_debug_cache_enabled() {
367                        println!("Cache HIT [T1 table idx={}] {}", idx, eval_key);
368                    }
369                    return Some(hit);
370                }
371            }
372
373            // Tier 2: validate the global entry against the parent main-form tracker.
374            //
375            // Global $params table entries are stored using `self.data_versions` (store_cache
376            // with no active item). When a rider field (e.g. `riders.sa`) changes via
377            // `with_item_cache_swap`, the newly-bumped paths are propagated into
378            // `parent_cache.data_versions` before the swap. This ensures the T2 check
379            // here correctly sees the change without needing MaxVersionTracker, which
380            // would pick up historical per-rider bumps and cause false misses.
381            let result = self.validate_entry(eval_key, deps, &self.entries, &self.data_versions);
382            if result.is_some() {
383                if crate::utils::is_debug_cache_enabled() {
384                    println!("Cache HIT [T2 table idx={}] {}", idx, eval_key);
385                }
386            }
387            result
388        } else {
389            self.validate_entry(eval_key, deps, &self.entries, &self.data_versions)
390        }
391    }
392
393    fn validate_entry(
394        &self,
395        eval_key: &str,
396        deps: &IndexSet<String>,
397        entries: &HashMap<String, CacheEntry>,
398        data_versions: &VersionTracker,
399    ) -> Option<Value> {
400        let entry = entries.get(eval_key)?;
401        for dep in deps {
402            let data_dep_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(dep);
403
404            let current_ver = if data_dep_path.starts_with("/$params") {
405                self.params_versions.get(&data_dep_path)
406            } else {
407                data_versions.get(&data_dep_path)
408            };
409
410            if let Some(&cached_ver) = entry.dep_versions.get(data_dep_path.as_ref()) {
411                if current_ver != cached_ver {
412                    if crate::utils::is_debug_cache_enabled() {
413                        println!(
414                            "Cache MISS {}: dep {} changed ({} -> {})",
415                            eval_key, data_dep_path, cached_ver, current_ver
416                        );
417                    }
418                    return None;
419                }
420            } else {
421                if crate::utils::is_debug_cache_enabled() {
422                    println!(
423                        "Cache MISS {}: dep {} missing from cache entry",
424                        eval_key, data_dep_path
425                    );
426                }
427                return None;
428            }
429        }
430        if crate::utils::is_debug_cache_enabled() {
431            println!("Cache HIT {}", eval_key);
432        }
433        Some(entry.result.clone())
434    }
435
436    /// Store the newly evaluated value and snapshot the dependency versions.
437    ///
438    /// Storage strategy:
439    /// - When an active item is set, store into `subform_caches[idx].entries` (item-scoped).
440    ///   This isolates per-rider results so different items with different data don't collide.
441    /// - The global `self.entries` is written only from the main form (no active item).
442    ///   Subforms can reuse these via the Tier 2 fallback in `check_cache`.
443    pub fn store_cache(&mut self, eval_key: &str, deps: &IndexSet<String>, result: Value) {
444        // Phase 1: snapshot dep versions using the correct data_versions tracker.
445        // Always use item data_versions for T1; for T2 promotion of $params tables we
446        // build a separate snapshot using PARENT data_versions (see Phase 2 note below).
447        let mut dep_versions = HashMap::with_capacity(deps.len());
448        {
449            let data_versions = if let Some(idx) = self.active_item_index {
450                self.ensure_active_item_cache(idx);
451                &self.subform_caches[&idx].data_versions
452            } else {
453                &self.data_versions
454            };
455
456            for dep in deps {
457                let data_dep_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(dep);
458                let ver = if data_dep_path.starts_with("/$params") {
459                    self.params_versions.get(&data_dep_path)
460                } else {
461                    data_versions.get(&data_dep_path)
462                };
463                dep_versions.insert(data_dep_path.into_owned(), ver);
464            }
465        }
466
467        // Phase 2: insert into the correct tier, tagging with the current item index.
468        let computed_for_item = self.active_item_index;
469
470        // For $params-scoped entries, only bump params_versions when the result value
471        // actually changed relative to the canonical cached entry.
472        //
473        // For T1 stores (active_item set), we compare against T2 (global) first.
474        // T2 is the authoritative reference: if T2 already holds the same value,
475        // params_versions was already bumped for it — bumping again per-rider causes
476        // an O(riders × $params_formulas) version explosion that makes every downstream
477        // formula (TOTAL_WOP_SA, WOP_MULTIPLIER, COMMISSION_FACTOR…) miss on each rider.
478        if eval_key.starts_with("#/$params") {
479            let existing_result: Option<&Value> = if let Some(idx) = self.active_item_index {
480                // Check T2 (global) first — if T2 has same value, no need to bump again.
481                self.entries.get(eval_key).map(|e| &e.result).or_else(|| {
482                    self.subform_caches
483                        .get(&idx)
484                        .and_then(|c| c.entries.get(eval_key))
485                        .map(|e| &e.result)
486                })
487            } else {
488                self.entries.get(eval_key).map(|e| &e.result)
489            };
490
491            let value_changed = existing_result.map_or(true, |r| r != &result);
492
493            if value_changed {
494                let data_path = crate::jsoneval::path_utils::schema_path_to_data_pointer(eval_key);
495
496                // Bump the explicit path and its table-level parent.
497                // Stop at slash_count < 3 — never bump /$params/others or /$params itself.
498                let mut current_path = data_path.as_ref();
499                let mut slash_count = current_path.matches('/').count();
500
501                while slash_count >= 3 {
502                    self.params_versions.bump(current_path, "store_cache");
503                    if let Some(last_slash) = current_path.rfind('/') {
504                        current_path = &current_path[..last_slash];
505                        slash_count -= 1;
506                    } else {
507                        break;
508                    }
509                }
510
511                self.eval_generation += 1;
512            }
513        }
514
515        let entry = CacheEntry {
516            dep_versions,
517            result,
518            computed_for_item,
519        };
520
521        if let Some(idx) = self.active_item_index {
522            // Store item-scoped: isolates per-rider entries so riders with different data don't collide
523            self.subform_caches
524                .get_mut(&idx)
525                .unwrap()
526                .entries
527                .insert(eval_key.to_string(), entry.clone());
528
529            // For $params-scoped tables, also promote to T2 (global entries).
530            // CRITICAL: T2 must be validated by check_table_cache using PARENT data_versions,
531            // not item data_versions. If we promoted the item-dep snapshot directly:
532            //   - T2 dep[/riders/code] = item_data_versions[/riders/code] = 1 (bumped for this rider)
533            //   - check_table_cache validates with parent data_versions[/riders/code] = 0
534            //   - 1 ≠ 0 → guaranteed miss for every other rider
535            // Fix: rebuild dep_versions using PARENT data_versions for non-$params paths.
536            // T1 retains item data_versions (correct for per-item scoping).
537            if eval_key.starts_with("#/$params") {
538                let t2_dep_versions: HashMap<String, u64> = entry
539                    .dep_versions
540                    .iter()
541                    .map(|(path, &item_ver)| {
542                        let parent_ver = if path.starts_with("/$params") {
543                            item_ver // params_versions are global — same for both
544                        } else {
545                            // Use parent data_versions, which is what check_table_cache reads
546                            self.data_versions.get(path)
547                        };
548                        (path.clone(), parent_ver)
549                    })
550                    .collect();
551
552                let t2_entry = CacheEntry {
553                    dep_versions: t2_dep_versions,
554                    result: entry.result.clone(),
555                    computed_for_item,
556                };
557                self.entries.insert(eval_key.to_string(), t2_entry);
558            }
559        } else {
560            self.entries.insert(eval_key.to_string(), entry);
561        }
562    }
563}
564
565/// Recursive helper to walk JSON structures and bump specific leaf versions where they differ
566pub(crate) fn diff_and_update_versions(
567    tracker: &mut VersionTracker,
568    pointer: &str,
569    old: &Value,
570    new: &Value,
571    source: &str,
572) {
573    let mut pointer_buf = String::with_capacity(128);
574    pointer_buf.push_str(pointer);
575    diff_and_update_versions_internal(tracker, &mut pointer_buf, old, new, source);
576}
577
578fn diff_and_update_versions_internal(
579    tracker: &mut VersionTracker,
580    pointer: &mut String,
581    old: &Value,
582    new: &Value,
583    source: &str,
584) {
585    if old == new {
586        return;
587    }
588
589    if crate::utils::is_debug_cache_enabled() {
590        println!("[diff_and_update_versions_internal] {} pointer={}, old={:?}, new={:?}", source, pointer, old, new);
591    }
592
593    match (old, new) {
594        (Value::Object(a), Value::Object(b)) => {
595            let mut keys = HashSet::new();
596            for k in a.keys() {
597                keys.insert(k.as_str());
598            }
599            for k in b.keys() {
600                keys.insert(k.as_str());
601            }
602
603            for key in keys {
604                // Do not deep-diff $params at any nesting level — it is manually tracked
605                // via bump_params_version on evaluations. Skipping at root-only was insufficient
606                // when item data is diffed via a non-empty pointer prefix.
607                if key == "$params" {
608                    continue;
609                }
610
611                let a_val = a.get(key).unwrap_or(&Value::Null);
612                let b_val = b.get(key).unwrap_or(&Value::Null);
613
614                let escaped_key = key.replace('~', "~0").replace('/', "~1");
615                let old_len = pointer.len();
616                pointer.push('/');
617                pointer.push_str(&escaped_key);
618                diff_and_update_versions_internal(tracker, pointer, a_val, b_val, source);
619                pointer.truncate(old_len);
620            }
621        }
622        (Value::Array(a), Value::Array(b)) => {
623            let max_len = a.len().max(b.len());
624            for i in 0..max_len {
625                let a_val = a.get(i).unwrap_or(&Value::Null);
626                let b_val = b.get(i).unwrap_or(&Value::Null);
627                let old_len = pointer.len();
628                use std::fmt::Write;
629                write!(pointer, "/{}", i).unwrap();
630                diff_and_update_versions_internal(tracker, pointer, a_val, b_val, source);
631                pointer.truncate(old_len);
632            }
633        }
634        (old_val, new_val) => {
635            if old_val != new_val {
636                if crate::utils::is_debug_cache_enabled() { println!("[store_cache] Catch-all for {}: old={}, new={}", pointer, match old_val { Value::Null => "Null", Value::Bool(_) => "Bool", Value::Number(_) => "Number", Value::String(_) => "String", Value::Array(_) => "Array", Value::Object(_) => "Object" }, match new_val { Value::Null => "Null", Value::Bool(_) => "Bool", Value::Number(_) => "Number", Value::String(_) => "String", Value::Array(_) => "Array", Value::Object(_) => "Object" }); } tracker.bump(pointer, "diff_and_update_versions_internal");
637
638                // If either side contains nested structures (e.g. Object replaced by Null, or vice versa)
639                // we must recursively bump all paths inside them so targeted cache entries invalidate.
640                if old_val.is_object() || old_val.is_array() {
641                    traverse_and_bump(tracker, pointer, old_val);
642                }
643                if new_val.is_object() || new_val.is_array() {
644                    traverse_and_bump(tracker, pointer, new_val);
645                }
646            }
647        }
648    }
649}
650
651/// Recursively traverses a value and bumps the version for every nested path.
652/// Used when a structural type mismatch occurs (e.g., Object -> Null) so that
653/// cache entries depending on nested fields are correctly invalidated.
654fn traverse_and_bump(tracker: &mut VersionTracker, pointer: &mut String, val: &Value) {
655    match val {
656        Value::Object(map) => {
657            for (key, v) in map {
658                if key == "$params" {
659                    continue; // Skip the special top-level params branch if it leaked here
660                }
661                let escaped_key = key.replace('~', "~0").replace('/', "~1");
662                let old_len = pointer.len();
663                pointer.push('/');
664                pointer.push_str(&escaped_key);
665                tracker.bump(pointer, "traverse_and_bump1");
666                traverse_and_bump(tracker, pointer, v);
667                pointer.truncate(old_len);
668            }
669        }
670        Value::Array(arr) => {
671            for (i, v) in arr.iter().enumerate() {
672                let old_len = pointer.len();
673                use std::fmt::Write;
674                write!(pointer, "/{}", i).unwrap();
675                tracker.bump(pointer, "traverse_and_bump2");
676                traverse_and_bump(tracker, pointer, v);
677                pointer.truncate(old_len);
678            }
679        }
680        _ => {}
681    }
682}