Persistence Save and restore cached query data across app restarts with the async Persister trait, persist_with, and hydrate. Persistence lets your app start with data already in the cache instead of an empty loading state. Enable the persist feature and gpui-query drives a snapshot of your Success entries through an async backend you implement (the filesystem, a database, or a KV store). It saves on a debounce and restores on cold start. Persistence is gated behind the persist feature. Add it alongside hook: gpui-query = { version = "0.2.0", features = ["hook", "persist"] } How it fits together The cache stores type-erased buckets, so persistence has to move typed data (Vec, Config, …) through an opaque shape. gpui-query splits that into a few pieces: Piece Role Persister Async trait you implement: load() -> Result and save(&PersistSnapshot) -> Result<(), PersistError>. PersistSnapshot / PersistedEntry The on-wire shape: a map of key → { value (JSON), cached_at, cache_policy, meta }. QueryClient::persist_with A debounced driver that collects a snapshot on every cache mutation and hands it to the persister. Returns a PersistHandle drop-guard. hydrate (free fn) Cold-start restore: load a snapshot and re-prime the live cache. SerializerRegistry / DeserializerRegistry Typed T -> JSON and JSON -> T conversions, registered per T. Required for the value-carrying round-trip. PersistOptions / PersistFilter Tuning: which keys (Exact / Prefix / All), max age, debounce. PersistError / PERSIST_VERSION Typed errors; the snapshot format version loaders check. Register the typed (de)serializers Only resources whose data type T has a registered serializer are emitted, and only types with a registered deserializer come back on hydrate. Register both, keyed by the concrete T and the E you read it under: use gpui_query::client::QueryClient; #[derive(Clone, serde::Serialize, serde::Deserialize)] struct User { id: u64, name: String } #[derive(Clone, Debug)] struct MyError; let mut client = QueryClient::new(); // T -> JSON (used when collecting a snapshot). client.register_serializer::, MyError>( |users| serde_json::to_value(users).unwrap(), ); // JSON -> T (used by hydrate to re-prime the cache). client.register_deserializer::, MyError>( |v| serde_json::from_value(v.clone()).ok(), ); hydrate offers every on-disk entry to every registered deserializer. There is no type tag on PersistedEntry, so routing is by trial. A deserializer must return None for any shape that is not its own, or a foreign entry can be mis-primed. Keep them strict and cheap. Implement a Persister Persister is async and Send + Sync + 'static; the save future runs on GPUI's background executor, so blocking I/O is fine there. Be tolerant on load. A missing or corrupt store should yield an empty snapshot, not an error: use gpui_query::client::{Persister, PersistSnapshot, PersistError}; struct DbPersister { /* your handle */ } impl Persister for DbPersister { async fn load(&self) -> Result { // Read from your backend. Missing/corrupt → Ok(PersistSnapshot::new()). Ok(PersistSnapshot::new()) } async fn save(&self, snapshot: &PersistSnapshot) -> Result<(), PersistError> { // Serialize `snapshot` (it is Serialize) and write it out. Ok(()) } } For tests or a disabled mode, NoopPersister persists nothing and loads an empty snapshot. Save with persist_with QueryClient::persist_with(persister, opts, cx) observes cache mutations and, after a short debounce, collects a fresh snapshot and calls persister.save. Bursts coalesce into one save per window. It returns a PersistHandle. Hold it for as long as you want saves to continue; dropping it stops scheduling new saves (one save already waiting on its timer may still finish). use gpui_query::client::{QueryClient, PersistOptions, PersistFilter}; # fn doc(client: &QueryClient, cx: &mut gpui::App) { // Defaults: every key, max age 24h, 500ms debounce. let _handle = client.persist_with(DbPersister, PersistOptions::default(), cx); // ...or scope and tune it: use std::time::Duration; let opts = PersistOptions { filter: PersistFilter::Prefix("users".into()), // only the "users" subtree max_age: Duration::from_secs(60 * 60), debounce: Duration::from_millis(250), }; let _handle = client.persist_with(DbPersister, opts, cx); # } For a one-shot snapshot without the driver, call client.collect_persist_snapshot(&filter, max_age, cx) and persist it yourself. Restore on cold start The free hydrate function loads a snapshot and re-primes the live cache through the registered deserializers. Call it once during startup, before your views mount. use std::time::Duration; use gpui_query::client::{hydrate, PersistFilter}; # async fn doc(client: &mut gpui_query::client::QueryClient, cx: &mut gpui::App) { let persister = DbPersister; match hydrate(client, &persister, &PersistFilter::All, Duration::from_secs(86_400), cx).await { Ok(snapshot) => { // Entries with a registered deserializer are already primed. // `snapshot` is returned so you can do ad-hoc typed priming for the rest. } Err(e) => eprintln!("hydrate failed: {e}"), } # } Entries older than max_age, excluded by filter, or without a matching deserializer are skipped. A snapshot whose version does not match PERSIST_VERSION returns PersistError::VersionMismatch. Reference adapter: FilePersister The companion crate gpui-query-persist (https://crates.io/crates/gpui-query-persist) ships a production-grade disk adapter, so you usually do not implement Persister yourself: cargo add gpui-query-persist use gpui_query_persist::FilePersister; use gpui_query::client::{QueryClient, PersistOptions}; # fn doc(client: &QueryClient, cx: &mut gpui::App) { // JSON (human-readable) or Bincode (compact); in_cache_dir roots at the OS cache dir. let persister = FilePersister::json("path/to/cache.json"); // let persister = FilePersister::in_cache_dir("my-app").unwrap(); let _handle = client.persist_with(persister, PersistOptions::default(), cx); # } Each save is atomic and durable: it writes a sibling temp file, fsyncs it (issuing F_FULLFSYNC on macOS), renames it over the target, and fsyncs the parent directory on POSIX, so a crash mid-write never leaves a corrupt cache. Loading is tolerant: a missing file yields an empty snapshot, a corrupt file is logged and treated as empty, and a version mismatch surfaces as a typed PersistError::VersionMismatch. Opaque metadata (ETags, etc.) If you fetch over HTTP, you can attach opaque metadata to a fetched value with Fetched::with_meta. It lands in PersistedEntry::meta and round-trips back through persistence. That lets you issue cheap 304 refetches after relaunch. See HTTP cache headers (/docs/guides/http-caching). When to persist - On app shutdown: the simplest correct strategy. persist_with's debounce lands a final flush shortly after the last mutation; drop the handle on quit. - Continuously, debounced: for long-running apps that may be killed without a clean shutdown. The default 500ms debounce keeps write volume bounded. - Scoped: use PersistFilter::Prefix to persist only a subtree (e.g. user data, not ephemeral feed pages). Sanitize errors before they reach persistence. A QueryError built from a raw server response can carry tokens or connection strings. See Error handling: Error sanitization (/docs/guides/error-handling#error-sanitization). Next steps - HTTP cache headers (/docs/guides/http-caching): derive a CachePolicy from Cache-Control and persist ETags. - Caching (/docs/guides/caching): how CachePolicy travels with each persisted entry. - Query keys (/docs/guides/query-keys): keys are what make a restored entry addressable.