Skip to main content

arete_server/
cache.rs

1//! Entity cache for snapshot-on-subscribe functionality.
2//!
3//! This module provides an `EntityCache` that maintains full projected entities
4//! in memory with LRU eviction. When a new client subscribes, they receive
5//! cached snapshots immediately rather than waiting for the next live mutation.
6
7use crate::mutation_batch::SlotIndexDomain;
8use arete_interpreter::AccountPosition;
9use lru::LruCache;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::BTreeMap;
13use std::collections::HashMap;
14use std::sync::Arc;
15use tokio::sync::RwLock;
16
17const DEFAULT_MAX_ENTITIES_PER_VIEW: usize = 500;
18const DEFAULT_MAX_ARRAY_LENGTH: usize = 100;
19const DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE: usize = 50;
20const DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE: usize = 100;
21/// Evicted keys remembered per cached entity, for a source that does not mark
22/// creations (see [`PatchOrigin::Unknown`]). Such a source can keep far more
23/// entities than a view caches (a VM keeps 2,500 per state table by default
24/// against 500 here), so a key the cache evicted can keep receiving patches
25/// long afterwards; eight times the cache bound covers the VM's default table
26/// with room to spare. A key evicted longer ago than that is taken for new.
27///
28/// A source that marks creations needs no memory: an unmarked patch for a key
29/// a view does not hold is refused however long ago the key left, or whether
30/// it was ever there.
31const EVICTED_KEYS_PER_CACHED_ENTITY: usize = 8;
32
33/// Whether a patch creates its entity, as far as its source says.
34///
35/// Every patch carries only the fields that changed. For a key a view holds
36/// that is all it needs; for one it does not hold, the patch is the entity
37/// only if it created it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PatchOrigin {
40    /// The source marked the patch as its entity's creation
41    /// ([`arete_interpreter::WholeEntity::Created`]), so it is all of the
42    /// entity.
43    Creation,
44    /// The source marks every creation and did not mark this patch, so it
45    /// changes an entity created earlier and is only part of it. A VM linked
46    /// through [`crate::EntityResync`] is such a source.
47    Change,
48    /// The source does not mark creations. A patch for a key the view does not
49    /// hold is taken for a new entity unless the view remembers evicting it.
50    Unknown,
51}
52
53/// What [`EntityCache::upsert_with_append`] did with a patch.
54#[derive(Debug, Clone, PartialEq)]
55pub enum CacheWrite {
56    /// Merged into the entity the cache already held.
57    Merged,
58    /// Stored as a new entity: the patch created it, or its source does not
59    /// say and the cache has no record of the key.
60    Created,
61    /// Not stored. The cache does not hold the key and the patch carries only
62    /// the fields that changed — part of an entity, not one: its source marks
63    /// creations and did not mark this one, or the cache evicted the key. The
64    /// patch is handed back for callers that hold a full copy elsewhere.
65    Refused { patch: Value },
66}
67
68/// One view's cached entities and the keys it evicted to stay bounded.
69///
70/// Both maps are unbounded `LruCache`s capped by hand: a bounded one
71/// allocates its full capacity up front, which for the evicted-key memory is
72/// several times the entity bound, per view, whether or not anything is ever
73/// evicted from it.
74struct ViewEntries {
75    max_entities: usize,
76    entities: LruCache<String, Value>,
77    /// Keys evicted for space, most recently evicted or refused first.
78    /// Allocated on the first eviction, and never for a view fed by a source
79    /// that marks creations.
80    evicted: Option<LruCache<String, ()>>,
81    /// Whether a source that marks creations writes to this view. Such a
82    /// source never consults `evicted`.
83    creations_marked: bool,
84    // Recent authoritative account positions and deletion barriers survive
85    // snapshots. They are intentionally separate from entity `_seq`, whose
86    // offset may instead be an instruction txn_index.
87    lifetimes: LruCache<String, EntityLifetimeCheckpoint>,
88}
89
90impl ViewEntries {
91    fn new(max_entities: usize) -> Self {
92        assert!(max_entities > 0, "max_entities_per_view must be > 0");
93        Self {
94            max_entities,
95            entities: LruCache::unbounded(),
96            evicted: None,
97            creations_marked: false,
98            lifetimes: LruCache::unbounded(),
99        }
100    }
101
102    /// Note a patch from a source that marks creations: this view no longer
103    /// needs to remember its evictions.
104    fn mark_creations(&mut self) {
105        self.creations_marked = true;
106        self.evicted = None;
107    }
108
109    /// Store `entity` under `key`, remembering whatever it pushes out.
110    fn store(&mut self, key: String, entity: Value) {
111        self.forget_evicted(&key);
112        self.entities.put(key, entity);
113        while self.entities.len() > self.max_entities {
114            match self.entities.pop_lru() {
115                Some((evicted, _)) => self.remember_evicted(evicted),
116                None => break,
117            }
118        }
119    }
120
121    fn remember_evicted(&mut self, key: String) {
122        if self.creations_marked {
123            return;
124        }
125        let capacity = self
126            .max_entities
127            .saturating_mul(EVICTED_KEYS_PER_CACHED_ENTITY);
128        let evicted = self.evicted.get_or_insert_with(LruCache::unbounded);
129        evicted.put(key, ());
130        while evicted.len() > capacity {
131            evicted.pop_lru();
132        }
133    }
134
135    fn forget_evicted(&mut self, key: &str) {
136        if let Some(evicted) = self.evicted.as_mut() {
137            evicted.pop(key);
138        }
139    }
140
141    /// Whether `key` was evicted and is still remembered. Asking refreshes
142    /// it, so an evicted key that keeps changing is not forgotten.
143    fn was_evicted(&mut self, key: &str) -> bool {
144        self.evicted
145            .as_mut()
146            .is_some_and(|evicted| evicted.get(key).is_some())
147    }
148
149    fn accepts(
150        &self,
151        key: &str,
152        creation: bool,
153        account_position: Option<AccountPosition>,
154        source_seq: Option<&str>,
155        source_domain: Option<SlotIndexDomain>,
156    ) -> bool {
157        if let Some(checkpoint) = self.lifetimes.peek(key) {
158            match (checkpoint.account_position, account_position) {
159                (Some(previous), Some(incoming)) if incoming <= previous => return false,
160                // Once a producer opts into authoritative account ordering it
161                // must carry it on every later account-owned lifetime change.
162                (Some(_), None) if creation || checkpoint.deleted => return false,
163                _ => {}
164            }
165            if checkpoint.deleted && !creation {
166                return false;
167            }
168        }
169
170        if account_position.is_none() && source_domain.is_some() && source_seq.is_some() {
171            return self.accepts_source_ordering(key, source_seq, source_domain, !creation);
172        }
173        true
174    }
175
176    fn latest_source_seq<'a>(
177        &'a self,
178        key: &str,
179        source_domain: Option<SlotIndexDomain>,
180    ) -> Option<&'a str> {
181        let checkpoint = self.lifetimes.peek(key);
182        // An account cursor and an instruction/source cursor are independent.
183        // In particular, do not recover the account update's `_seq` from the
184        // entity and compare its write version with an instruction index.
185        if checkpoint.is_some_and(|checkpoint| checkpoint.account_position.is_some()) {
186            return checkpoint.and_then(|checkpoint| checkpoint.source_seq(source_domain));
187        }
188        let checkpoint = checkpoint.and_then(|checkpoint| checkpoint.source_seq(source_domain));
189        // Only the legacy domain may recover ordering from an entity written
190        // before explicit source domains were recorded. An entity `_seq`
191        // alone cannot say whether its offset is an account write version,
192        // instruction index or resolver counter.
193        if source_domain != Some(SlotIndexDomain::Legacy) {
194            return checkpoint;
195        }
196        let entity = self
197            .entities
198            .peek(key)
199            .and_then(|entity| entity.get("_seq"))
200            .and_then(Value::as_str);
201        match (checkpoint, entity) {
202            (Some(left), Some(right)) if cmp_seq(left, right).is_lt() => Some(right),
203            (Some(left), _) => Some(left),
204            (None, entity) => entity,
205        }
206    }
207
208    fn accepts_source_ordering(
209        &self,
210        key: &str,
211        incoming: Option<&str>,
212        source_domain: Option<SlotIndexDomain>,
213        allow_equal: bool,
214    ) -> bool {
215        match (self.latest_source_seq(key, source_domain), incoming) {
216            (Some(previous), Some(incoming)) => {
217                let ordering = cmp_seq(incoming, previous);
218                ordering.is_gt() || (allow_equal && ordering.is_eq())
219            }
220            // Once a markerless source supplies ordering, an unsequenced
221            // lifetime change cannot prove that it is newer.
222            (Some(_), None) => false,
223            (None, _) => true,
224        }
225    }
226
227    fn trim_lifetimes(&mut self) {
228        while self.lifetimes.len()
229            > self
230                .max_entities
231                .saturating_mul(EVICTED_KEYS_PER_CACHED_ENTITY)
232        {
233            self.lifetimes.pop_lru();
234        }
235    }
236}
237
238/// The authoritative account lifetime known for one projected entity.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct EntityLifetimeCheckpoint {
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub account_position: Option<AccountPosition>,
244    /// Ordering for producers that do not carry an authoritative account
245    /// position. This is never compared with `account_position`.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub source_seq: Option<String>,
248    /// Explicit producer-local cursors. Each domain is compared only with
249    /// itself, so resolver counters cannot suppress later instructions.
250    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
251    pub source_sequences: BTreeMap<SlotIndexDomain, String>,
252    pub deleted: bool,
253}
254
255impl EntityLifetimeCheckpoint {
256    fn source_seq(&self, source_domain: Option<SlotIndexDomain>) -> Option<&str> {
257        match source_domain {
258            Some(SlotIndexDomain::Legacy) | None => self
259                .source_sequences
260                .get(&SlotIndexDomain::Legacy)
261                .map(String::as_str)
262                .or(self.source_seq.as_deref()),
263            Some(domain) => self.source_sequences.get(&domain).map(String::as_str),
264        }
265    }
266}
267
268/// `(view, key, checkpoint)` in most-recently-changed order.
269pub type EntityLifetimes = Vec<(String, String, EntityLifetimeCheckpoint)>;
270
271/// Independent ordering metadata attached to one projected cache write.
272#[derive(Debug, Clone, Copy, Default)]
273pub struct LifetimeOrdering<'a> {
274    pub account_position: Option<AccountPosition>,
275    pub source_seq: Option<&'a str>,
276    /// The producer-local domain in which `source_seq` is comparable.
277    /// `None` deliberately disables source recency checks.
278    pub source_domain: Option<SlotIndexDomain>,
279}
280
281/// Legacy `(view, key, _seq)` barriers, retained only to restore snapshots
282/// written before account positions had their own ordering domain.
283pub type EntityTombstones = Vec<(String, String, Option<String>)>;
284
285/// Compare two `_seq` values numerically.
286/// `_seq` format is "{slot}:{offset}" where slot is not zero-padded.
287/// This handles digit-count boundaries correctly (e.g., 99999999 < 100000000).
288pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
289    fn parse(s: &str) -> (u64, u64) {
290        let mut parts = s.splitn(2, ':');
291        let slot = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
292        let offset = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
293        (slot, offset)
294    }
295    parse(a).cmp(&parse(b))
296}
297
298fn account_position_from_seq(seq: &str) -> Option<AccountPosition> {
299    let mut parts = seq.splitn(2, ':');
300    Some(AccountPosition::new(
301        parts.next()?.parse().ok()?,
302        parts.next()?.parse().ok()?,
303    ))
304}
305
306/// Configuration for the entity cache
307#[derive(Debug, Clone)]
308pub struct EntityCacheConfig {
309    /// Maximum number of entities to cache per view
310    pub max_entities_per_view: usize,
311    /// Maximum array length before oldest elements are evicted
312    pub max_array_length: usize,
313    /// Number of entities to send in the first snapshot batch (for fast initial render)
314    pub initial_snapshot_batch_size: usize,
315    /// Number of entities to send in subsequent snapshot batches
316    pub subsequent_snapshot_batch_size: usize,
317}
318
319impl Default for EntityCacheConfig {
320    fn default() -> Self {
321        Self {
322            max_entities_per_view: DEFAULT_MAX_ENTITIES_PER_VIEW,
323            max_array_length: DEFAULT_MAX_ARRAY_LENGTH,
324            initial_snapshot_batch_size: DEFAULT_INITIAL_SNAPSHOT_BATCH_SIZE,
325            subsequent_snapshot_batch_size: DEFAULT_SUBSEQUENT_SNAPSHOT_BATCH_SIZE,
326        }
327    }
328}
329
330/// Entity cache that maintains full projected entities with LRU eviction.
331///
332/// The cache is populated as mutations flow through the projector, regardless
333/// of subscriber state. When a new subscriber connects, they receive snapshots
334/// of all cached entities for their requested view.
335///
336/// # Only whole entities
337///
338/// Source mutations carry only the fields that changed. A patch for a key a
339/// view holds merges into the whole entity. A patch for a key it does not
340/// hold is stored only if it is the whole entity: storing a few changed
341/// fields would serve them as though they were the entity, in snapshots and
342/// in every derived view.
343///
344/// A VM linked through [`crate::EntityResync`] marks the mutation that creates
345/// each entity ([`PatchOrigin::Creation`]), which is stored as-is. Any other
346/// patch from it for a key a view does not hold ([`PatchOrigin::Change`]) is
347/// refused with [`CacheWrite::Refused`] — whether the view evicted the key or
348/// never held it, as after a restore. The projector then asks the VM for the
349/// whole entity, which follows in the VM's next batch and is stored with
350/// [`EntityCache::store_whole`]. That holds however many entities the VM
351/// keeps and however long ago the view evicted the key.
352///
353/// A source that marks nothing ([`PatchOrigin::Unknown`]) leaves a new key and
354/// an evicted one looking alike. Each view therefore remembers the keys it
355/// evicted (bounded, most recent first) and refuses patches for them; a key
356/// evicted so long ago that it has been forgotten is taken for new. A source
357/// delete also ends an eviction.
358#[derive(Clone)]
359pub struct EntityCache {
360    /// view_id -> LRU<entity_key, full_projected_entity>, plus evicted keys
361    caches: Arc<RwLock<HashMap<String, ViewEntries>>>,
362    config: EntityCacheConfig,
363}
364
365impl EntityCache {
366    /// Create a new entity cache with default configuration
367    pub fn new() -> Self {
368        Self::with_config(EntityCacheConfig::default())
369    }
370
371    /// Create a new entity cache with custom configuration
372    pub fn with_config(config: EntityCacheConfig) -> Self {
373        Self {
374            caches: Arc::new(RwLock::new(HashMap::new())),
375            config,
376        }
377    }
378
379    /// Maximum number of entities cached per view.
380    ///
381    /// Derived sorted view caches are bounded by the same value so they never
382    /// hold more entities than their source view caches.
383    pub fn max_entities_per_view(&self) -> usize {
384        self.config.max_entities_per_view
385    }
386
387    /// Write a patch from a source that does not mark creations
388    /// ([`PatchOrigin::Unknown`]).
389    pub async fn upsert(&self, view_id: &str, key: &str, patch: Value) -> CacheWrite {
390        self.upsert_with_append(view_id, key, patch, &[], PatchOrigin::Unknown)
391            .await
392    }
393
394    /// Merge `patch` into the cached entity, store it as a new entity, or
395    /// refuse it as part of an entity the cache does not hold (see the type
396    /// docs).
397    pub async fn upsert_with_append(
398        &self,
399        view_id: &str,
400        key: &str,
401        patch: Value,
402        append_paths: &[String],
403        origin: PatchOrigin,
404    ) -> CacheWrite {
405        self.upsert_with_lifetime(view_id, key, patch, append_paths, origin, None)
406            .await
407    }
408
409    /// Apply a projected patch with optional authoritative account ordering.
410    /// `_seq` is consulted for lifetime changes only when no account position
411    /// exists; the two ordering domains are never compared.
412    pub async fn upsert_with_lifetime(
413        &self,
414        view_id: &str,
415        key: &str,
416        patch: Value,
417        append_paths: &[String],
418        origin: PatchOrigin,
419        account_position: Option<AccountPosition>,
420    ) -> CacheWrite {
421        let source_seq = patch.get("_seq").and_then(Value::as_str).map(str::to_owned);
422        self.upsert_with_ordering(
423            view_id,
424            key,
425            patch,
426            append_paths,
427            origin,
428            LifetimeOrdering {
429                account_position,
430                source_seq: source_seq.as_deref(),
431                // The convenience path supports markerless lifetime ordering,
432                // but sparse legacy patches may mix account write versions
433                // and instruction indices within one slot.
434                source_domain: (origin == PatchOrigin::Creation).then_some(SlotIndexDomain::Legacy),
435            },
436        )
437        .await
438    }
439
440    /// Apply a projected patch with both ordering domains supplied
441    /// explicitly. Projections may omit `_seq`, so the projector passes its
442    /// source cursor separately here.
443    pub async fn upsert_with_ordering(
444        &self,
445        view_id: &str,
446        key: &str,
447        patch: Value,
448        append_paths: &[String],
449        origin: PatchOrigin,
450        ordering: LifetimeOrdering<'_>,
451    ) -> CacheWrite {
452        let LifetimeOrdering {
453            account_position,
454            source_seq,
455            source_domain,
456        } = ordering;
457        let mut caches = self.caches.write().await;
458
459        let view = caches
460            .entry(view_id.to_string())
461            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
462        if origin != PatchOrigin::Unknown && !view.creations_marked {
463            view.mark_creations();
464        }
465
466        let max_array_length = self.config.max_array_length;
467
468        let creation = origin == PatchOrigin::Creation;
469        if !view.accepts(key, creation, account_position, source_seq, source_domain) {
470            return CacheWrite::Refused { patch };
471        }
472
473        if let Some(position) = account_position {
474            // Account writes choose the lifetime, but they do not reset the
475            // independent instruction/resolver high-water marks. Keeping
476            // those cursors across deletion and recreation prevents a late
477            // duplicate from the old lifetime from contaminating the new
478            // row. A genuinely newer source event still advances its own
479            // domain normally.
480            let previous = view.lifetimes.peek(key).cloned();
481            view.lifetimes.put(
482                key.to_string(),
483                EntityLifetimeCheckpoint {
484                    account_position: Some(position),
485                    source_seq: previous
486                        .as_ref()
487                        .and_then(|checkpoint| checkpoint.source_seq.clone()),
488                    source_sequences: previous
489                        .map(|checkpoint| checkpoint.source_sequences)
490                        .unwrap_or_default(),
491                    deleted: false,
492                },
493            );
494            view.trim_lifetimes();
495        } else {
496            if let (Some(source_seq), Some(source_domain)) = (source_seq, source_domain) {
497                let mut checkpoint = if creation {
498                    EntityLifetimeCheckpoint {
499                        account_position: None,
500                        source_seq: None,
501                        source_sequences: BTreeMap::new(),
502                        deleted: false,
503                    }
504                } else {
505                    view.lifetimes
506                        .peek(key)
507                        .cloned()
508                        .unwrap_or(EntityLifetimeCheckpoint {
509                            account_position: None,
510                            source_seq: None,
511                            source_sequences: BTreeMap::new(),
512                            deleted: false,
513                        })
514                };
515                checkpoint
516                    .source_sequences
517                    .insert(source_domain, source_seq.to_string());
518                checkpoint.deleted = false;
519                view.lifetimes.put(key.to_string(), checkpoint);
520                view.trim_lifetimes();
521            } else if creation
522                && view.lifetimes.peek(key).is_some_and(|checkpoint| {
523                    checkpoint.source_seq.is_none() && checkpoint.source_sequences.is_empty()
524                })
525            {
526                // An entirely unsequenced source preserves its historical
527                // recreate behavior; there is no ordering evidence to retain.
528                view.lifetimes.pop(key);
529            }
530        }
531
532        let whole = match origin {
533            PatchOrigin::Creation => true,
534            PatchOrigin::Change => view.entities.contains(key),
535            PatchOrigin::Unknown => view.entities.contains(key) || !view.was_evicted(key),
536        };
537        if !whole {
538            return CacheWrite::Refused { patch };
539        }
540
541        // A creation is a complete replacement. Deep-merging it would retain
542        // fields from the previous account lifetime.
543        if creation {
544            let new_entity = truncate_arrays_if_needed(patch, max_array_length);
545            view.store(key.to_string(), new_entity);
546            return CacheWrite::Created;
547        }
548
549        if let Some(entity) = view.entities.get_mut(key) {
550            deep_merge_with_append(entity, patch, append_paths, max_array_length);
551            return CacheWrite::Merged;
552        }
553        let new_entity = truncate_arrays_if_needed(patch, max_array_length);
554        view.store(key.to_string(), new_entity);
555        CacheWrite::Created
556    }
557
558    /// Store `entity` as the whole entity for `key`, replacing anything held
559    /// and clearing an eviction: the source vouched for it being complete
560    /// (see [`arete_interpreter::Mutation::mark_whole_entity`]).
561    pub async fn store_whole(&self, view_id: &str, key: &str, entity: Value) -> bool {
562        self.store_whole_for_lifetime(view_id, key, entity, None)
563            .await
564    }
565
566    /// Store a requested whole entity only if the request was made for the
567    /// account lifetime that is still current.
568    pub async fn store_whole_for_lifetime(
569        &self,
570        view_id: &str,
571        key: &str,
572        entity: Value,
573        requested_account_position: Option<AccountPosition>,
574    ) -> bool {
575        self.store_whole_checked(view_id, key, entity, requested_account_position, None)
576            .await
577    }
578
579    pub async fn store_whole_for_ordering(
580        &self,
581        view_id: &str,
582        key: &str,
583        entity: Value,
584        requested_account_position: Option<AccountPosition>,
585        requested_source_seq: Option<&str>,
586    ) -> bool {
587        self.store_whole_for_ordering_in_domain(
588            view_id,
589            key,
590            entity,
591            requested_account_position,
592            requested_source_seq,
593            SlotIndexDomain::Legacy,
594        )
595        .await
596    }
597
598    pub async fn store_whole_for_ordering_in_domain(
599        &self,
600        view_id: &str,
601        key: &str,
602        entity: Value,
603        requested_account_position: Option<AccountPosition>,
604        requested_source_seq: Option<&str>,
605        requested_source_domain: SlotIndexDomain,
606    ) -> bool {
607        self.store_whole_checked(
608            view_id,
609            key,
610            entity,
611            requested_account_position,
612            Some((requested_source_seq, requested_source_domain)),
613        )
614        .await
615    }
616
617    async fn store_whole_checked(
618        &self,
619        view_id: &str,
620        key: &str,
621        entity: Value,
622        requested_account_position: Option<AccountPosition>,
623        requested_source: Option<(Option<&str>, SlotIndexDomain)>,
624    ) -> bool {
625        let mut caches = self.caches.write().await;
626        let view = caches
627            .entry(view_id.to_string())
628            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
629        let entity = truncate_arrays_if_needed(entity, self.config.max_array_length);
630        if let Some(checkpoint) = view.lifetimes.peek(key) {
631            if checkpoint.deleted || checkpoint.account_position != requested_account_position {
632                return false;
633            }
634            if let Some((requested_source_seq, requested_source_domain)) = requested_source {
635                if (checkpoint.account_position.is_none()
636                    && checkpoint.source_seq(Some(requested_source_domain)) != requested_source_seq)
637                    || view.entities.contains(key)
638                {
639                    return false;
640                }
641            } else if checkpoint.account_position.is_some() && view.entities.contains(key) {
642                return false;
643            }
644        }
645        view.store(key.to_string(), entity);
646        true
647    }
648
649    pub async fn account_position(&self, view_id: &str, key: &str) -> Option<AccountPosition> {
650        self.caches
651            .read()
652            .await
653            .get(view_id)
654            .and_then(|view| view.lifetimes.peek(key))
655            .and_then(|checkpoint| checkpoint.account_position)
656    }
657
658    pub async fn accepts_mutation(
659        &self,
660        view_id: &str,
661        key: &str,
662        _entity: &Value,
663        creation: bool,
664    ) -> bool {
665        self.accepts_lifetime_mutation(view_id, key, creation, None)
666            .await
667    }
668
669    pub async fn accepts_lifetime_mutation(
670        &self,
671        view_id: &str,
672        key: &str,
673        creation: bool,
674        account_position: Option<AccountPosition>,
675    ) -> bool {
676        self.accepts_ordered_lifetime_mutation(view_id, key, creation, account_position, None)
677            .await
678    }
679
680    pub async fn accepts_ordered_lifetime_mutation(
681        &self,
682        view_id: &str,
683        key: &str,
684        creation: bool,
685        account_position: Option<AccountPosition>,
686        source_seq: Option<&str>,
687    ) -> bool {
688        self.accepts_ordered_lifetime_mutation_in_domain(
689            view_id,
690            key,
691            creation,
692            account_position,
693            source_seq,
694            SlotIndexDomain::Legacy,
695        )
696        .await
697    }
698
699    pub async fn accepts_ordered_lifetime_mutation_in_domain(
700        &self,
701        view_id: &str,
702        key: &str,
703        creation: bool,
704        account_position: Option<AccountPosition>,
705        source_seq: Option<&str>,
706        source_domain: SlotIndexDomain,
707    ) -> bool {
708        self.caches.read().await.get(view_id).is_none_or(|view| {
709            view.accepts(
710                key,
711                creation,
712                account_position,
713                source_seq,
714                Some(source_domain),
715            )
716        })
717    }
718
719    /// Apply an ordered source deletion. A delayed deletion never removes a
720    /// newer recreation. Only a newer, explicitly marked creation can restart
721    /// a deleted row; sparse patches and resends cannot resurrect it.
722    pub async fn delete(
723        &self,
724        view_id: &str,
725        key: &str,
726        account_position: Option<AccountPosition>,
727    ) -> bool {
728        match account_position {
729            Some(position) => {
730                self.delete_ordered(view_id, key, Some(position), None)
731                    .await
732            }
733            None => self.delete_current(view_id, key).await,
734        }
735    }
736
737    /// Delete the lifetime currently held by the cache when the producer has
738    /// no cursor to attach. This is an explicit, trusted operation: callers
739    /// with ordering evidence must use [`Self::delete_ordered`] instead.
740    pub async fn delete_current(&self, view_id: &str, key: &str) -> bool {
741        let mut caches = self.caches.write().await;
742        let view = caches
743            .entry(view_id.to_string())
744            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
745        let checkpoint = view
746            .lifetimes
747            .peek(key)
748            .cloned()
749            .unwrap_or(EntityLifetimeCheckpoint {
750                account_position: None,
751                source_seq: None,
752                source_sequences: BTreeMap::new(),
753                deleted: false,
754            });
755        if checkpoint.deleted {
756            return true;
757        }
758        view.entities.pop(key);
759        view.forget_evicted(key);
760        view.lifetimes.put(
761            key.to_string(),
762            EntityLifetimeCheckpoint {
763                deleted: true,
764                ..checkpoint
765            },
766        );
767        view.trim_lifetimes();
768        true
769    }
770
771    /// Delete using the producer's own `_seq` ordering only when no
772    /// authoritative account position is present.
773    pub async fn delete_ordered(
774        &self,
775        view_id: &str,
776        key: &str,
777        account_position: Option<AccountPosition>,
778        source_seq: Option<&str>,
779    ) -> bool {
780        self.delete_ordered_in_domain(
781            view_id,
782            key,
783            account_position,
784            source_seq,
785            SlotIndexDomain::Legacy,
786        )
787        .await
788    }
789
790    pub async fn delete_ordered_in_domain(
791        &self,
792        view_id: &str,
793        key: &str,
794        account_position: Option<AccountPosition>,
795        source_seq: Option<&str>,
796        source_domain: SlotIndexDomain,
797    ) -> bool {
798        let mut caches = self.caches.write().await;
799        let view = caches
800            .entry(view_id.to_string())
801            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
802        if let Some(checkpoint) = view.lifetimes.peek(key) {
803            match (checkpoint.account_position, account_position) {
804                (Some(previous), Some(incoming)) if incoming < previous => return false,
805                (Some(previous), Some(incoming)) if incoming == previous => {
806                    return checkpoint.deleted;
807                }
808                _ => {}
809            }
810        }
811        if account_position.is_none()
812            && source_seq.is_some()
813            && !view.accepts_source_ordering(key, source_seq, Some(source_domain), true)
814        {
815            return false;
816        }
817        let previous = view.lifetimes.peek(key).cloned();
818        view.entities.pop(key);
819        view.forget_evicted(key);
820        let mut checkpoint = if account_position.is_some() {
821            let previous = previous.unwrap_or(EntityLifetimeCheckpoint {
822                account_position: None,
823                source_seq: None,
824                source_sequences: BTreeMap::new(),
825                deleted: false,
826            });
827            EntityLifetimeCheckpoint {
828                account_position,
829                source_seq: previous.source_seq,
830                source_sequences: previous.source_sequences,
831                deleted: true,
832            }
833        } else {
834            previous.unwrap_or(EntityLifetimeCheckpoint {
835                account_position: None,
836                source_seq: None,
837                source_sequences: BTreeMap::new(),
838                deleted: false,
839            })
840        };
841        if account_position.is_none() {
842            if let Some(source_seq) = source_seq {
843                checkpoint
844                    .source_sequences
845                    .insert(source_domain, source_seq.to_string());
846            }
847            checkpoint.deleted = true;
848        }
849        view.lifetimes.put(key.to_string(), checkpoint);
850        view.trim_lifetimes();
851        true
852    }
853
854    pub async fn dump_lifetimes(&self) -> EntityLifetimes {
855        self.caches
856            .read()
857            .await
858            .iter()
859            .flat_map(|(id, view)| {
860                view.lifetimes
861                    .iter()
862                    .map(|(key, checkpoint)| (id.clone(), key.clone(), checkpoint.clone()))
863            })
864            .collect()
865    }
866
867    pub async fn hydrate_lifetimes(&self, lifetimes: EntityLifetimes) {
868        let mut caches = self.caches.write().await;
869        for (id, key, checkpoint) in lifetimes.into_iter().rev() {
870            let view = caches
871                .entry(id)
872                .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
873            view.lifetimes.put(key, checkpoint);
874            view.trim_lifetimes();
875        }
876    }
877
878    /// Restore the old `_seq` barriers as account positions. Those barriers
879    /// were written only by authoritative account deletion/recreation paths;
880    /// whether the row exists distinguishes a live recreation from deletion.
881    pub async fn hydrate_legacy_tombstones(&self, tombstones: EntityTombstones) {
882        let mut caches = self.caches.write().await;
883        for (id, key, seq) in tombstones.into_iter().rev() {
884            let view = caches
885                .entry(id)
886                .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
887            let deleted = !view.entities.contains(&key);
888            let account_position = seq.as_deref().and_then(account_position_from_seq);
889            view.lifetimes.put(
890                key,
891                EntityLifetimeCheckpoint {
892                    account_position,
893                    source_seq: None,
894                    source_sequences: BTreeMap::new(),
895                    deleted,
896                },
897            );
898            view.trim_lifetimes();
899        }
900    }
901
902    /// Whether the shared cache says a published delete still represents the
903    /// latest lifetime. Subscription tasks use this without mutating the cache
904    /// a second time after the projector has already applied the delete.
905    pub async fn deletion_is_current(&self, view_id: &str, key: &str) -> bool {
906        let caches = self.caches.read().await;
907        caches.get(view_id).is_none_or(|view| {
908            view.lifetimes
909                .peek(key)
910                .map_or(!view.entities.contains(key), |checkpoint| {
911                    checkpoint.deleted
912                })
913        })
914    }
915
916    /// Merge a source patch into `base` with this cache's append and array
917    /// rules, for callers that keep their own full copy of an entity.
918    pub fn merge_patch(&self, base: &mut Value, patch: Value, append_paths: &[String]) {
919        deep_merge_with_append(base, patch, append_paths, self.config.max_array_length);
920    }
921
922    /// Treat `keys` as evicted from `view_id` unless the view holds them, so
923    /// patches for them from a source that does not mark creations are
924    /// refused. Used after a restore, when the VM holds entities the restored
925    /// cache does not; a VM that marks creations makes this moot, and the
926    /// view drops the memory with its first patch.
927    pub async fn remember_evicted(&self, view_id: &str, keys: impl IntoIterator<Item = String>) {
928        let mut caches = self.caches.write().await;
929        let view = caches
930            .entry(view_id.to_string())
931            .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
932        for key in keys {
933            if !view.entities.contains(&key) {
934                view.remember_evicted(key);
935            }
936        }
937    }
938
939    /// Get all cached entities for a view.
940    ///
941    /// Returns a vector of (key, entity) pairs for sending as snapshots
942    /// to new subscribers.
943    pub async fn get_all(&self, view_id: &str) -> Vec<(String, Value)> {
944        let caches = self.caches.read().await;
945
946        caches
947            .get(view_id)
948            .map(|view| {
949                view.entities
950                    .iter()
951                    .map(|(k, v)| (k.clone(), v.clone()))
952                    .collect()
953            })
954            .unwrap_or_default()
955    }
956
957    /// Get entities with _seq greater than the provided cursor.
958    ///
959    /// Returns entities that have been updated after the given cursor,
960    /// sorted by _seq in ascending order. Useful for resuming from
961    /// a specific point in the stream.
962    pub async fn get_after(
963        &self,
964        view_id: &str,
965        cursor: &str,
966        limit: Option<usize>,
967    ) -> Vec<(String, Value)> {
968        let caches = self.caches.read().await;
969
970        if let Some(view) = caches.get(view_id) {
971            let mut results: Vec<(String, Value)> = view
972                .entities
973                .iter()
974                .filter(|(_, entity)| {
975                    entity
976                        .get("_seq")
977                        .and_then(|s| s.as_str())
978                        .map(|seq| cmp_seq(seq, cursor) == std::cmp::Ordering::Greater)
979                        .unwrap_or(false)
980                })
981                .map(|(k, v)| (k.clone(), v.clone()))
982                .collect();
983
984            // Sort by _seq (ascending)
985            results.sort_by(|a, b| {
986                let seq_a = a.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
987                let seq_b = b.1.get("_seq").and_then(|s| s.as_str()).unwrap_or("");
988                cmp_seq(seq_a, seq_b)
989            });
990
991            // Apply limit if provided
992            if let Some(limit) = limit {
993                results.truncate(limit);
994            }
995
996            results
997        } else {
998            vec![]
999        }
1000    }
1001
1002    /// Get a specific entity from the cache
1003    pub async fn get(&self, view_id: &str, key: &str) -> Option<Value> {
1004        let caches = self.caches.read().await;
1005        caches
1006            .get(view_id)
1007            .and_then(|view| view.entities.peek(key).cloned())
1008    }
1009
1010    /// Remove one entity after a source-wide delete.
1011    ///
1012    /// The delete ends the entity, so the key is also forgotten as evicted: a
1013    /// later patch for it creates a new entity.
1014    pub async fn remove(&self, view_id: &str, key: &str) -> Option<Value> {
1015        let mut caches = self.caches.write().await;
1016        caches.get_mut(view_id).and_then(|view| {
1017            view.forget_evicted(key);
1018            view.entities.pop(key)
1019        })
1020    }
1021
1022    /// Get the number of cached entities for a view
1023    pub async fn len(&self, view_id: &str) -> usize {
1024        let caches = self.caches.read().await;
1025        caches
1026            .get(view_id)
1027            .map(|view| view.entities.len())
1028            .unwrap_or(0)
1029    }
1030
1031    /// Check if the cache for a view is empty
1032    pub async fn is_empty(&self, view_id: &str) -> bool {
1033        self.len(view_id).await == 0
1034    }
1035
1036    /// Get the snapshot batch configuration
1037    pub fn snapshot_config(&self) -> SnapshotBatchConfig {
1038        SnapshotBatchConfig {
1039            initial_batch_size: self.config.initial_snapshot_batch_size,
1040            subsequent_batch_size: self.config.subsequent_snapshot_batch_size,
1041        }
1042    }
1043
1044    /// Clear all cached entities for a view
1045    pub async fn clear(&self, view_id: &str) {
1046        let mut caches = self.caches.write().await;
1047        if let Some(view) = caches.get_mut(view_id) {
1048            view.entities.clear();
1049            view.evicted = None;
1050        }
1051    }
1052
1053    pub async fn clear_all(&self) {
1054        let mut caches = self.caches.write().await;
1055        caches.clear();
1056    }
1057
1058    /// Dump every view's entities for a state snapshot.
1059    ///
1060    /// Entries are ordered most-recently-used first; [`Self::hydrate`] relies
1061    /// on that to reconstruct LRU eviction order.
1062    pub async fn dump(&self) -> Vec<(String, Vec<(String, Value)>)> {
1063        let caches = self.caches.read().await;
1064        caches
1065            .iter()
1066            .map(|(view_id, view)| {
1067                (
1068                    view_id.clone(),
1069                    view.entities
1070                        .iter()
1071                        .map(|(key, entity)| (key.clone(), entity.clone()))
1072                        .collect(),
1073                )
1074            })
1075            .collect()
1076    }
1077
1078    /// Restore entities dumped by [`Self::dump`], preserving LRU order.
1079    ///
1080    /// Entities are inserted as-is (no merge): a snapshot holds fully
1081    /// projected entities, not patches.
1082    pub async fn hydrate(&self, views: Vec<(String, Vec<(String, Value)>)>) {
1083        let mut caches = self.caches.write().await;
1084        caches.clear();
1085        for (view_id, entries) in views {
1086            let view = caches
1087                .entry(view_id)
1088                .or_insert_with(|| ViewEntries::new(self.config.max_entities_per_view));
1089            for (key, entity) in entries.into_iter().rev() {
1090                view.store(key, entity);
1091            }
1092        }
1093    }
1094
1095    pub async fn stats(&self) -> CacheStats {
1096        let caches = self.caches.read().await;
1097        let mut total_entities = 0;
1098        let mut views = Vec::new();
1099
1100        for (view_id, view) in caches.iter() {
1101            let count = view.entities.len();
1102            total_entities += count;
1103            views.push((view_id.clone(), count));
1104        }
1105
1106        views.sort_by_key(|b| std::cmp::Reverse(b.1));
1107
1108        CacheStats {
1109            view_count: caches.len(),
1110            total_entities,
1111            top_views: views.into_iter().take(5).collect(),
1112        }
1113    }
1114}
1115
1116/// A point-in-time count of an [`EntityCache`]'s contents.
1117#[derive(Debug, Clone, PartialEq, Eq)]
1118pub struct CacheStats {
1119    pub view_count: usize,
1120    pub total_entities: usize,
1121    pub top_views: Vec<(String, usize)>,
1122}
1123
1124#[derive(Debug, Clone, Copy)]
1125pub struct SnapshotBatchConfig {
1126    pub initial_batch_size: usize,
1127    pub subsequent_batch_size: usize,
1128}
1129
1130impl Default for EntityCache {
1131    fn default() -> Self {
1132        Self::new()
1133    }
1134}
1135
1136fn deep_merge_with_append(
1137    base: &mut Value,
1138    patch: Value,
1139    append_paths: &[String],
1140    max_array_length: usize,
1141) {
1142    deep_merge_with_append_inner(base, patch, append_paths, "", max_array_length);
1143}
1144
1145fn deep_merge_with_append_inner(
1146    base: &mut Value,
1147    patch: Value,
1148    append_paths: &[String],
1149    current_path: &str,
1150    max_array_length: usize,
1151) {
1152    match (base, patch) {
1153        (Value::Object(base_map), Value::Object(patch_map)) => {
1154            for (key, patch_value) in patch_map {
1155                let child_path = if current_path.is_empty() {
1156                    key.clone()
1157                } else {
1158                    format!("{}.{}", current_path, key)
1159                };
1160
1161                if let Some(base_value) = base_map.get_mut(&key) {
1162                    deep_merge_with_append_inner(
1163                        base_value,
1164                        patch_value,
1165                        append_paths,
1166                        &child_path,
1167                        max_array_length,
1168                    );
1169                } else {
1170                    base_map.insert(
1171                        key,
1172                        truncate_arrays_if_needed(patch_value, max_array_length),
1173                    );
1174                }
1175            }
1176        }
1177
1178        (Value::Array(base_arr), Value::Array(patch_arr)) => {
1179            let should_append = append_paths.iter().any(|p| p == current_path);
1180            if should_append {
1181                base_arr.extend(patch_arr);
1182                if base_arr.len() > max_array_length {
1183                    let excess = base_arr.len() - max_array_length;
1184                    base_arr.drain(0..excess);
1185                }
1186            } else {
1187                *base_arr = patch_arr;
1188                if base_arr.len() > max_array_length {
1189                    let excess = base_arr.len() - max_array_length;
1190                    base_arr.drain(0..excess);
1191                }
1192            }
1193        }
1194
1195        (base, patch_value) => {
1196            *base = truncate_arrays_if_needed(patch_value, max_array_length);
1197        }
1198    }
1199}
1200
1201/// Recursively truncate any arrays in a value to the max length
1202fn truncate_arrays_if_needed(value: Value, max_array_length: usize) -> Value {
1203    match value {
1204        Value::Array(mut arr) => {
1205            // Truncate this array if needed
1206            if arr.len() > max_array_length {
1207                let excess = arr.len() - max_array_length;
1208                arr.drain(0..excess);
1209            }
1210            // Recursively process elements
1211            Value::Array(
1212                arr.into_iter()
1213                    .map(|v| truncate_arrays_if_needed(v, max_array_length))
1214                    .collect(),
1215            )
1216        }
1217        Value::Object(map) => Value::Object(
1218            map.into_iter()
1219                .map(|(k, v)| (k, truncate_arrays_if_needed(v, max_array_length)))
1220                .collect(),
1221        ),
1222        other => other,
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use serde_json::json;
1230
1231    #[tokio::test]
1232    async fn same_slot_instruction_activity_cannot_block_authoritative_deletion() {
1233        let cache = EntityCache::new();
1234        assert_eq!(
1235            cache
1236                .upsert_with_lifetime(
1237                    "v",
1238                    "a",
1239                    json!({"account":true,"_seq":"100:000000000009"}),
1240                    &[],
1241                    PatchOrigin::Creation,
1242                    Some(AccountPosition::new(100, 9)),
1243                )
1244                .await,
1245            CacheWrite::Created
1246        );
1247        assert_eq!(
1248            cache
1249                .upsert_with_append(
1250                    "v",
1251                    "a",
1252                    json!({"instruction":true,"_seq":"100:000000000900"}),
1253                    &[],
1254                    PatchOrigin::Change
1255                )
1256                .await,
1257            CacheWrite::Merged
1258        );
1259        assert!(
1260            cache
1261                .delete("v", "a", Some(AccountPosition::new(100, 10)))
1262                .await
1263        );
1264        assert!(cache.get("v", "a").await.is_none());
1265        assert!(cache.deletion_is_current("v", "a").await);
1266    }
1267
1268    #[tokio::test]
1269    async fn recreation_has_its_own_ordering_and_replaces_old_fields() {
1270        let cache = EntityCache::with_config(EntityCacheConfig {
1271            max_entities_per_view: 1,
1272            ..Default::default()
1273        });
1274        assert!(
1275            cache
1276                .delete("v", "a", Some(AccountPosition::new(100, 1001)))
1277                .await
1278        );
1279        assert_eq!(
1280            cache
1281                .upsert_with_lifetime(
1282                    "v",
1283                    "a",
1284                    json!({"fresh":true, "_seq":"100:000000001002"}),
1285                    &[],
1286                    PatchOrigin::Creation,
1287                    Some(AccountPosition::new(100, 1002)),
1288                )
1289                .await,
1290            CacheWrite::Created
1291        );
1292        assert_eq!(
1293            cache
1294                .upsert_with_append(
1295                    "v",
1296                    "a",
1297                    json!({"instruction":true, "_seq":"100:000000000900"}),
1298                    &[],
1299                    PatchOrigin::Change
1300                )
1301                .await,
1302            CacheWrite::Merged
1303        );
1304
1305        // Neither a stale authoritative write/tombstone nor a delayed resend
1306        // from the deleted lifetime can contaminate the replacement.
1307        assert!(matches!(
1308            cache
1309                .upsert_with_lifetime(
1310                    "v",
1311                    "a",
1312                    json!({"old":true, "_seq":"100:000000001001"}),
1313                    &[],
1314                    PatchOrigin::Change,
1315                    Some(AccountPosition::new(100, 1001)),
1316                )
1317                .await,
1318            CacheWrite::Refused { .. }
1319        ));
1320        assert!(
1321            !cache
1322                .delete("v", "a", Some(AccountPosition::new(100, 1001)))
1323                .await
1324        );
1325        assert!(
1326            !cache
1327                .store_whole("v", "a", json!({"old":true, "_seq":"100:000000000900"}))
1328                .await
1329        );
1330        let row = cache.get("v", "a").await.unwrap();
1331        assert_eq!(row["fresh"], true);
1332        assert_eq!(row["instruction"], true);
1333        assert!(row.get("old").is_none());
1334
1335        cache
1336            .upsert_with_append(
1337                "v",
1338                "other",
1339                json!({"other":true}),
1340                &[],
1341                PatchOrigin::Creation,
1342            )
1343            .await;
1344        assert!(cache.get("v", "a").await.is_none());
1345        assert!(matches!(
1346            cache
1347                .upsert_with_lifetime(
1348                    "v",
1349                    "a",
1350                    json!({"accountUpdate":true, "_seq":"100:000000001003"}),
1351                    &[],
1352                    PatchOrigin::Change,
1353                    Some(AccountPosition::new(100, 1003)),
1354                )
1355                .await,
1356            CacheWrite::Refused { .. }
1357        ));
1358        assert!(
1359            !cache
1360                .store_whole_for_lifetime(
1361                    "v",
1362                    "a",
1363                    row.clone(),
1364                    Some(AccountPosition::new(100, 1002)),
1365                )
1366                .await
1367        );
1368        let mut current = row;
1369        current["accountUpdate"] = Value::Bool(true);
1370        current["_seq"] = Value::String("100:000000001003".into());
1371        assert!(
1372            cache
1373                .store_whole_for_lifetime("v", "a", current, Some(AccountPosition::new(100, 1003)),)
1374                .await
1375        );
1376
1377        // Current lifetime metadata survives a snapshot and continues to
1378        // admit instruction-domain offsets while rejecting stale account data.
1379        let restored = EntityCache::new();
1380        restored.hydrate(cache.dump().await).await;
1381        restored
1382            .hydrate_lifetimes(cache.dump_lifetimes().await)
1383            .await;
1384        assert_eq!(
1385            restored
1386                .upsert_with_append(
1387                    "v",
1388                    "a",
1389                    json!({"afterRestore":true, "_seq":"100:000000000901"}),
1390                    &[],
1391                    PatchOrigin::Change,
1392                )
1393                .await,
1394            CacheWrite::Merged
1395        );
1396        assert!(matches!(
1397            restored
1398                .upsert_with_lifetime(
1399                    "v",
1400                    "a",
1401                    json!({"old":true, "_seq":"100:000000001001"}),
1402                    &[],
1403                    PatchOrigin::Change,
1404                    Some(AccountPosition::new(100, 1001)),
1405                )
1406                .await,
1407            CacheWrite::Refused { .. }
1408        ));
1409    }
1410
1411    #[tokio::test]
1412    async fn recreation_retains_each_source_cursor_as_an_old_lifetime_barrier() {
1413        let cache = EntityCache::new();
1414        assert_eq!(
1415            cache
1416                .upsert_with_lifetime(
1417                    "v",
1418                    "a",
1419                    json!({"lifetime":"old"}),
1420                    &[],
1421                    PatchOrigin::Creation,
1422                    Some(AccountPosition::new(100, 9)),
1423                )
1424                .await,
1425            CacheWrite::Created
1426        );
1427        for (domain, seq, field) in [
1428            (
1429                SlotIndexDomain::Instruction,
1430                "100:000000000800",
1431                "oldInstruction",
1432            ),
1433            (SlotIndexDomain::Resolver, "100:900000000000", "oldResolver"),
1434        ] {
1435            let mut patch = json!({"_seq":seq});
1436            patch[field] = Value::Bool(true);
1437            assert_eq!(
1438                cache
1439                    .upsert_with_ordering(
1440                        "v",
1441                        "a",
1442                        patch,
1443                        &[],
1444                        PatchOrigin::Change,
1445                        LifetimeOrdering {
1446                            account_position: None,
1447                            source_seq: Some(seq),
1448                            source_domain: Some(domain),
1449                        },
1450                    )
1451                    .await,
1452                CacheWrite::Merged
1453            );
1454        }
1455
1456        assert!(
1457            cache
1458                .delete("v", "a", Some(AccountPosition::new(100, 10)))
1459                .await
1460        );
1461        assert_eq!(
1462            cache
1463                .upsert_with_lifetime(
1464                    "v",
1465                    "a",
1466                    json!({"lifetime":"new"}),
1467                    &[],
1468                    PatchOrigin::Creation,
1469                    Some(AccountPosition::new(100, 11)),
1470                )
1471                .await,
1472            CacheWrite::Created
1473        );
1474
1475        // Duplicates delayed across the deletion/recreation boundary retain
1476        // their old source positions and cannot change the replacement.
1477        for (domain, seq, field) in [
1478            (
1479                SlotIndexDomain::Instruction,
1480                "100:000000000799",
1481                "staleInstruction",
1482            ),
1483            (
1484                SlotIndexDomain::Resolver,
1485                "100:899999999999",
1486                "staleResolver",
1487            ),
1488        ] {
1489            let mut patch = json!({"_seq":seq});
1490            patch[field] = Value::Bool(true);
1491            assert!(matches!(
1492                cache
1493                    .upsert_with_ordering(
1494                        "v",
1495                        "a",
1496                        patch,
1497                        &[],
1498                        PatchOrigin::Change,
1499                        LifetimeOrdering {
1500                            account_position: None,
1501                            source_seq: Some(seq),
1502                            source_domain: Some(domain),
1503                        },
1504                    )
1505                    .await,
1506                CacheWrite::Refused { .. }
1507            ));
1508        }
1509
1510        // A later instruction in the same source domain remains valid even
1511        // though its txn index is lower than account write versions.
1512        assert_eq!(
1513            cache
1514                .upsert_with_ordering(
1515                    "v",
1516                    "a",
1517                    json!({"newInstruction":true, "_seq":"100:000000000900"}),
1518                    &[],
1519                    PatchOrigin::Change,
1520                    LifetimeOrdering {
1521                        account_position: None,
1522                        source_seq: Some("100:000000000900"),
1523                        source_domain: Some(SlotIndexDomain::Instruction),
1524                    },
1525                )
1526                .await,
1527            CacheWrite::Merged
1528        );
1529        let row = cache.get("v", "a").await.unwrap();
1530        assert_eq!(row["lifetime"], "new");
1531        assert_eq!(row["newInstruction"], true);
1532        assert!(row.get("oldInstruction").is_none());
1533        assert!(row.get("oldResolver").is_none());
1534        assert!(row.get("staleInstruction").is_none());
1535        assert!(row.get("staleResolver").is_none());
1536
1537        let restored = EntityCache::new();
1538        restored.hydrate(cache.dump().await).await;
1539        restored
1540            .hydrate_lifetimes(cache.dump_lifetimes().await)
1541            .await;
1542        assert!(matches!(
1543            restored
1544                .upsert_with_ordering(
1545                    "v",
1546                    "a",
1547                    json!({"staleAfterRestore":true}),
1548                    &[],
1549                    PatchOrigin::Change,
1550                    LifetimeOrdering {
1551                        account_position: None,
1552                        source_seq: Some("100:000000000799"),
1553                        source_domain: Some(SlotIndexDomain::Instruction),
1554                    },
1555                )
1556                .await,
1557            CacheWrite::Refused { .. }
1558        ));
1559    }
1560
1561    #[tokio::test]
1562    async fn markerless_sources_keep_seq_ordering_across_recreation_and_restore() {
1563        let cache = EntityCache::new();
1564        assert_eq!(
1565            cache
1566                .upsert_with_append(
1567                    "v",
1568                    "a",
1569                    json!({"old":true,"_seq":"100:000000000010"}),
1570                    &[],
1571                    PatchOrigin::Creation,
1572                )
1573                .await,
1574            CacheWrite::Created
1575        );
1576        assert!(
1577            !cache
1578                .delete_ordered("v", "a", None, Some("100:000000000009"))
1579                .await
1580        );
1581        assert!(cache.get("v", "a").await.is_some());
1582        assert!(
1583            cache
1584                .delete_ordered("v", "a", None, Some("100:000000000011"))
1585                .await
1586        );
1587        assert_eq!(
1588            cache
1589                .upsert_with_append(
1590                    "v",
1591                    "a",
1592                    json!({"fresh":true,"_seq":"100:000000000012"}),
1593                    &[],
1594                    PatchOrigin::Creation,
1595                )
1596                .await,
1597            CacheWrite::Created
1598        );
1599        assert!(matches!(
1600            cache
1601                .upsert_with_append(
1602                    "v",
1603                    "a",
1604                    json!({"stale":true,"_seq":"100:000000000010"}),
1605                    &[],
1606                    PatchOrigin::Creation,
1607                )
1608                .await,
1609            CacheWrite::Refused { .. }
1610        ));
1611        assert!(
1612            !cache
1613                .delete_ordered("v", "a", None, Some("100:000000000011"))
1614                .await
1615        );
1616
1617        let restored = EntityCache::new();
1618        restored.hydrate(cache.dump().await).await;
1619        restored
1620            .hydrate_lifetimes(cache.dump_lifetimes().await)
1621            .await;
1622        assert!(
1623            !restored
1624                .delete_ordered("v", "a", None, Some("100:000000000011"))
1625                .await
1626        );
1627        let row = restored.get("v", "a").await.unwrap();
1628        assert_eq!(row["fresh"], true);
1629        assert!(row.get("old").is_none());
1630        assert!(row.get("stale").is_none());
1631
1632        // An evicted row may be filled by a requested whole-entity resend,
1633        // but a response requested for the previous lifetime must not do it.
1634        let resync_cache = EntityCache::new();
1635        resync_cache
1636            .hydrate_lifetimes(cache.dump_lifetimes().await)
1637            .await;
1638        assert!(
1639            !resync_cache
1640                .store_whole_for_ordering(
1641                    "v",
1642                    "a",
1643                    json!({"stale_resend":true}),
1644                    None,
1645                    Some("100:000000000010"),
1646                )
1647                .await
1648        );
1649        assert!(
1650            resync_cache
1651                .store_whole_for_ordering(
1652                    "v",
1653                    "a",
1654                    json!({"fresh_resend":true}),
1655                    None,
1656                    Some("100:000000000012"),
1657                )
1658                .await
1659        );
1660    }
1661
1662    #[tokio::test]
1663    async fn older_source_patch_cannot_replace_newer_row_or_move_its_cursor_back() {
1664        let cache = EntityCache::new();
1665        assert_eq!(
1666            cache
1667                .upsert_with_append(
1668                    "v",
1669                    "a",
1670                    json!({"value":"created","_seq":"100:000000000010"}),
1671                    &[],
1672                    PatchOrigin::Creation,
1673                )
1674                .await,
1675            CacheWrite::Created
1676        );
1677        assert_eq!(
1678            cache
1679                .upsert_with_ordering(
1680                    "v",
1681                    "a",
1682                    json!({"value":"newer","_seq":"100:000000000012"}),
1683                    &[],
1684                    PatchOrigin::Change,
1685                    LifetimeOrdering {
1686                        account_position: None,
1687                        source_seq: Some("100:000000000012"),
1688                        source_domain: Some(SlotIndexDomain::Legacy),
1689                    },
1690                )
1691                .await,
1692            CacheWrite::Merged
1693        );
1694        assert!(matches!(
1695            cache
1696                .upsert_with_ordering(
1697                    "v",
1698                    "a",
1699                    json!({"value":"older","stale":true,"_seq":"100:000000000011"}),
1700                    &[],
1701                    PatchOrigin::Change,
1702                    LifetimeOrdering {
1703                        account_position: None,
1704                        source_seq: Some("100:000000000011"),
1705                        source_domain: Some(SlotIndexDomain::Legacy),
1706                    },
1707                )
1708                .await,
1709            CacheWrite::Refused { .. }
1710        ));
1711        assert!(
1712            !cache
1713                .delete_ordered("v", "a", None, Some("100:000000000011"))
1714                .await
1715        );
1716        let row = cache.get("v", "a").await.unwrap();
1717        assert_eq!(row["value"], "newer");
1718        assert_eq!(row["_seq"], "100:000000000012");
1719        assert!(row.get("stale").is_none());
1720    }
1721
1722    #[tokio::test]
1723    async fn independent_source_recency_domains_survive_snapshot_restore() {
1724        let cache = EntityCache::new();
1725        cache
1726            .upsert_with_lifetime(
1727                "v",
1728                "a",
1729                json!({"value":"created"}),
1730                &[],
1731                PatchOrigin::Creation,
1732                Some(AccountPosition::new(100, 9)),
1733            )
1734            .await;
1735        for (domain, seq, value) in [
1736            (SlotIndexDomain::Resolver, "100:900000000000", "resolver"),
1737            (
1738                SlotIndexDomain::Instruction,
1739                "100:000000000900",
1740                "instruction",
1741            ),
1742        ] {
1743            assert_eq!(
1744                cache
1745                    .upsert_with_ordering(
1746                        "v",
1747                        "a",
1748                        json!({"value":value, "_seq":seq}),
1749                        &[],
1750                        PatchOrigin::Change,
1751                        LifetimeOrdering {
1752                            account_position: None,
1753                            source_seq: Some(seq),
1754                            source_domain: Some(domain),
1755                        },
1756                    )
1757                    .await,
1758                CacheWrite::Merged
1759            );
1760        }
1761
1762        let restored = EntityCache::new();
1763        restored.hydrate(cache.dump().await).await;
1764        restored
1765            .hydrate_lifetimes(cache.dump_lifetimes().await)
1766            .await;
1767
1768        for (domain, seq) in [
1769            (SlotIndexDomain::Instruction, "100:000000000899"),
1770            (SlotIndexDomain::Resolver, "100:899999999999"),
1771        ] {
1772            assert!(matches!(
1773                restored
1774                    .upsert_with_ordering(
1775                        "v",
1776                        "a",
1777                        json!({"stale":true, "_seq":seq}),
1778                        &[],
1779                        PatchOrigin::Change,
1780                        LifetimeOrdering {
1781                            account_position: None,
1782                            source_seq: Some(seq),
1783                            source_domain: Some(domain),
1784                        },
1785                    )
1786                    .await,
1787                CacheWrite::Refused { .. }
1788            ));
1789        }
1790        assert_eq!(
1791            restored.get("v", "a").await.unwrap()["value"],
1792            "instruction"
1793        );
1794    }
1795
1796    #[tokio::test]
1797    async fn explicit_current_delete_accepts_a_source_without_position() {
1798        let cache = EntityCache::new();
1799        cache
1800            .upsert_with_append(
1801                "v",
1802                "a",
1803                json!({"value":true,"_seq":"100:000000000010"}),
1804                &[],
1805                PatchOrigin::Creation,
1806            )
1807            .await;
1808
1809        assert!(cache.delete("v", "a", None).await);
1810        assert!(cache.get("v", "a").await.is_none());
1811        assert!(cache.deletion_is_current("v", "a").await);
1812    }
1813
1814    #[tokio::test]
1815    async fn evicted_live_row_does_not_make_an_earlier_delete_current() {
1816        let cache = EntityCache::with_config(EntityCacheConfig {
1817            max_entities_per_view: 1,
1818            ..Default::default()
1819        });
1820        cache
1821            .upsert_with_lifetime(
1822                "v",
1823                "a",
1824                json!({"value":true,"_seq":"100:000000000010"}),
1825                &[],
1826                PatchOrigin::Creation,
1827                Some(AccountPosition::new(100, 10)),
1828            )
1829            .await;
1830        cache
1831            .upsert_with_append("v", "b", json!({"value":true}), &[], PatchOrigin::Creation)
1832            .await;
1833
1834        assert!(cache.get("v", "a").await.is_none());
1835        assert!(!cache.deletion_is_current("v", "a").await);
1836        assert!(cache.delete_current("v", "a").await);
1837        assert!(cache.deletion_is_current("v", "a").await);
1838    }
1839
1840    #[tokio::test]
1841    async fn test_basic_upsert_and_get() {
1842        let cache = EntityCache::new();
1843
1844        cache
1845            .upsert("tokens/list", "abc123", json!({"name": "Test Token"}))
1846            .await;
1847
1848        let entity = cache.get("tokens/list", "abc123").await;
1849        assert!(entity.is_some());
1850        assert_eq!(entity.unwrap()["name"], "Test Token");
1851    }
1852
1853    #[tokio::test]
1854    async fn test_deep_merge_objects() {
1855        let cache = EntityCache::new();
1856
1857        cache
1858            .upsert(
1859                "tokens/list",
1860                "abc123",
1861                json!({
1862                    "id": "abc123",
1863                    "metrics": {"volume": 100}
1864                }),
1865            )
1866            .await;
1867
1868        cache
1869            .upsert(
1870                "tokens/list",
1871                "abc123",
1872                json!({
1873                    "metrics": {"trades": 50}
1874                }),
1875            )
1876            .await;
1877
1878        let entity = cache.get("tokens/list", "abc123").await.unwrap();
1879        assert_eq!(entity["id"], "abc123");
1880        assert_eq!(entity["metrics"]["volume"], 100);
1881        assert_eq!(entity["metrics"]["trades"], 50);
1882    }
1883
1884    #[tokio::test]
1885    async fn test_array_append() {
1886        let cache = EntityCache::new();
1887
1888        cache
1889            .upsert(
1890                "tokens/list",
1891                "abc123",
1892                json!({
1893                    "events": [{"type": "buy", "amount": 100}]
1894                }),
1895            )
1896            .await;
1897
1898        cache
1899            .upsert_with_append(
1900                "tokens/list",
1901                "abc123",
1902                json!({
1903                    "events": [{"type": "sell", "amount": 50}]
1904                }),
1905                &["events".to_string()],
1906                PatchOrigin::Unknown,
1907            )
1908            .await;
1909
1910        let entity = cache.get("tokens/list", "abc123").await.unwrap();
1911        let events = entity["events"].as_array().unwrap();
1912        assert_eq!(events.len(), 2);
1913        assert_eq!(events[0]["type"], "buy");
1914        assert_eq!(events[1]["type"], "sell");
1915    }
1916
1917    #[tokio::test]
1918    async fn test_array_lru_eviction() {
1919        let config = EntityCacheConfig {
1920            max_entities_per_view: 1000,
1921            max_array_length: 3,
1922            ..Default::default()
1923        };
1924        let cache = EntityCache::with_config(config);
1925
1926        cache
1927            .upsert(
1928                "tokens/list",
1929                "abc123",
1930                json!({
1931                    "events": [
1932                        {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}
1933                    ]
1934                }),
1935            )
1936            .await;
1937
1938        let entity = cache.get("tokens/list", "abc123").await.unwrap();
1939        let events = entity["events"].as_array().unwrap();
1940
1941        assert_eq!(events.len(), 3);
1942        assert_eq!(events[0]["id"], 3);
1943        assert_eq!(events[1]["id"], 4);
1944        assert_eq!(events[2]["id"], 5);
1945    }
1946
1947    #[tokio::test]
1948    async fn test_array_append_with_lru() {
1949        let config = EntityCacheConfig {
1950            max_entities_per_view: 1000,
1951            max_array_length: 3,
1952            ..Default::default()
1953        };
1954        let cache = EntityCache::with_config(config);
1955
1956        cache
1957            .upsert(
1958                "tokens/list",
1959                "abc123",
1960                json!({
1961                    "events": [{"id": 1}, {"id": 2}]
1962                }),
1963            )
1964            .await;
1965
1966        cache
1967            .upsert_with_append(
1968                "tokens/list",
1969                "abc123",
1970                json!({
1971                    "events": [{"id": 3}, {"id": 4}]
1972                }),
1973                &["events".to_string()],
1974                PatchOrigin::Unknown,
1975            )
1976            .await;
1977
1978        let entity = cache.get("tokens/list", "abc123").await.unwrap();
1979        let events = entity["events"].as_array().unwrap();
1980
1981        // [1,2] + [3,4] = [1,2,3,4] → LRU(3) = [2,3,4]
1982        assert_eq!(events.len(), 3);
1983        assert_eq!(events[0]["id"], 2);
1984        assert_eq!(events[1]["id"], 3);
1985        assert_eq!(events[2]["id"], 4);
1986    }
1987
1988    #[tokio::test]
1989    async fn test_entity_lru_eviction() {
1990        let config = EntityCacheConfig {
1991            max_entities_per_view: 2,
1992            max_array_length: 100,
1993            ..Default::default()
1994        };
1995        let cache = EntityCache::with_config(config);
1996
1997        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
1998        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
1999        cache.upsert("tokens/list", "key3", json!({"id": 3})).await;
2000
2001        assert!(cache.get("tokens/list", "key1").await.is_none());
2002        assert!(cache.get("tokens/list", "key2").await.is_some());
2003        assert!(cache.get("tokens/list", "key3").await.is_some());
2004    }
2005
2006    #[tokio::test]
2007    async fn a_patch_for_an_evicted_key_is_refused_not_stored() {
2008        let cache = EntityCache::with_config(EntityCacheConfig {
2009            max_entities_per_view: 2,
2010            ..Default::default()
2011        });
2012        for id in 1..=3 {
2013            assert_eq!(
2014                cache
2015                    .upsert(
2016                        "tokens/list",
2017                        &format!("key{id}"),
2018                        json!({"id": id, "n": 0})
2019                    )
2020                    .await,
2021                CacheWrite::Created
2022            );
2023        }
2024        // key1 was evicted; this carries only what changed.
2025        assert_eq!(
2026            cache.upsert("tokens/list", "key1", json!({"n": 5})).await,
2027            CacheWrite::Refused {
2028                patch: json!({"n": 5})
2029            }
2030        );
2031        assert!(cache.get("tokens/list", "key1").await.is_none());
2032        assert_eq!(
2033            cache.upsert("tokens/list", "key3", json!({"n": 1})).await,
2034            CacheWrite::Merged
2035        );
2036
2037        // A source delete ends the entity: it may then be created again.
2038        cache.remove("tokens/list", "key1").await;
2039        assert_eq!(
2040            cache
2041                .upsert("tokens/list", "key1", json!({"id": 1, "n": 6}))
2042                .await,
2043            CacheWrite::Created
2044        );
2045    }
2046
2047    #[tokio::test]
2048    async fn a_whole_entity_ends_an_eviction() {
2049        let cache = EntityCache::with_config(EntityCacheConfig {
2050            max_entities_per_view: 1,
2051            ..Default::default()
2052        });
2053        cache.upsert("v", "a", json!({"id": "a", "n": 0})).await;
2054        cache.upsert("v", "b", json!({"id": "b", "n": 0})).await;
2055        assert!(matches!(
2056            cache.upsert("v", "a", json!({"n": 1})).await,
2057            CacheWrite::Refused { .. }
2058        ));
2059        cache
2060            .store_whole("v", "a", json!({"id": "a", "n": 1}))
2061            .await;
2062        assert_eq!(cache.get("v", "a").await, Some(json!({"id": "a", "n": 1})));
2063        assert_eq!(cache.len("v").await, 1, "still bounded: b made room");
2064        assert_eq!(
2065            cache.upsert("v", "a", json!({"n": 2})).await,
2066            CacheWrite::Merged
2067        );
2068    }
2069
2070    #[tokio::test]
2071    async fn the_evicted_key_memory_is_bounded() {
2072        let cache = EntityCache::with_config(EntityCacheConfig {
2073            max_entities_per_view: 1,
2074            ..Default::default()
2075        });
2076        let remembered = EVICTED_KEYS_PER_CACHED_ENTITY;
2077        for key in 0..=remembered + 1 {
2078            cache
2079                .upsert("v", &key.to_string(), json!({"id": key}))
2080                .await;
2081        }
2082        // Key 0 was pushed out of the memory by the evictions after it; the
2083        // most recent evictions are still remembered.
2084        assert_eq!(
2085            cache.upsert("v", "0", json!({"id": 0})).await,
2086            CacheWrite::Created
2087        );
2088        assert!(matches!(
2089            cache
2090                .upsert("v", &remembered.to_string(), json!({"n": 1}))
2091                .await,
2092            CacheWrite::Refused { .. }
2093        ));
2094    }
2095
2096    async fn write(
2097        cache: &EntityCache,
2098        key: &str,
2099        patch: Value,
2100        origin: PatchOrigin,
2101    ) -> CacheWrite {
2102        cache.upsert_with_append("v", key, patch, &[], origin).await
2103    }
2104
2105    /// A source that marks creations says outright which patches are whole:
2106    /// no memory of evictions is needed, and none can run out.
2107    #[tokio::test]
2108    async fn an_unmarked_patch_for_a_key_not_held_is_refused_however_old() {
2109        let cache = EntityCache::with_config(EntityCacheConfig {
2110            max_entities_per_view: 1,
2111            ..Default::default()
2112        });
2113        let evictions = EVICTED_KEYS_PER_CACHED_ENTITY * 4;
2114        for key in 0..=evictions {
2115            assert_eq!(
2116                write(
2117                    &cache,
2118                    &key.to_string(),
2119                    json!({"id": key}),
2120                    PatchOrigin::Creation
2121                )
2122                .await,
2123                CacheWrite::Created,
2124                "a creation is stored as soon as it arrives"
2125            );
2126        }
2127        // Key 0 was evicted far longer ago than any memory would reach.
2128        assert_eq!(
2129            write(&cache, "0", json!({"n": 1}), PatchOrigin::Change).await,
2130            CacheWrite::Refused {
2131                patch: json!({"n": 1})
2132            }
2133        );
2134        assert!(
2135            matches!(
2136                write(&cache, "never", json!({"n": 1}), PatchOrigin::Change).await,
2137                CacheWrite::Refused { .. }
2138            ),
2139            "nor does it matter whether the view ever held the key"
2140        );
2141        assert_eq!(cache.get("v", "0").await, None);
2142        assert_eq!(
2143            write(
2144                &cache,
2145                &evictions.to_string(),
2146                json!({"n": 1}),
2147                PatchOrigin::Change
2148            )
2149            .await,
2150            CacheWrite::Merged
2151        );
2152        // The source started key 0 again.
2153        assert_eq!(
2154            write(&cache, "0", json!({"id": 0, "n": 2}), PatchOrigin::Creation).await,
2155            CacheWrite::Created
2156        );
2157        assert_eq!(cache.get("v", "0").await, Some(json!({"id": 0, "n": 2})));
2158    }
2159
2160    /// A creation for a key the view still holds starts a new lifetime and
2161    /// replaces fields that no longer exist.
2162    #[tokio::test]
2163    async fn a_creation_for_a_key_held_replaces() {
2164        let cache = EntityCache::new();
2165        write(
2166            &cache,
2167            "a",
2168            json!({"id": "a", "old": 1}),
2169            PatchOrigin::Creation,
2170        )
2171        .await;
2172        assert_eq!(
2173            write(
2174                &cache,
2175                "a",
2176                json!({"id": "a", "n": 1}),
2177                PatchOrigin::Creation
2178            )
2179            .await,
2180            CacheWrite::Created
2181        );
2182        assert_eq!(cache.get("v", "a").await, Some(json!({"id": "a", "n": 1})));
2183    }
2184
2185    async fn remembers_evictions(cache: &EntityCache, view_id: &str) -> bool {
2186        cache
2187            .caches
2188            .read()
2189            .await
2190            .get(view_id)
2191            .is_some_and(|view| view.evicted.is_some())
2192    }
2193
2194    /// A view fed by a source that marks creations never consults the
2195    /// evicted-key memory, so it drops what a restore put there and keeps no
2196    /// more.
2197    #[tokio::test]
2198    async fn a_view_fed_marked_creations_keeps_no_eviction_memory() {
2199        let cache = EntityCache::with_config(EntityCacheConfig {
2200            max_entities_per_view: 1,
2201            ..Default::default()
2202        });
2203        cache.remember_evicted("v", ["restored".to_string()]).await;
2204        assert!(remembers_evictions(&cache, "v").await);
2205
2206        write(&cache, "a", json!({"id": "a"}), PatchOrigin::Creation).await;
2207        assert!(!remembers_evictions(&cache, "v").await);
2208        write(&cache, "b", json!({"id": "b"}), PatchOrigin::Creation).await;
2209        assert!(!remembers_evictions(&cache, "v").await, "a was evicted");
2210        assert!(matches!(
2211            write(&cache, "restored", json!({"n": 1}), PatchOrigin::Change).await,
2212            CacheWrite::Refused { .. }
2213        ));
2214        assert!(matches!(
2215            write(&cache, "a", json!({"n": 1}), PatchOrigin::Change).await,
2216            CacheWrite::Refused { .. }
2217        ));
2218    }
2219
2220    #[tokio::test]
2221    async fn remembered_keys_are_refused_unless_held() {
2222        let cache = EntityCache::new();
2223        cache.upsert("tokens/list", "held", json!({"id": 1})).await;
2224        cache
2225            .remember_evicted("tokens/list", ["held".to_string(), "gone".to_string()])
2226            .await;
2227        assert_eq!(
2228            cache.upsert("tokens/list", "held", json!({"n": 1})).await,
2229            CacheWrite::Merged
2230        );
2231        assert!(matches!(
2232            cache.upsert("tokens/list", "gone", json!({"n": 1})).await,
2233            CacheWrite::Refused { .. }
2234        ));
2235        // Other views are unaffected.
2236        assert_eq!(
2237            cache.upsert("tokens/state", "gone", json!({"id": 2})).await,
2238            CacheWrite::Created
2239        );
2240    }
2241
2242    #[tokio::test]
2243    async fn test_get_all() {
2244        let cache = EntityCache::new();
2245
2246        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
2247        cache.upsert("tokens/list", "key2", json!({"id": 2})).await;
2248
2249        let all = cache.get_all("tokens/list").await;
2250        assert_eq!(all.len(), 2);
2251    }
2252
2253    #[tokio::test]
2254    async fn remove_is_scoped_to_one_entity() {
2255        let cache = EntityCache::new();
2256        cache.upsert("tokens/list", "one", json!({"id": 1})).await;
2257        cache.upsert("tokens/list", "two", json!({"id": 2})).await;
2258
2259        assert_eq!(cache.remove("tokens/list", "one").await.unwrap()["id"], 1);
2260        assert!(cache.get("tokens/list", "one").await.is_none());
2261        assert!(cache.get("tokens/list", "two").await.is_some());
2262    }
2263
2264    #[tokio::test]
2265    async fn test_separate_views() {
2266        let cache = EntityCache::new();
2267
2268        cache
2269            .upsert("tokens/list", "key1", json!({"type": "token"}))
2270            .await;
2271        cache
2272            .upsert("games/list", "key1", json!({"type": "game"}))
2273            .await;
2274
2275        let token = cache.get("tokens/list", "key1").await.unwrap();
2276        let game = cache.get("games/list", "key1").await.unwrap();
2277
2278        assert_eq!(token["type"], "token");
2279        assert_eq!(game["type"], "game");
2280    }
2281
2282    #[test]
2283    fn test_deep_merge_with_append() {
2284        let mut base = json!({
2285            "a": 1,
2286            "b": {"c": 2},
2287            "arr": [1, 2]
2288        });
2289
2290        let patch = json!({
2291            "b": {"d": 3},
2292            "arr": [3],
2293            "e": 4
2294        });
2295
2296        deep_merge_with_append(&mut base, patch, &["arr".to_string()], 100);
2297
2298        assert_eq!(base["a"], 1);
2299        assert_eq!(base["b"]["c"], 2);
2300        assert_eq!(base["b"]["d"], 3);
2301        assert_eq!(base["arr"].as_array().unwrap().len(), 3);
2302        assert_eq!(base["e"], 4);
2303    }
2304
2305    #[test]
2306    fn test_deep_merge_replace_array() {
2307        let mut base = json!({
2308            "arr": [1, 2, 3]
2309        });
2310
2311        let patch = json!({
2312            "arr": [4, 5]
2313        });
2314
2315        deep_merge_with_append(&mut base, patch, &[], 100);
2316
2317        assert_eq!(base["arr"].as_array().unwrap().len(), 2);
2318        assert_eq!(base["arr"][0], 4);
2319        assert_eq!(base["arr"][1], 5);
2320    }
2321
2322    #[test]
2323    fn test_deep_merge_nested_append() {
2324        let mut base = json!({
2325            "stats": {"events": [1, 2]}
2326        });
2327
2328        let patch = json!({
2329            "stats": {"events": [3]}
2330        });
2331
2332        deep_merge_with_append(&mut base, patch, &["stats.events".to_string()], 100);
2333
2334        assert_eq!(base["stats"]["events"].as_array().unwrap().len(), 3);
2335    }
2336
2337    #[test]
2338    fn test_snapshot_config_defaults() {
2339        let cache = EntityCache::new();
2340        let config = cache.snapshot_config();
2341
2342        assert_eq!(config.initial_batch_size, 50);
2343        assert_eq!(config.subsequent_batch_size, 100);
2344    }
2345
2346    #[test]
2347    fn test_snapshot_config_custom() {
2348        let config = EntityCacheConfig {
2349            initial_snapshot_batch_size: 25,
2350            subsequent_snapshot_batch_size: 75,
2351            ..Default::default()
2352        };
2353        let cache = EntityCache::with_config(config);
2354        let snapshot_config = cache.snapshot_config();
2355
2356        assert_eq!(snapshot_config.initial_batch_size, 25);
2357        assert_eq!(snapshot_config.subsequent_batch_size, 75);
2358    }
2359
2360    #[tokio::test]
2361    async fn test_get_after() {
2362        let cache = EntityCache::new();
2363
2364        // Insert entities with _seq values
2365        cache
2366            .upsert(
2367                "tokens/list",
2368                "key1",
2369                json!({"id": 1, "_seq": "100:000000000001"}),
2370            )
2371            .await;
2372        cache
2373            .upsert(
2374                "tokens/list",
2375                "key2",
2376                json!({"id": 2, "_seq": "100:000000000002"}),
2377            )
2378            .await;
2379        cache
2380            .upsert(
2381                "tokens/list",
2382                "key3",
2383                json!({"id": 3, "_seq": "100:000000000003"}),
2384            )
2385            .await;
2386        cache
2387            .upsert(
2388                "tokens/list",
2389                "key4",
2390                json!({"id": 4, "_seq": "101:000000000001"}),
2391            )
2392            .await;
2393
2394        // Get all entities after "100:000000000002"
2395        let after = cache
2396            .get_after("tokens/list", "100:000000000002", None)
2397            .await;
2398
2399        // Should return key3 and key4 (sorted by _seq)
2400        assert_eq!(after.len(), 2);
2401        assert_eq!(after[0].0, "key3");
2402        assert_eq!(after[1].0, "key4");
2403    }
2404
2405    #[tokio::test]
2406    async fn test_get_after_with_limit() {
2407        let cache = EntityCache::new();
2408
2409        // Insert entities with _seq values
2410        cache
2411            .upsert(
2412                "tokens/list",
2413                "key1",
2414                json!({"id": 1, "_seq": "100:000000000001"}),
2415            )
2416            .await;
2417        cache
2418            .upsert(
2419                "tokens/list",
2420                "key2",
2421                json!({"id": 2, "_seq": "100:000000000002"}),
2422            )
2423            .await;
2424        cache
2425            .upsert(
2426                "tokens/list",
2427                "key3",
2428                json!({"id": 3, "_seq": "100:000000000003"}),
2429            )
2430            .await;
2431
2432        // Get entities after "100:000000000000" with limit 2
2433        let after = cache
2434            .get_after("tokens/list", "100:000000000000", Some(2))
2435            .await;
2436
2437        // Should return only first 2 (key1 and key2)
2438        assert_eq!(after.len(), 2);
2439        assert_eq!(after[0].0, "key1");
2440        assert_eq!(after[1].0, "key2");
2441    }
2442
2443    #[tokio::test]
2444    async fn test_get_after_empty_result() {
2445        let cache = EntityCache::new();
2446
2447        cache
2448            .upsert(
2449                "tokens/list",
2450                "key1",
2451                json!({"id": 1, "_seq": "100:000000000001"}),
2452            )
2453            .await;
2454
2455        // Get entities after a future cursor
2456        let after = cache
2457            .get_after("tokens/list", "999:000000000000", None)
2458            .await;
2459
2460        assert!(after.is_empty());
2461    }
2462
2463    #[tokio::test]
2464    async fn test_get_after_missing_seq() {
2465        let cache = EntityCache::new();
2466
2467        // Insert entity without _seq
2468        cache.upsert("tokens/list", "key1", json!({"id": 1})).await;
2469
2470        // Get entities after any cursor - entity without _seq should not be included
2471        let after = cache.get_after("tokens/list", "0:000000000000", None).await;
2472
2473        assert!(after.is_empty());
2474    }
2475}