Persistence
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.
How it fits together
Section titled “How it fits together”The cache stores type-erased buckets, so persistence has to move typed data (Vec<User>, Config, …) through an opaque shape. gpui-query splits that into a few pieces:
| Piece | Role |
|---|---|
Persister |
Async trait you implement: load() -> Result<PersistSnapshot, PersistError> 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
Section titled “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::<Vec<User>, MyError>( |users| serde_json::to_value(users).unwrap(),);
// JSON -> T (used by hydrate to re-prime the cache).client.register_deserializer::<Vec<User>, MyError>( |v| serde_json::from_value(v.clone()).ok(),);Implement a Persister
Section titled “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<PersistSnapshot, PersistError> { // 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
Section titled “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
Section titled “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
Section titled “Reference adapter: FilePersister”The companion crate gpui-query-persist ships a production-grade disk adapter, so you usually do not implement Persister yourself:
cargo add gpui-query-persistuse 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.)
Section titled “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.
When to persist
Section titled “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::Prefixto persist only a subtree (e.g. user data, not ephemeral feed pages).
Next steps
Section titled “Next steps”- HTTP cache headers: derive a
CachePolicyfromCache-Controland persistETags. - Caching: how
CachePolicytravels with each persisted entry. - Query keys: keys are what make a restored entry addressable.