Chapter 13 of 15

Working offline

On the train the network comes and goes. Freddy keeps reading his team's notes and writes two new ones. When the network is back, both are sent, once each, and an edit that clashed with a colleague's waits for him to decide.

Turn it on

App::new()
    .database(sqlite_url(&path))                         // where the copy lives
    .backend(Backend::new("https://notes.example.com").offline())

That is the whole switch, and it changes no command. The design is RFC 0007.

Reads: the last answer

Each successful GET keeps its answer, per user, URL and query. A GET that gets no answer returns that copy instead of an offline error; one never read before is still offline. Response::stale_since() says when the copy was made, and .no_offline() keeps a request out of it, for a one-time code or a big report.

The copy lives in the app's database with the user and the URL hashed. It is kept per user, only while signed in, and cleared when the user signs out or someone else signs in. It is capped at 100 MiB, oldest first, and an answer over 2 MiB is not kept.

No answer means offline. While offline, reads answer from the copy at once, and Laravel's health route, /up, is probed every second, backing off to thirty. Any answer means online again, and every live query that read from the backend re-runs.

Writes: the outbox

A write marked .queue_offline() that gets no answer waits in the outbox instead of failing:

#[command]
async fn team_notes_store(ctx: Ctx, input: TeamNoteInput) -> Result<TeamNote> {
    let note: TeamNote = ctx.get::<Backend>()
        .post("/api/notes")
        .body(&input)
        .queue_offline()
        .resource()
        .await?;                     // no answer: Err(BackendError::Queued(id)), kind "queued"
    ctx.dispatch(TeamNoteCreated { note: note.clone() }).await?;
    Ok(note)
}
  • queued is an error kind, not a value. The command's types stay what they are, and the code after the write runs only when the write really happened. The page says "Saved, it will be sent when you're back online".
  • Sent in order, once. When the server is back, writes go one at a time, oldest first, each with the Idempotency-Key it was first tried with. Without .queue_offline() a write fails with offline as before, because a payment should not go later by accident.
  • The server's answer decides. A 2xx dispatches OutboxSent and the lists re-run. A 422, 403, 404 or another refusal moves the write aside with the server's errors per field, dispatches OutboxFailed, and the next writes go on. A 401 waits until the same user signs in again. No answer, a 5xx or a 429 is tried again with backoff, up to five minutes or the server's Retry-After.
  • Signing out is refused with kind pending_changes while writes wait or were refused, since they would be lost. auth.signOutDiscarding() signs out anyway, after asking.

The outbox survives a restart, and the token is never stored in it.

In the frontend

<script>
  import { connection, outbox } from "@elyra/runtime";
</script>

{#if $connection.state === "offline"}
  <p>Offline since {new Date($connection.since).toLocaleTimeString()}
     — {$connection.pending} change(s) waiting</p>
{/if}

{#each $outbox.filter((w) => w.status === "failed") as w}
  <p>{w.method} {w.path}: {w.error}</p>
  {#if w.reason === "conflict"}
    <button onclick={() => outbox.keepMine(w.id)}>Keep mine</button>
    <button onclick={() => outbox.takeTheirs(w.id)}>Take theirs</button>
  {:else}
    <button onclick={() => outbox.retry(w.id, fixedBody)}>Send again</button>
    <button onclick={() => outbox.discard(w.id)}>Discard</button>
  {/if}
{/each}

The page can list, retry, resolve and discard writes, and a retry may change the body only. It cannot add a write, change one's method or path, or see the token. A resource generated with --backend writes all of this for him once the app has .offline(): an offline banner, the writes waiting and those refused, and a test that saves with no network and checks the write is sent once.

Conflicts

By default the last write wins, as in Laravel. On the train Freddy edits a note his colleague is also editing in the office; sent later, his edit would overwrite hers. Where that is wrong, a write carries a precondition:

backend.put(format!("/api/notes/{id}"))
    .body(&input)
    .unless_changed_since(&note.updated_at)   // If-Unmodified-Since
    .queue_offline()

The server answers 412 when the record changed, and 409 means the same. From the outbox, the write is refused with reason: "conflict", and Freddy chooses: Keep mine sends it again without the precondition, Take theirs drops it and re-reads the note. On the Laravel side it is one line in the controller:

abort_if($request->hasHeader('If-Unmodified-Since')
    && $note->updated_at->gt(Carbon::parse($request->header('If-Unmodified-Since'))), 412,
    'Someone changed this note meanwhile.');

Once, on the Laravel side too

A write can reach the server just as the connection drops, so the app never hears the answer and sends it again. The guide's Idempotent middleware, after auth:sanctum, answers a repeated Idempotency-Key from its record for 24 hours per key and user, and remembers only writes that happened, so the note is not created twice. The full middleware is in the guide.

What you learned

  • .offline() keeps the last answer per user and serves it when there is no answer, without changing a command.
  • .queue_offline() puts a write in an outbox that sends it once, in order, with its Idempotency-Key; queued is an error kind.
  • Refused writes wait for the user; signing out with writes waiting is refused.
  • unless_changed_since turns a stale update into a conflict, resolved with Keep mine or Take theirs.
Next: in Chapter 14 Laravel's notifications reach Freddy's desktop.