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#
- Reads come from disk.
notesis apersistquery: the list is on screen from the first frame of the next launch, and refreshed in the background. - Writes queue.
add_noteisidempotent: offline it goes into a persisted queue and is replayed in order when the network returns, after a restart too.create_noteshows the note at once and takes it back if the server refuses. - The outbox is data.
outbox()says how many writes wait and which ones an update could not carry over (dead letters), with the reason. - An update is two rules. Build 2 renamed a field and added another.
#[undra(default)]fills the new field in old data. A#[undra::migrate]hook handles the rename in cached lists and queued writes. Whatever a hook refuses is never lost: a queued write becomes a dead letter.
Show the Rust
/// 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
}/// 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
@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
}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)
}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#
- A note created offline shows at once; when the network returns the server gets it and the placeholder gives way to its note.
- The app is killed with a write queued: the new process finds it on disk and replays it with the same
Idempotency-Key. - A refused write takes its placeholder back; sign-out is refused while a write is queued.
- Both hooks turn build-1 data (
body) into build-2 data (text), and refuse what is not a note.
Limits#
- Only a failure while the
Connectivityport says offline queues a write. A500is returned to the caller like any error. - A migration across two builds needs two builds to prove end to end. The recipe tests the hooks directly; the playground's contract scenarios run build A's data under build B on all three platforms (Shipping an update).