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