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}