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:

core/src/remote.rs
/// 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?)
}
ArgumentMeaning
key = "…"the cache key; {param} placeholders name parameters, so two lists are two independent entries
stale = "30s"how long a successful result stays fresh
persistwrite the entry to the Kv port so it survives a restart
retry = 3how 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:

SignalHolds
datathe latest successful result, or nothing yet
statusidle, fetching, success or error
errorthe error of the latest failed fetch, cleared by the next success
fetchingwhether a request is in flight, also true while refetching stale data
updatedAtwhen 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).

tsx
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:

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:

rust
ctx.mutate::<PostRemoteTodoMutation>((list, title))
    .optimistic(move |cache| {
        cache.update::<RemoteTodosQuery>((cached,), |items| items.push(placeholder));
    })
    .invalidates(/* keys to mark stale */)
    .await

optimistic 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:

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.

rust
#[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:

rust
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.

Edit this page on GitHub