Guides
Queries & mutations
Data fetching, caching and offline behaviour are not something each platform reimplements. They live in the core, next to the state they feed, and every platform observes the same cache.
Why the data layer is in the core#
Fetching, caching, retrying, optimistic updates and offline queues are logic, and logic is what Undra writes once. The core never opens a socket: every request goes through the Http port, which is URLSession, HttpURLConnection or fetch in an app and a scripted fake in a test. The query client keeps one cache that all platforms observe, so the to-do list on the iPhone and the one in a Chrome tab agree about what is fresh.
Defining a query#
A query is an async fn whose first parameter is the Ctx, marked with #[undra::query]. This is the playground's remote to-do list, a GET that is cached per list:
/// The items of the server's list `list`: `GET {base}/lists/{list}/todos`.
#[undra::query(key = "remote-todos:{list}", stale = "30s", persist, retry = 1)]
pub async fn remote_todos(ctx: &Ctx, list: String) -> Result<Vec<RemoteTodo>, RemoteError> {
let url = endpoint(ctx, &format!("/lists/{list}/todos"))?;
parse(&send(ctx, HttpRequest::get(url)).await?)
}| Argument | Meaning |
|---|---|
key = "…" | the cache key; {param} placeholders name parameters, so two lists are two independent entries |
stale = "30s" | how long a successful result stays fresh |
persist | write the entry to the Kv port so it survives a restart |
retry = 3 | how many times to retry a failed fetch |
idempotent | (mutations) the request is safe to repeat, which makes it eligible for the offline queue |
The signature is checked when you compile: it must be async, take ctx: &Ctx first, and return Result<T, E>. A query whose success value is () or an Option is rejected, because a query caches a value and effects belong in a mutation (E0040 to E0042).
Observing a query#
Platforms observe a query through a generated query handle, which is an ordinary store. For remote_todos it is RemoteTodosQueryHandle, and it has five signals:
| Signal | Holds |
|---|---|
data | the latest successful result, or nothing yet |
status | idle, fetching, success or error |
error | the error of the latest failed fetch, cleared by the next success |
fetching | whether a request is in flight, also true while refetching stale data |
updatedAt | when data last changed |
Constructing the handle registers an observer and starts a fetch if the entry is missing or stale. Releasing it removes the observer; when the last observer goes, the in-flight fetch is cancelled and the entry is kept for five minutes before it is garbage collected. The handle also has refetch() (fetch now, even if fresh) and invalidate() (mark stale; it refetches while observed).
const inbox = await RemoteTodosQueryHandle.create("inbox"); // observe
const items = useSignal(inbox.data); // in a component
const busy = useSignal(inbox.fetching);Staleness, refetch and dedup#
An observed entry refetches when it is stale and something happens:
- an observer is added while it is stale,
- the app becomes
Activeagain (theLifecycleport), - connectivity returns (the
Connectivityport), - its optional interval elapses, or
- it is invalidated, by key prefix.
One fetch per key at a time. If five screens observe the same list, they share one in-flight request.
Polling and paging. interval = "30s" refetches an observed query on a schedule (Polling), and infinite turns a cursor query into a keyed list that grows by the page (Paging and lazy lists).
Retry with jitter#
A failed fetch is retried retry times (three by default) with exponential backoff, min(1000 × 2ⁿ, 30000) milliseconds, with plus or minus 20% jitter. The jitter comes from the Rng port and the delay from the Timer port, so a test with fakes sees exactly the schedule it scripts.
Mutations#
A mutation is an async fn marked #[undra::mutation]. It shares its cache key with the query it affects. Call it through the context:
ctx.mutate::<PostRemoteTodoMutation>((list, title))
.optimistic(move |cache| {
cache.update::<RemoteTodosQuery>((cached,), |items| items.push(placeholder));
})
.invalidates(/* keys to mark stale */)
.awaitoptimistic runs inside a transaction with typed access to cache entries (get, set, update), so the UI shows the new item at once. On success the invalidates keys are marked stale and refetched if observed, which replaces the placeholder with the server's item. The playground wraps this in create_remote_todo and set_remote_done, which are what its three UIs call.
Precise rollback#
If the mutation fails, the optimistic write is undone, but only the writes that mutation made. Every cache entry carries a write stamp that changes with each write: an optimistic write, a set, or a fetch result. When a mutation fails, each entry it wrote is restored to what it was before, unless something else has written that entry since. An entry that was written since keeps that write.
That rule is what makes concurrent mutations safe:
- a later optimistic mutation's placeholder survives an earlier mutation's rollback, even on the same entry;
- a fetch result that arrived in the meantime is newer than the failed closure and is kept;
- a later mutation that wrote directly on top of the failed one inherits its restore point, so if it fails too the entry goes back to before both.
Stamps are compared, never contents, so two writes of equal data are still distinguishable. This behaviour is covered by the optimistic-rollback contract scenario on all three platforms.
The offline queue#
A mutation marked idempotent that fails with a network error while the Connectivity port says the device is offline is not lost. It is appended to a persisted queue (the Kv key undra.query.queue2) with its parameters, the fingerprint of their types and an idempotency key, and replayed in order when connectivity returns. The request carries an Idempotency-Key header that stays the same across retries and replays, so a server can tell a repeat from a second item.
#[undra::mutation(key = "remote-todos:{list}", idempotent)]
pub async fn post_remote_todo(ctx: &Ctx, list: String, title: String) -> Result<RemoteTodo, RemoteError> {
// …
if let Some(key) = undra::query::idempotency_key() {
request = request.with_header("Idempotency-Key", key.to_string());
}
// …
}A mutation that is not idempotent fails immediately when offline. While a queued call waits, the optimistic placeholder stays visible, and the caller's await keeps waiting until the network returns and the request is replayed.
A queued call survives an app update: its input is migrated by parameter name, or by a #[undra::migrate] hook. One that cannot be migrated is never dropped; it moves to the dead letters, which ctx.query().dead_letters() lists and retry_dead_letter or discard_dead_letter settle. Shipping an update has the rules.
Persistence#
Entries marked persist are written to the Kv port after each successful fetch (debounced, 250 ms) and read back when the core starts. Each entry carries the fingerprint of its type, so an unrelated change to the core keeps the cache, and an entry whose type changed is migrated by name or, if it cannot be, dropped and refetched. At most 1,000 entries are kept (set_max_persisted_entries), least recently updated first out.
Storage can fail: a quota runs out, a device is locked. The Kv port reports it as a StorageError, and the client treats storage as best-effort: a failed write is logged once and retried, a failed read starts the entry empty, and an offline queue that cannot be read is never overwritten or replayed until it can be.
Testing without a network#
Because the network is a port, a query is tested with a scripted fake and no sockets. FakeHttp answers from rules and records every request, including a flaky endpoint:
let http = FakeHttp::new();
// two timeouts, then success for ever after
http.respond_sequence("https://x.test/a", [Err(HttpError::Timeout), Err(HttpError::Timeout)]);
http.respond("https://x.test/a", HttpResponse::new(200, b"ok".to_vec()));Pair it with FakeClock to advance time and ScriptedConnectivity to go offline. Ports & adapters lists them all.
See it run#
The playground's Remote tab on every platform is this page in motion: a cached query, optimistic adds that roll back when the server refuses, and an Offline switch that queues writes and replays them on reconnect. Its source is examples/playground/core/src/remote.rs.