Skip to content

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.

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.

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(),
);

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.

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.

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.

The companion crate gpui-query-persist ships a production-grade disk adapter, so you usually do not implement Persister yourself:

Terminal window
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.

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.

  • 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).
  • HTTP cache headers: derive a CachePolicy from Cache-Control and persist ETags.
  • Caching: how CachePolicy travels with each persisted entry.
  • Query keys: keys are what make a restored entry addressable.