Skip to main content

gpui_query/client/
persist.rs

1//! Async, value-carrying persistence layer for [`QueryClient`](super::QueryClient).
2//!
3//! This is the Phase B enrichment of the shipped (synchronous, metadata-only)
4//! skeleton. It adds:
5//!
6//! - an async [`Persister`] trait (non-object-safe; generic over the future),
7//! - a value-carrying [`PersistedEntry`] (opaque `serde_json::Value` payload),
8//! - a debounced [`QueryClient::persist_with`] driver keyed off the precise
9//!   [`CacheMutation`](super::CacheMutation) dirty signal,
10//! - a typed-serializer registry so core can serialize concrete `T` without a
11//!   `T: Serialize` bound leaking onto every resource, and a matching
12//!   deserializer registry so [`hydrate`] can re-prime concrete values.
13//!
14//! See `docs/features.md` and the plan (`snug-wobbling-puzzle.md`, Phase B) for
15//! the design rationale.
16
17use std::any::TypeId;
18use std::collections::HashMap;
19use std::future::Future;
20use std::sync::Arc;
21use std::time::Duration;
22
23use gpui::{App, BorrowAppContext as _, Subscription};
24use serde_json::Value as JsonValue;
25use thiserror::Error;
26
27use crate::core::{CachePolicy, QueryKey};
28
29use super::QueryClient;
30// The erased-bucket traits' `collect_persistable_into` methods are dispatched
31// via the trait object's vtable (`Box<dyn ErasedBucket>`), so the traits
32// themselves need not be imported here.
33
34/// Current on-disk snapshot format version. Bumped when the serialized shape of
35/// [`PersistSnapshot`] changes in a backwards-incompatible way; loaders reject
36/// mismatched versions with [`PersistError::VersionMismatch`].
37pub const PERSIST_VERSION: u32 = 1;
38
39// ── Errors ───────────────────────────────────────────────────────────────
40
41/// Errors produced by the persistence layer.
42///
43/// Every IO failure from a [`Persister`] implementation is mapped to a variant
44/// here rather than panicked on; loaders tolerate corrupt/missing files (see
45/// [`FilePersister`](../../gpui_query_persist/struct.FilePersister.html)) by
46/// degrading to an empty snapshot.
47#[derive(Debug, Error)]
48pub enum PersistError {
49    /// An underlying IO error (read or write) failed.
50    #[error("persistence io error: {0}")]
51    Io(#[from] std::io::Error),
52    /// Serializing the snapshot (or an entry) to the persister's format failed.
53    #[error("persistence serialize error: {0}")]
54    Serialize(#[from] serde_json::Error),
55    /// The on-disk snapshot could not be parsed / deserialized.
56    ///
57    /// Reserved for persister implementations that surface (rather than
58    /// tolerate) deserialization failures; the shipped `FilePersister` degrades
59    /// corrupt stores to an empty snapshot instead, so core never constructs
60    /// this variant. It is retained on the public API for backends that prefer
61    /// to propagate parse errors.
62    #[error("persistence deserialize error: {0}")]
63    Deserialize(String),
64    /// The on-disk snapshot's `version` does not match [`PERSIST_VERSION`].
65    ///
66    /// Treated as a typed error (rather than silent empty-snapshot) so callers
67    /// can distinguish "file was corrupt" from "file was written by a
68    /// newer/older format we cannot read".
69    #[error("persistence version mismatch: expected {expected}, found {found}")]
70    VersionMismatch {
71        /// The version this loader understands ([`PERSIST_VERSION`]).
72        expected: u32,
73        /// The version actually found on disk.
74        found: u32,
75    },
76    /// The requested path was unusable (e.g. the OS returned no cache dir).
77    #[error("persistence bad path: {0}")]
78    BadPath(String),
79    /// The persister could not acquire a required resource (e.g. file lock).
80    #[error("persistence permission denied: {0}")]
81    Permission(String),
82}
83
84// ── Snapshot types ───────────────────────────────────────────────────────
85
86/// A single persisted cache entry carrying the typed data as an opaque JSON
87/// value plus the metadata needed to re-prime and re-validate it.
88///
89/// `value` is opaque to core (a `serde_json::Value`); the typed round-trip is
90/// driven by the serializer/deserializer registries on [`QueryClient`]. This is
91/// Open Question 3 from the design doc.
92#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
93pub struct PersistedEntry {
94    /// The serialized data value. Opaque to core.
95    pub value: JsonValue,
96    /// Wall-clock ms (since UNIX epoch) the entry was cached.
97    pub cached_at: u64,
98    /// The cache policy in force when the entry was cached.
99    pub cache_policy: CachePolicy,
100    /// Optional opaque metadata (e.g. ETag/Last-Modified for HTTP). Reserved
101    /// for the `gpui-query-http` companion crate.
102    pub meta: Option<JsonValue>,
103}
104
105/// A full snapshot of the persistable cache, ready to hand to a [`Persister`].
106#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
107pub struct PersistSnapshot {
108    /// The persistable entries, keyed by [`QueryKey`] path string.
109    ///
110    /// Keys are stored as their `to_path()` `String` form so the snapshot is
111    /// self-contained (no `Arc` aliasing across processes) and serializable.
112    pub entries: HashMap<String, PersistedEntry>,
113    /// Format version; see [`PERSIST_VERSION`] and
114    /// [`PersistError::VersionMismatch`].
115    pub version: u32,
116}
117
118impl PersistSnapshot {
119    /// Construct an empty snapshot at the current version.
120    pub fn new() -> Self {
121        Self {
122            entries: HashMap::new(),
123            version: PERSIST_VERSION,
124        }
125    }
126}
127
128// ── Owned filter (vs core's borrowing QueryKeyFilter<'a>) ────────────────
129
130/// Owned counterpart to [`QueryKeyFilter`](crate::core::QueryKeyFilter) for the
131/// persistence layer.
132///
133/// The core filter borrows (`Exact(&QueryKey)` / `Prefix(&QueryKey)`) and is
134/// therefore neither `Serialize` nor storable inside [`PersistOptions`]; this
135/// owned enum lets a caller pin the filter into a long-lived `persist_with`
136/// driver.
137#[derive(Clone, Debug)]
138pub enum PersistFilter {
139    /// Persist only the entry matching exactly this key.
140    Exact(QueryKey),
141    /// Persist every entry whose key starts with this prefix.
142    Prefix(QueryKey),
143    /// Persist every persistable entry.
144    All,
145}
146
147impl PersistFilter {
148    /// Returns `true` if `key` should be included under this filter.
149    pub fn matches(&self, key: &QueryKey) -> bool {
150        match self {
151            PersistFilter::Exact(target) => key == target,
152            PersistFilter::Prefix(prefix) => key.starts_with(prefix),
153            PersistFilter::All => true,
154        }
155    }
156}
157
158/// Tuning knobs for [`QueryClient::persist_with`].
159///
160/// `Default` is: every entry, max age 24 hours, 500 ms debounce — a sensible
161/// "save the cache to disk shortly after it changes" baseline.
162#[derive(Clone, Debug)]
163pub struct PersistOptions {
164    /// Which entries to include.
165    pub filter: PersistFilter,
166    /// Skip entries older than this at save time.
167    pub max_age: Duration,
168    /// Coalesce bursts of [`CacheMutation`](super::CacheMutation) into one save
169    /// per window.
170    ///
171    /// Passing [`Duration::ZERO`] disables the timer-based coalescing window —
172    /// each bump still races to drain the pending slot, but there is no
173    /// batching delay (saves still serialize through the drain slot).
174    pub debounce: Duration,
175}
176
177impl Default for PersistOptions {
178    fn default() -> Self {
179        Self {
180            filter: PersistFilter::All,
181            max_age: Duration::from_secs(24 * 60 * 60),
182            debounce: Duration::from_millis(500),
183        }
184    }
185}
186
187// ── Serializer / deserializer registries ─────────────────────────────────
188
189/// Type-erased serializer closure: `&dyn Any -> Option<serde_json::Value>`.
190///
191/// `None` means the downcast to the registered `T` failed — unreachable by
192/// construction (the bucket looks the closure up by `TypeId::of::<T>()` and
193/// passes that same `T`), but callers skip the entry rather than persisting a
194/// placeholder, so a future invariant break can never panic the foreground
195/// thread from inside the persistence path.
196type SerializeFn = Box<dyn Fn(&dyn std::any::Any) -> Option<JsonValue> + Send + Sync>;
197
198/// Registry of `T -> serde_json::Value` serializers, keyed by `TypeId` of the
199/// resource's data type `T`.
200///
201/// The keying is intentionally on `T` alone (not the full `(T, E)` resource
202/// pair): the bucket impls (`erased_ops.rs`, `infinite_bucket.rs`) likewise
203/// look up by `TypeId::of::<T>()`, so insert and lookup are consistent on `T`.
204/// Serialization only depends on the data type, not the error type. Consequence:
205/// registering serializers for the same `T` under two different `E` types
206/// silently overwrites (last write wins); whichever serializer survives is
207/// applied to both `(T, E)` buckets, which is correct because the value *is*
208/// that `T`.
209///
210/// Stored closures accept `&dyn Any` and downcast internally, so the registry
211/// stays free of `T: Serialize` bounds on the resource itself.
212#[derive(Default)]
213pub struct SerializerRegistry {
214    serializers: HashMap<TypeId, SerializeFn>,
215}
216
217impl SerializerRegistry {
218    /// Register a serializer for `T`.
219    ///
220    /// `f` receives a `&T` already downcast by the bucket impl; we erase it to
221    /// `&dyn Any` here so the registry is heterogeneous.
222    pub fn register<T: 'static>(&mut self, f: fn(&T) -> JsonValue) {
223        let wrap = move |any: &dyn std::any::Any| -> Option<JsonValue> {
224            // Downcast to the concrete `T` this closure was registered for. The
225            // bucket impl only invokes this after looking the closure up by
226            // `TypeId::of::<T>()`, so the `Any` is that `T` and the downcast
227            // always succeeds — but degrade to `None` (the bucket then skips the
228            // entry) instead of `.expect()`, honoring the no-panic rule for the
229            // persistence path even if the TypeId invariant ever breaks.
230            any.downcast_ref::<T>().map(f)
231        };
232        self.serializers.insert(TypeId::of::<T>(), Box::new(wrap));
233    }
234
235    /// Look up the serializer closure registered for the given data `TypeId`.
236    pub(crate) fn get(&self, type_id: TypeId) -> Option<&SerializeFn> {
237        self.serializers.get(&type_id)
238    }
239
240    /// Returns `true` if a serializer is registered for `type_id`.
241    pub fn contains(&self, type_id: TypeId) -> bool {
242        self.serializers.contains_key(&type_id)
243    }
244}
245
246/// Type-erased hydrate step: decode `JsonValue -> ()`, priming the live cache
247/// via `client.set_query_data::<T, E>(key, value, cx)`. The closure captures
248/// the concrete `T`/`E` in its monomorphized `register` call site, so it can
249/// downcast/re-prime without the registry layer knowing the types.
250type HydrateStep =
251    Arc<dyn Fn(&mut QueryClient, &QueryKey, &JsonValue, &mut App) -> bool + Send + Sync>;
252
253/// Registry of `serde_json::Value -> primed cache entry` steps, keyed by
254/// `TypeId` of the resource's data type `T`. Used by [`hydrate`] to re-prime
255/// erased on-disk values to concrete `T` so they can be handed to
256/// `set_query_data`.
257///
258/// Each step returns `true` if it successfully decoded and primed the value,
259/// `false` if the value was unparseable (the entry is then skipped).
260#[derive(Default)]
261pub struct DeserializerRegistry {
262    steps: Vec<(TypeId, HydrateStep)>,
263}
264
265impl DeserializerRegistry {
266    /// Register a deserializer for resources of type `(T, E)`.
267    ///
268    /// `deserialize` returns `Option<T>`; `None` means the value was
269    /// unparseable and the entry is skipped during hydration. On `Some(t)`,
270    /// `t` is primed into the cache via `set_query_data::<T, E>(key, t, cx)`.
271    pub fn register<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>)
272    where
273        T: Clone + Send + Sync + 'static,
274        E: Clone + Send + Sync + 'static,
275    {
276        let step = move |client: &mut QueryClient,
277                         key: &QueryKey,
278                         value: &JsonValue,
279                         cx: &mut App|
280              -> bool {
281            let Some(t) = deserialize(value) else {
282                return false;
283            };
284            client.set_query_data::<T, E>(key.clone(), t, cx);
285            true
286        };
287        self.steps.push((TypeId::of::<(T, E)>(), Arc::new(step)));
288    }
289
290    /// Iterate every registered `(TypeId, hydrate-step)` pair. Used by
291    /// [`hydrate`] to find which registry entry owns a given on-disk key.
292    fn iter(&self) -> impl Iterator<Item = (TypeId, HydrateStep)> {
293        self.steps.iter().map(|(k, v)| (*k, Arc::clone(v)))
294    }
295}
296
297// ── Persister trait ──────────────────────────────────────────────────────
298
299/// Async persistence backend for [`QueryClient::persist_with`].
300///
301/// Non-object-safe (methods return `impl Future`): the trait is consumed
302/// generically by `persist_with<P: Persister>`, which monomorphizes the driver
303/// around the concrete `P`. This avoids `Pin<Box<dyn Future>>` overhead and
304/// keeps the `Send + 'static` bounds visible at the call site (the save future
305/// runs on GPUI's `background_executor`, so it must be `Send + 'static`).
306///
307/// Implementations store cached data in any backend (filesystem, database,
308/// KV store, …). See [`gpui_query_persist::FilePersister`] for a reference
309/// disk adapter.
310pub trait Persister: Send + Sync + 'static {
311    /// Load the snapshot from storage.
312    ///
313    /// Implementations should be tolerant: a missing store yields an empty
314    /// snapshot, a corrupt store yields an empty snapshot + a logged warning
315    /// (or a typed [`PersistError`] for version mismatches the caller may wish
316    /// to handle).
317    fn load(&self) -> impl Future<Output = Result<PersistSnapshot, PersistError>> + Send;
318
319    /// Save `snapshot`, replacing any previously stored data.
320    fn save(
321        &self,
322        snapshot: &PersistSnapshot,
323    ) -> impl Future<Output = Result<(), PersistError>> + Send;
324}
325
326// ── PersistHandle ────────────────────────────────────────────────────────
327
328/// Drop-guard returned by [`QueryClient::persist_with`].
329///
330/// Holding the handle keeps the underlying [`CacheMutation`](super::CacheMutation)
331/// observation (and thus the debounced save loop) alive; dropping it drops the
332/// [`Subscription`], so no *new* saves are scheduled. A save task already
333/// waiting on its debounce timer is detached and may still complete one final
334/// save. The wrapped [`Persister`] is held in an `Arc` so the spawned save
335/// future can use it after `persist_with` returns.
336pub struct PersistHandle {
337    // Subscription is dropped when the handle is, ending observation.
338    _subscription: Option<Subscription>,
339}
340
341impl PersistHandle {
342    /// Construct a handle that does nothing on drop (for tests / no-op).
343    pub fn empty() -> Self {
344        Self {
345            _subscription: None,
346        }
347    }
348}
349
350// ── QueryClient methods ─────────────────────────────────────────────────
351
352impl QueryClient {
353    /// Register a serializer for resources of type `(T, E)`.
354    ///
355    /// Only resources whose `T` has a registered serializer are emitted by the
356    /// value-carrying `collect_persistable_into` path; unregistered types fall
357    /// back to metadata-only (skipped), matching the legacy `dehydrate`.
358    ///
359    /// `f` is a `fn(&T) -> serde_json::Value` (a plain function pointer, not a
360    /// closure) so it is `Send + Sync + 'static` withoutboxing overhead.
361    pub fn register_serializer<T, E>(&mut self, f: fn(&T) -> JsonValue)
362    where
363        T: Clone + Send + Sync + 'static,
364        E: Clone + Send + Sync + 'static,
365    {
366        let registry = self
367            .serializers
368            .get_or_insert_with(SerializerRegistry::default);
369        registry.register::<T>(f);
370    }
371
372    /// Register a deserializer for resources of type `(T, E)`, enabling
373    /// [`hydrate`] to re-prime on-disk values of this type.
374    ///
375    /// **Strict-deserializer contract.** [`hydrate`] offers every on-disk entry
376    /// to *every* registered deserializer (there is no type discriminator on
377    /// [`PersistedEntry`], so routing is by trial). A deserializer MUST return
378    /// `None` for any JSON shape it does not recognize as its own `T`; only
379    /// return `Some` for values that genuinely decode to `T`. A lax
380    /// deserializer that accepts a foreign shape would mis-prime the wrong
381    /// bucket. (Each `(T, E)` writes to its own bucket, so typed data is not
382    /// clobbered, but a permissive decoder wastes work and can prime a stale
383    /// value.) Keep deserializers strict and cheap.
384    pub fn register_deserializer<T, E>(&mut self, deserialize: fn(&JsonValue) -> Option<T>)
385    where
386        T: Clone + Send + Sync + 'static,
387        E: Clone + Send + Sync + 'static,
388    {
389        let registry = self
390            .deserializers
391            .get_or_insert_with(DeserializerRegistry::default);
392        registry.register::<T, E>(deserialize);
393    }
394
395    /// Collect a value-carrying snapshot from the live cache, honoring `filter`
396    /// and `max_age`. Only resources with a registered serializer (and in
397    /// `Success` status) are included.
398    pub fn collect_persist_snapshot(
399        &self,
400        filter: &PersistFilter,
401        max_age: Duration,
402        cx: &App,
403    ) -> PersistSnapshot {
404        let Some(ref registry) = self.serializers else {
405            return PersistSnapshot::new();
406        };
407        let now_ms = crate::client::time::current_time_ms();
408        let max_age_ms = max_age.as_millis() as u64;
409
410        let mut out: Vec<(QueryKey, PersistedEntry)> = Vec::new();
411        for bucket in self.buckets.values() {
412            bucket.collect_persistable_into(cx, registry, now_ms, &mut out);
413        }
414        for bucket in self.infinite_buckets.values() {
415            bucket.collect_persistable_into(cx, registry, now_ms, &mut out);
416        }
417
418        // Enrich each collected entry with any opaque metadata recorded for its
419        // key at fetch-completion time (see QueryClient::record_meta), so HTTP
420        // CacheMeta and similar round-trip through PersistedEntry.meta.
421        if let Some(meta_map) = &self.persisted_meta {
422            for (key, entry) in &mut out {
423                if let Some(m) = meta_map.get(key) {
424                    entry.meta = Some(m.clone());
425                }
426            }
427        }
428
429        let mut snapshot = PersistSnapshot::new();
430        for (key, entry) in out {
431            if !filter.matches(&key) {
432                continue;
433            }
434            if max_age_ms > 0 && now_ms.saturating_sub(entry.cached_at) > max_age_ms {
435                continue;
436            }
437            snapshot.entries.insert(key.to_path(), entry);
438        }
439        snapshot
440    }
441
442    /// Drive a [`Persister`] from the live cache, debounced on the
443    /// [`CacheMutation`](super::CacheMutation) dirty signal.
444    ///
445    /// On every `CacheMutation` bump, the callback:
446    /// 1. collects a fresh [`PersistSnapshot`] (cheap; main thread, has `&App`),
447    /// 2. stashes it in a shared slot, replacing any pending snapshot,
448    /// 3. spawns a debounced task that, after `opts.debounce`, takes the latest
449    ///    snapshot from the slot and runs `persister.save(&snapshot)` on the
450    ///    background executor.
451    ///
452    /// Rapid bursts coalesce: only the most recently collected snapshot is
453    /// saved when the debounce timer elapses. Returning the [`PersistHandle`]
454    /// keeps the observation alive; dropping it stops further saves.
455    pub fn persist_with<P: Persister>(
456        &self,
457        persister: P,
458        opts: PersistOptions,
459        cx: &mut App,
460    ) -> PersistHandle {
461        let persister: Arc<P> = Arc::new(persister);
462        let debounce = opts.debounce;
463        // Shared slot for the latest pending snapshot. Replaced on every bump;
464        // drained by the debounced save task.
465        let pending: Arc<std::sync::Mutex<Option<PersistSnapshot>>> =
466            Arc::new(std::sync::Mutex::new(None));
467
468        // Bound on in-flight debounce tasks: at most one pending per window.
469        // A bump that arrives while a task is already armed skips spawning a
470        // new one (its snapshot still lands in `pending`, where the armed task
471        // will drain it), so a burst produces one task rather than N. Cleared
472        // by the task after it drains (or finds empty) the slot — on every
473        // path, so persistence can never get stuck never-spawning-again.
474        let armed: Arc<std::sync::Mutex<bool>> = Arc::new(std::sync::Mutex::new(false));
475
476        // Ensure the marker exists before observing. The bump sites (see
477        // `mutation_signal.rs`) call `cx.default_global::<CacheMutation>()`,
478        // which — like `set_global`/`global_mut` — pushes a
479        // `NotifyGlobalObservers` effect; that notification is what wakes this
480        // observer. We seed the marker here too so it is guaranteed present
481        // before observation is registered (the idempotent seeding itself also
482        // notifies, harmlessly).
483        let _ = cx.default_global::<super::CacheMutation>();
484
485        let subscription = {
486            let persister = persister.clone();
487            let pending = pending.clone();
488            let armed = armed.clone();
489            let filter = opts.filter;
490            let max_age = opts.max_age;
491            let bg = cx.background_executor().clone();
492            cx.observe_global::<super::CacheMutation>(move |cx| {
493                // Collect fresh snapshot on the main thread (has &App).
494                let snapshot = cx.update_global::<QueryClient, _>(|client, cx| {
495                    client.collect_persist_snapshot(&filter, max_age, cx)
496                });
497                // Stash as the latest pending snapshot.
498                if let Ok(mut slot) = pending.lock() {
499                    *slot = Some(snapshot);
500                }
501                // Spawn a debounced save only if no task is already armed for
502                // this window; otherwise let the in-flight task drain the slot
503                // we just stashed (latest snapshot wins).
504                {
505                    let Ok(mut guard) = armed.lock() else {
506                        return;
507                    };
508                    if *guard {
509                        return;
510                    }
511                    *guard = true;
512                }
513                let persister = persister.clone();
514                let pending = pending.clone();
515                let armed = armed.clone();
516                let bg_for_future = bg.clone();
517                bg.spawn(async move {
518                    if !debounce.is_zero() {
519                        bg_for_future.timer(debounce).await;
520                    }
521                    // Take the latest snapshot (or no-op if a later task
522                    // already drained the slot). Clear `armed` on every path so
523                    // the next bump can spawn again — do it after draining so a
524                    // bump that lands during the window still coalesces into
525                    // this task's drain.
526                    let snapshot = pending.lock().ok().and_then(|mut slot| slot.take());
527                    if let Ok(mut guard) = armed.lock() {
528                        *guard = false;
529                    }
530                    let Some(snapshot) = snapshot else { return };
531                    if let Err(err) = persister.save(&snapshot).await {
532                        #[cfg(debug_assertions)]
533                        eprintln!("persist_with: save failed: {err}");
534                    }
535                })
536                .detach();
537            })
538        };
539
540        PersistHandle {
541            _subscription: Some(subscription),
542        }
543    }
544}
545
546// ── NoopPersister ────────────────────────────────────────────────────────
547
548/// A [`Persister`] that persists nothing and loads an empty snapshot.
549///
550/// Useful as a default, for tests that only exercise the dirty-signal/debounce
551/// path, or as a base to compose with a real persister behind a feature flag.
552pub struct NoopPersister;
553
554impl Persister for NoopPersister {
555    async fn load(&self) -> Result<PersistSnapshot, PersistError> {
556        Ok(PersistSnapshot::new())
557    }
558
559    async fn save(&self, _snapshot: &PersistSnapshot) -> Result<(), PersistError> {
560        Ok(())
561    }
562}
563
564// ── hydrate ──────────────────────────────────────────────────────────────
565
566/// Load a snapshot from `persister` and re-prime the live cache with it.
567///
568/// This is the value-carrying counterpart to the (metadata-only)
569/// [`QueryClient::hydrate`](super::QueryClient::hydrate). For each on-disk
570/// entry whose `(T, E)` has a registered deserializer (see
571/// [`QueryClient::register_deserializer`]), the JSON `value` is decoded and
572/// primed via `set_query_data::<T, E>`. Entries without a registered
573/// deserializer are skipped (the caller can still inspect them via the
574/// returned [`PersistSnapshot`] for ad-hoc typed priming, matching the legacy
575/// `hydrate` escape hatch).
576///
577/// Entries older than `max_age` or excluded by `filter` are skipped.
578///
579/// **Routing.** There is no type discriminator on [`PersistedEntry`], so every
580/// surviving entry is offered to every registered deserializer (O(deserializers
581/// × entries)); each is primed by the first deserializer that decodes it. This
582/// relies on the strict-deserializer contract of
583/// [`QueryClient::register_deserializer`] — keep deserializers strict.
584///
585/// Returns the loaded snapshot (post-filter) so callers can perform additional
586/// metadata-only priming or diagnostics. Errors from the persister's `load`
587/// propagate.
588pub async fn hydrate<P: Persister>(
589    client: &mut QueryClient,
590    persister: &P,
591    filter: &PersistFilter,
592    max_age: Duration,
593    cx: &mut App,
594) -> Result<PersistSnapshot, PersistError> {
595    let snapshot = persister.load().await?;
596    // If the persister already enforces version, we still double-check here so
597    // an in-memory persister can't silently feed a mismatched snapshot.
598    if snapshot.version != PERSIST_VERSION {
599        return Err(PersistError::VersionMismatch {
600            expected: PERSIST_VERSION,
601            found: snapshot.version,
602        });
603    }
604    let now_ms = crate::client::time::current_time_ms();
605    let max_age_ms = max_age.as_millis() as u64;
606
607    let Some(deserializers) = client.deserializers.as_ref() else {
608        return Ok(snapshot);
609    };
610
611    // Clone the step list out (cheap `Arc` bumps) so we drop the immutable
612    // borrow on `client` before calling `step(client, …)` which needs
613    // `&mut QueryClient` (it calls `set_query_data`).
614    let steps: Vec<HydrateStep> = deserializers.iter().map(|(_, s)| s).collect();
615
616    // For each registered (T, E) hydrate-step, walk the snapshot entries and
617    // let the step decode + prime any matching key. Because each step is
618    // monomorphized over concrete (T, E), it downcasts safely inside its own
619    // closure — no cross-type confusion.
620    for step in steps {
621        for (key_path, entry) in &snapshot.entries {
622            let key = QueryKey::from(key_path.as_str());
623            if !filter.matches(&key) {
624                continue;
625            }
626            if max_age_ms > 0 && now_ms.saturating_sub(entry.cached_at) > max_age_ms {
627                continue;
628            }
629            step(client, &key, &entry.value, cx);
630        }
631    }
632
633    Ok(snapshot)
634}