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)
}
queuedis 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-Keyit was first tried with. Without.queue_offline()a write fails withofflineas before, because a payment should not go later by accident. - The server's answer decides. A 2xx dispatches
OutboxSentand the lists re-run. A 422, 403, 404 or another refusal moves the write aside with the server's errors per field, dispatchesOutboxFailed, 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'sRetry-After. - Signing out is refused with kind
pending_changeswhile 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(¬e.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 itsIdempotency-Key;queuedis an error kind.- Refused writes wait for the user; signing out with writes waiting is refused.
unless_changed_sinceturns a stale update into a conflict, resolved with Keep mine or Take theirs.