Persistence
Persistence lets your app start with data already in the cache instead of an empty loading state. gpui-query provides a QueryPersister trait you implement against any backend — the filesystem, a database, an in-memory store — plus dehydrate() / persist() / restore() to drive snapshots in and out of the QueryClient.
The pieces
| Piece | Role |
|---|---|
QueryPersister | Trait you implement: load() -> Vec<DehydratedEntry> and save(...). |
DehydratedEntry | One cached resource: key, TypeId, kind ("query" / "infinite" / "mutation"). |
DehydratedState | A Vec<DehydratedEntry> snapshot of all Success resources. |
QueryClient::dehydrate | Produce a snapshot from the live cache. |
QueryClient::persist / restore | Save a snapshot via a persister / load entries back. |
QueryClient::set_query_data | Restore typed data for a key (the hydration step). |
Implementing a persister
QueryPersister is Send + Sync with two methods. Entries flow through as plain values — the backend is free to serialize them however it likes (JSON, bincode, a database row).
use std::path::PathBuf;
use gpui_query::client::{QueryPersister, DehydratedEntry};
struct FilePersister {
path: PathBuf,
}
impl QueryPersister for FilePersister {
fn load(&self) -> Vec<DehydratedEntry> {
// Read the file, deserialize, return entries.
// On any failure, return an empty vec (cold start).
Vec::new()
}
fn save(&self, _entries: Vec<DehydratedEntry>) {
// Serialize `entries` and write to `self.path`.
}
}
Because the QueryClient stores type-erased buckets, the persister deals in DehydratedEntry values — key + TypeId + kind — not in typed data. The actual T payload is serialized and restored at the call site where the concrete type is known (see Restoring typed data below).
Saving state
QueryClient::persist(persister, cx) dehydrates the live cache and hands the entries to the persister. Call it on shutdown, on a timer, or as part of GC — whatever cadence matches your app.
use gpui_query::client::QueryClient;
# use std::sync::Arc;
# struct FilePersister;
# impl gpui_query::client::QueryPersister for FilePersister {
# fn load(&self) -> Vec<gpui_query::client::DehydratedEntry> { vec![] }
# fn save(&self, _: Vec<gpui_query::client::DehydratedEntry>) {}
# }
# fn _doc(cx: &mut gpui::App) {
# let client = cx.global::<QueryClient>();
# let persister = Arc::new(FilePersister);
client.persist(&*persister, cx);
# }
Only resources in Success status are included; Idle, Loading, Failure, and Cancelled resources are skipped — you don't want to restore half-finished fetches.
Restoring typed data
restore(persister) is an associated function (it does not read &self) that loads the raw entries. Hydration itself is type-specific: iterate the entries and call set_query_data::<T, E>(key, data, cx) for each key you recognize.
use gpui_query::client::QueryClient;
# use std::sync::Arc;
# #[derive(Clone)] struct User;
# #[derive(Clone, Debug)] struct MyError;
# struct FilePersister;
# impl gpui_query::client::QueryPersister for FilePersister {
# fn load(&self) -> Vec<gpui_query::client::DehydratedEntry> { vec![] }
# fn save(&self, _: Vec<gpui_query::client::DehydratedEntry>) {}
# }
# fn _doc(cx: &mut gpui::App) {
# let mut client = QueryClient::new();
// 1. Load whatever was persisted.
let entries = QueryClient::restore(&(FilePersister));
// 2. For each entry, restore typed data you know how to deserialize.
for entry in entries {
match entry.key.as_str() {
"users" => {
let cached: Vec<User> = read_users_from_disk(); // your deserializer
client.set_query_data::<Vec<User>, MyError>("users", cached, cx);
}
other => {
// Unknown key — skip, or store for later typed restoration.
}
}
}
# }
# fn read_users_from_disk() -> Vec<User> { vec![] }
# }
set_query_data writes directly into the cache for a key, creating the resource if it does not already exist. The resource's previous data is saved for rollback via rollback_to_previous(), but the status and timestamp are not changed — this is manual cache manipulation where you control the lifecycle.
Why restore is type-specific
The QueryClient partitions resources by TypeId of (T, E). Once erased into a bucket, the payload cannot be downcast back without knowing the concrete type. So the persister stores the metadata (key, TypeId, kind) and the call site — which knows it is restoring Vec<User> — performs the typed set_query_data call. This mirrors TanStack Query's split between the dehydrated state and the queryClient.setQueryData(key, data) calls that repopulate it.
hydrate
QueryClient::hydrate(state, cx) is the symmetric entry point to dehydrate(). It accepts a DehydratedState and is the documented hook for typed hydration — internally it delegates to the same per-entry set_query_data flow shown above. Use dehydrate() + hydrate() when you are moving state between two in-memory clients (for example, during tests or a workspace transfer), and persist() / restore() when the destination is an external backend.
When to persist
- On app shutdown — the simplest correct strategy. Persist once when the user quits, restore on next launch.
- On a timer — for long-running apps that may be killed without a clean shutdown. A few times per minute is usually plenty; the snapshot is cheap because it only records
Successentries. - As part of GC — you can call
persist()from the same place GC runs, so eviction and persistence stay in step. Be careful not to persist on every GC pass if your backend write is expensive.
Sanitize errors before they reach persistence. A QueryError built from a raw server response can contain tokens or connection strings; once it is on a resource it can flow into a dehydrate snapshot. See Error handling — Sanitizing errors.
Next steps
- DevTools —
DehydratedStateis the same structure diagnostics read from. - Query keys — keys are what make a restored entry addressable.
- Error handling — sanitize before you persist.