Skip to content

Offline-first with fespalier ​

How an app that keeps working without a network is built on fespalier (since 0.10.0): what is read from the device, what is written locally, and what must wait for the server. The code is fespalier_cratestack; the wiring for a CrateStack client is in CrateStack with fespalier. The ideas here do not depend on it.

Two kinds of writes ​

Everything a user changes is one of two kinds, and the two are handled differently.

Rows a user ownsDecisions only the server makes
Examplesa note, a draft, a to-do, a settingcancel an order, accept an invitation, transfer a balance
Writtenon the device at once, offline or notas an intent: saved, sent, and decided by the server
Conflictsmerged field by field, nobody is askedthe server answers, the person resolves a conflict
The page showsthe new value immediately"will send when back online", never a success the server has not given
In this packageOwnedRows, RowSyncIntentQueue

Some calls stay online-only, on purpose: a one-time code, a stock reservation, anything that has to be answered now. Do not queue them. A call that should fail when there is no network is a feature.

Reads ​

dart
FutureOr<Served<List<Order>>> data(Ref ref) => ref.serve(
  key: 'orders',
  codec: _codec,
  empty: () => const [],
  fetch: () => ref.read(shopClient).models.order.list(),
);

A read has a policy:

PolicyLooks first atUse it for
networkFirst (default)the server, then the device on a connection failureanything others can change
cacheFirstthe device, then the server if there is nothingdata that does not change once saved
localOnlythe device only, the copy an earlier read saveddata that is only ever read here (rows this device owns are read from ownedRows, below)
serverOnlythe server only; offline is an error, never a guesswhat must be current

The rules that keep it honest:

  • Only a connection failure falls back. A refusal is the answer: a 403 is not "the network is down", and showing last week's copy of something you may no longer see would be a leak. The refusal is rethrown to error.dart. That holds on a route with a freshness too (since 0.13.1): CrateStackRefused is a DataRefusal, so the DataView's keepDataOnError keeps the page for an offline reload but shows error.dart instead of the old copy for any refusal (a 404, 429 or 400 included: every 4xx but 401 and 409). Before 0.13.1 the old Served copy stayed on screen: watch reconnectSignal in data.dart instead of declaring freshness there.
  • An empty list offline is an answer. Served says neverFetched, and the page can say "not loaded on this phone yet" instead of "you have no orders".
  • A single row with nothing saved is an error. CrateStackNoLocalData, shown with a retry. There is no empty row to invent.
  • Every answer says where it came from and as of when, per account. One account's copy never reaches another: the scope is part of every key.
  • A gateway or a captive portal counts as offline. An HTML page where a JSON answer was expected, or a 5xx with no CrateStack envelope, means nobody that knows your API answered, and the write may have landed. Since 0.16.0 that is every 5xx without the envelope, a bare 500 included; before it was 502, 503, 504 and 511. A 5xx in the envelope is not: the server did answer. Put a PortalTransport in the transport chain (since 0.21.0; CrateStackPortalInterceptor with Dio until 0.20.0): no transport throws on an HTML 200, so without it the page reaches the client as a decoding error that no reader knows. A refused TLS certificate is not offline (since 0.21.0): it may be a captive portal or an attack, so it is unclassified and a read serves no saved copy for it (CrateStack with fespalier).

The Freshness rules of data.dart (staleTime, refetchOnResume, refetchOnReconnect) still say when a read runs again; serve says where its answer comes from.

Intents ​

dart
final out = await ref.read(intentQueue).submit(
  const RpcCall('cancelOrder', {'id': 42, 'expectedVersion': 3}),
  subject: 'order:42',
  touches: const {'orders'},
  decode: (o) => Order.fromJson(o! as Map<String, Object?>),
);

switch (out) {
  Accepted(:final value) => showCancelled(value),
  Queued() => showSaved('It will be sent when you are back online'),
}

