Cookbook

Offline-first

Offline-first is three decisions the core makes once: reads come from disk, writes queue, and an update never loses what the last build left on the device.

The recipe#

Show the Rust
core/src/offline.rs
/// The notes of `list`: `GET /lists/{list}/notes`. Persisted, so the next launch shows them before
/// the network answers.
#[undra::query(key = "notes:{list}", stale = "30s", persist, retry = 1)]
pub async fn notes(ctx: &Ctx, list: String) -> Result<Vec<Note>, NetError> {
    let url = net::url(ctx, &format!("/lists/{list}/notes"))?;
    net::json(&net::ok(net::send(ctx, HttpRequest::get(url)).await?)?)
}

/// Adds a note to `list`: `POST /lists/{list}/notes`. Idempotent: the key stays the same across
/// retries and offline replays. `pinned` is build 2's; a queued build-1 call has none (`None`).
#[undra::mutation(key = "notes:{list}", idempotent)]
pub async fn add_note(
    ctx: &Ctx,
    list: String,
    text: String,
    pinned: Option<bool>,
) -> Result<Note, NetError> {
    let url = net::url(ctx, &format!("/lists/{list}/notes"))?;
    let body = serde_json::json!({ "text": text, "pinned": pinned.unwrap_or(false) });
    let mut request = net::post_json(url, &body);
    if let Some(key) = undra::query::idempotency_key() {
        request = request.with_header("Idempotency-Key", key.to_string());
    }
    net::json(&net::ok(net::send(ctx, request).await?)?)
}

/// Adds a note the way a UI wants it: it shows at once, the server is asked, and the placeholder is
/// taken back if the server refuses. Offline, the call keeps waiting and the placeholder stays
/// until the queue replays the write.
#[undra::api]
pub async fn create_note(
    ctx: &Ctx,
    list: String,
    text: String,
    pinned: bool,
) -> Result<Note, NetError> {
    let placeholder = Note {
        id: u32::MAX,
        text: text.clone(),
        pinned,
    };
    let cached = list.clone();
    ctx.mutate::<AddNoteMutation>((list, text, Some(pinned)))
        .optimistic(move |cache| {
            cache.update::<NotesQuery>((cached,), |notes| notes.push(placeholder));
        })
        .await
}
core/src/offline.rs
/// A note. Build 1 had `{ id, body }`; build 2 renamed the text and added `pinned`.
#[undra::api]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct Note {
    /// The server's identity of the note. A note that is only shown optimistically, and that the
    /// server has not answered for yet, has an identity counting down from `u32::MAX`.
    pub id: u32,
    /// What the note says.
    pub text: String,
    /// Whether it is pinned to the top. Added in build 2: build-1 data reads it as `false`.
    #[undra(default)]
    #[serde(default)]
    pub pinned: bool,
}

/// Build 1's notes (`{ id, body }`) become build 2's. Structural migration cannot do this one, a
/// rename is a removal and an addition to it, so the hook reads the old name.
#[undra::migrate(ty = "Note")]
fn note_from_build_1(old: &DynValue) -> Result<Note, MigrateError> {
    let id = old
        .field("id")
        .and_then(DynValue::as_i64)
        .and_then(|id| u32::try_from(id).ok())
        .ok_or_else(|| MigrateError::new("a note without an id"))?;
    let text = old
        .field("text")
        .or_else(|| old.field("body"))
        .and_then(DynValue::as_str)
        .ok_or_else(|| MigrateError::new("a note without text"))?;
    let pinned = old.field("pinned").and_then(DynValue::as_bool);
    Ok(Note {
        id,
        text: text.to_owned(),
        pinned: pinned.unwrap_or(false),
    })
}

/// A queued build-1 `add_note(list, body)` becomes build 2's `add_note(list, text, pinned)`:
/// `pinned` is an `Option` and fills itself with `None`; the renamed parameter is the hook's job.
#[undra::migrate(mutation = "add_note")]
fn add_note_from_build_1(old: &DynRecord) -> Result<DynRecord, MigrateError> {
    let mut new = old.clone();
    new.rename("body", "text");
    Ok(new)
}

On the platform#

A cached list, a write that returns when the server has it, and a count for the "waiting to sync" line.

Show the Swift, Kotlin and TypeScript
swift
@MainActor func notesScreen() async throws {
    let notes = try NotesQueryHandle(list: "inbox")       // cached on disk: shown before the network answers
    _ = notes.data

    // Shows at once; returns when the server has it, even if that is after the train leaves the tunnel.
    _ = try await createNote(list: "inbox", text: "Buy milk", pinned: false)

    let waiting = try outbox().pending                    // "2 changes waiting to sync"
    _ = waiting
}
kotlin
suspend fun notesScreen() {
    val notes = NotesQueryHandle.create("inbox")      // cached on disk: shown before the network answers
    notes.data

    // Shows at once; returns when the server has it, even if that is after the tunnel.
    createNote(list = "inbox", text = "Buy milk", pinned = false)

    val waiting = outbox().pending             // "2 changes waiting to sync"
    println(waiting)
}
tsx
export async function notesScreen() {
  const notes = await NotesQueryHandle.create("inbox");   // cached on disk: shown before the network answers
  void notes.data;

  // Shows at once; returns when the server has it, even if that is after the tunnel.
  await createNote("inbox", "Buy milk", false);

  const waiting = (await outbox()).pending;               // "2 changes waiting to sync"
  return waiting;
}

What the tests prove#

Limits#

Source: examples/cookbook/core/src/offline.rs.

Edit this page on GitHub