What an intent is:

  • A stored call. The call is encoded once and saved before it is sent, then sent from the saved text every time. Every attempt is byte-identical, which is what a server's idempotency check compares.
  • A key, <id>#<attempt>. The id is 128 random bits. The same key is used until the server answers with a stored failure (a 5xx in CrateStack's envelope; a 5xx without one keeps the key, since 0.16.0); then the next attempt gets the next number, because a stored failure may be replayed under the old one. A replay is only safe if the server keys its idempotency store by a verified principal: keyed by a hash of the Authorization header, a token refresh between attempts makes the replay a new request (see CrateStack with fespalier).
  • Never expiring. An intent stays on the device until the server decides or the person discards it. The server's idempotency window is the only bound on "never".
  • Wiped at sign-out. Sending one account's intent under another's session is worse than losing it. crateStackAccount.clear() removes the intents, rows and cached reads of the account.
  • Ordered per subject. An intent waits behind an undecided earlier one with the same subject (order:42), so "pay" never overtakes "edit address" for the same order. That holds for a submit too: behind an undecided intent it is saved and Queued, not sent. Intents for other subjects do not wait.
  • JSON-native. The call is stored as JSON: maps, lists, strings, numbers, booleans, null. Bytes would silently become a list, so send them as text.

The answer table is in CrateStack with fespalier. In short: no answer, in flight and 401 keep the key; a stored failure takes the next; a refusal is kept failed with its wire code (never the server's message, which may quote values); a conflict is kept for the person.

Never fake success. submit returns Accepted only when the server said yes. A queued intent is Queued, and the page says so. An optimistic overlay (a row that reads "cancelling...") comes from pendingIntents, not from pretending the order is already cancelled.

Owned rows ​

dart
final rows = ref.read(ownedRows);

// A note, edited offline: stamped, saved, and marked dirty.
await rows.edit('notes', 'n1', {'title': 'Groceries', 'body': 'Milk'});
await rows.remove('notes', 'n1'); // a tombstone, a field like any other

// A page reads them locally: synchronous with a synchronous store, so on the first frame.
FutureOr<List<OwnedRow>> data(Ref ref) {
  ref.watch(crateStackRevision('notes')); // an edit or a sync rebuilds the read
  // The rows are the signed-in account's: a read while signed out is empty, not an error.
  if (ref.watch(crateStackScope) == null) return const [];
  return ref.watch(ownedRows).list('notes');
}

How two devices that edit the same row end up the same:

  • A field is the unit. Each field carries the stamp of the write that set it. Two devices that edit different fields of the same row both keep their edit. For the same field, the greater stamp wins, on both devices, whatever order the rows arrive in (the merge is commutative, associative and idempotent).
  • The stamp is a hybrid logical clock. Wall milliseconds, a counter and a random node id for the device, totally ordered. When a device receives a row, its clock moves past every stamp in it. So "latest" means causally later, not "whose wall clock was ahead": a phone whose clock is wrong cannot make a later edit lose to an earlier one it has already seen.
  • A delete is a field. The tombstone (deletedAt) is stamped like any other field, so a delete and a concurrent edit of another field both survive.
  • A read never overwrites an unpushed edit. A row arriving from the server replaces the clean fields; a dirty field keeps the local edit unless the server's stamp is newer.
  • A refused row rolls back, out loud. If the server rejects a row, the device returns to the server's version (or drops a row the server never took), and the sync report lists it with the reason code only.

CrateStack has no sync protocol of its own, so RowSync is two procedures you add to your schema (say syncPush and syncPull). The server side merges with the same per-field rule and writes through the model layer, so policies still apply to what a device pushes.

Sync ​

dart
final report = await ref.read(syncRunner).sync(SyncReason.manual);
if (report.rolledBack.isNotEmpty) showUndone(report.rolledBack);

A sync does three things, in this order: push the dirty rows, pull each collection, then drain the intents. The order matters: an intent may name a row made offline, and the server must have the row first. The drain runs even when the push failed, and stops at the first sign of no network.

  • Single flight. A sync that starts while another runs joins it and gets the same result. A push from the server is the exception (since 0.25.0): it marks the engine dirty and one more sync runs after the running one, so a burst of pushes gives two syncs, never more (A push from the server).
  • A minimum interval. A sync started by a signal (resume, reconnect, the app's tick) within minInterval (10 seconds by default) of the last one that reached the server does nothing, unless the last one found no network or work has been queued since (an intent, an edited row). A manual sync, the first one at start, and a push always run.
  • It never throws. The report says what failed (failure), what was pushed, what was pulled, and what the drain did.

Triggers ​

autoSync, watched once in the root layout.dart, runs a sync on five occasions, and each one exists for a reason:

TriggerWhy it is needed
startwork queued in the last session should not wait for an event
resumethe app came back; the device may have been offline for hours
reconnectthe network came back while the app stayed open
the app's ticka device that never backgrounds and never loses signal would otherwise never push
a push (since 0.25.0)another device or the server changed a row, and the open screen should show it now

The first three come from fespalier: appResumeSignal, reconnectSignal (override it with ConnectivitySignal.new from fespalier_connectivity) and the start of autoSync. The tick is a signal the package never fires itself, because it starts no timer; the app owns the timer, its period and its test:

dart
class ForegroundTicker extends RefetchSignal {
  @override
  int build() {
    final t = Timer.periodic(const Duration(minutes: 5), (_) => fire());
    ref.onDispose(t.cancel);
    return 0;
  }
}
// startup(): syncTicker.overrideWith(ForegroundTicker.new)

It is autoDispose and watched only by autoSync, so the timer exists only while the app shows the root layout.

Two patterns around the triggers:

  • Save locally, then sync best-effort. An action edits the row, then calls engine.sync(SyncReason.manual) and ignores a failure: the triggers will try again. The person never waits for the network to save. That report does not reach autoSync's state, so to tell the person about a rolledBack row keep it yourself (the offline example does, in a lastSync notifier).
  • Push before a server decision that depends on a local row. An action that asks the server to act on a row made offline calls await ref.read(syncEngine).push() first. Unlike sync, push() throws: if the rows are not on the server, say so rather than send a decision that names a row the server has never seen.

A push from the server ​

Since 0.25.0 autoSync also follows syncPushSignal, a count that moves when the server says something changed (SyncTriggers(onPush: true), on by default). It is 0 and never moves until the app overrides it, usually with a change feed over the server's own stream (Streaming); with CrateStack, over a subscription (Subscriptions):

dart
final serverChanges = ChangeFeed<CrateStackModelEvent>.live(
  (ref, _) {
    // Who is signed in: signing in, out or as someone else replaces the connection.
    ref.watch(crateStackScope);
    return crateStackEvents(ref.watch(apiTransport), apiBase, 'model.Note.subscribe');
  },
  rules: LiveRules(reconnectOn: [reconnectSignal]),
  name: 'serverChanges',
);
// startup():
syncPushSignal.overrideWith((ref) => ref.watch(serverChanges.any)),

connect watches the account on purpose. The feed opens when the app starts, usually signed out, and the server answers that GET with a 401, which ends the feed (ServerEventsRefused) and nothing reopens it by itself; after a sign-out the old connection would stay under the old session. Watching crateStackScope (or authSession.select, as in Sign-in and sign-out) replaces the connection at each change of account, so a refused feed recovers at sign-in and one account never reads the other's.

Each move is a SyncReason.push, and the engine treats it differently from the other signals:

  • It skips the minimum interval. The server just said there is something to pull.
  • It is not joined to a sync that runs. That sync may have pulled past the change already, so the push marks the engine dirty and one more sync runs once it ends; every push until then joins that one. A burst of a hundred events during a sync gives two syncs, and no timer waits between them.
  • The event is a hint. The sync pulls the rows through your API, and the page shows those, never what the event carried: a server may send an event to someone who may not read the row (CrateStack 0.15.4 checks no read policy on a subscription).
  • A gap is a push too. A change feed whose connection opened again without resuming moves any by one, so the app syncs after every reopen that may have missed an event.

A feed the server refused moves nothing (its latest holds the error, which a banner can watch; it opens again when the account changes), and a signal that throws is ignored: the other triggers keep working. A SyncRunner of your own gets SyncReason.push too, and decides for itself what a push during a run means. A push that was queued behind a sync of an engine that is then replaced (its account changed, or a provider it reads did) is asked again of the new engine, once, so it is not lost.

What a push costs, with CrateStack 0.15.4:

  • It reaches every device of every user. A subscription is per model, with no per-account filter: when anyone writes a Note, every connected device syncs, whoever's note it was. Fine for a small app; for many users, bound it (below) or filter in front of the router.
  • A device hears its own writes. The one that pushed a row gets the event about it too, so a local edit is two syncs: the push, then the echo's. The offline example shows both.
  • any has no budget. A topic of the feed with a ChangeBudget moves at most once between two ticks of a signal your own timer fires, and still moves at once for the first event after a quiet period. The events held back are not lost: the next tick moves the topic once for all of them.
dart
final syncPushTopic = serverChanges.topic<Object?>(
  (event, _) => true,
  budget: ChangeBudget(syncPushTick), // a RefetchSignal fired from your timer, every 5 s say
);
// startup():
syncPushSignal.overrideWith((ref) => ref.watch(syncPushTopic(null))),
  • A silently dead connection is not found. CrateStack sends no heartbeat, so LiveRules.idle stays unset, and a reconnectOn signal or the streamTicker act only on a connection that is waiting. Add ref.watch(appShowSignal) to connect to open a new connection each time the app is shown again (a gap, so one sync); the other triggers keep syncing meanwhile (Subscriptions).

Freshness in the UI ​

Served and pendingIntents are what a banner is made of.

dart
final waiting = ref.watch(pendingIntents(null)).value ?? const [];
final status = ref.watch(autoSync); // isSyncing, and the last report

if (orders.source == ServedFrom.local)
  Text(orders.neverFetched ? 'Offline: not loaded on this phone yet' : 'Offline copy, as of ${orders.fetchedAt}'),
if (waiting.isNotEmpty) Text('${waiting.length} changes waiting to send'),

Show the time rather than the word "stale": a person can judge "as of 10:42". stale follows the maxAge you pass to serve, for the screens where an old answer needs a warning.

Testing offline ​

  • The fakes. FakeCrateStackTransport plays the server, including the idempotency layer. FakeRowServer is a row server that merges and can refuse by id. crateStackTestOverrides wires them with a synchronous store and a signed-in scope.
  • The clock. Everything reads package:clock: use withClock or a testWidgets fake clock, then pump a day or a year and watch an intent still send.
  • The triggers. ManualSyncTicker.tick() for the tick; FakeConnectivity for the network; the lifecycle through tester.binding.handleAppLifecycleStateChanged; for a push, fespalier_stream's FakeEventServer as the subscription's transport and server.current.modelEvent('Note', 'Updated') (package:fespalier_cratestack/testing.dart, since 0.25.0).
  • No timers. Nothing in the package starts one, so a testWidgets that ends with a timer pending points at your own code.

Limits ​

  • No signed intents. A queued call cannot carry a proof tied to the moment it was made (a re-confirmation, a device-bound proof). Keep those calls online-only.
  • No paging helpers yet beyond the cursor loop of RowSync.pull.
  • One isolate. Sync runs while the app is open. A background sync is the app's to build, and it needs a store that a second isolate can open safely.
  • The web. Use InMemoryLocalStore (nothing outlives the tab) or HiveLocalStore on IndexedDB. Both are synchronous after opening, so localOnly reads stay on the first frame.
  • Where the embedded CrateStack fits is in CrateStack with fespalier.

Released under the MIT License.