← Back to blog
Offline-First Flutter · Part 12 of 12
October 8, 202629 min read

100 Questions to Master Offline-First Flutter

FlutterDartOffline-First

100 Questions to Master Offline-First Flutter

This is Part 12 — the finale of the Offline-First Flutter series. The previous eleven parts built FieldNotes layer by layer; this is where you prove you can architect and reason about offline-first sync.

How to use this bank:

  • 100 questions, grouped by topic, tagged [Basic], [Medium], [Advanced] and marked (Theory) or (Coding).
  • Each has a Hint and a separate Solution. Try cold first, peek at the hint if stuck, only then check the solution.
  • For (Coding) questions, sketch the Drift/Riverpod/sync code yourself.
  • After the 100, there are 10 coding mini-exercises with full solutions, ending in a capstone that assembles the whole sync engine.

If you can explain the why on all 100 without hints, you can design offline-first sync at a senior level. Let's go.


Section A — Foundations (Q1–9)

Q1. [Basic] (Theory) Define offline-first in one sentence.

Hint

Local first, sync separately. See Part 1.

Solution

Every read and write hits local storage first (instant, network-independent), and syncing with the server happens separately in the background — the app never waits on or blocks for the network.

Q2. [Basic] (Theory) What is the single source of truth in this architecture?

Hint

Not the server.

Solution

The local database. The UI reads only from it; the sync engine keeps it eventually consistent with the server.

Q3. [Basic] (Theory) How does offline-first differ from "online-first with a cache"?

Hint

Writes.

Solution

A cache is a read fallback on a network-primary design, so writes still fail offline. Offline-first makes local storage primary for reads and writes; the network is never on the critical path and sync is first-class.

Q4. [Medium] (Theory) Name the two independent data flows in the architecture.

Hint

Fast vs slow.

Solution

(1) User flow: UI → Repository → Local DB (synchronous, instant, local). (2) Sync flow: Sync Engine ↔ Server (asynchronous, background). The user waits only on the first.

Q5. [Medium] (Theory) Why must records use client-generated UUIDs?

Hint

Created before the server.

Solution

Records are created offline, before the server sees them, so there's no round-trip to allocate a server id. The client mints a UUID for a stable, collision-free identity from creation.

Q6. [Medium] (Theory) Why is delete modeled as a soft delete?

Hint

Deletion must sync.

Solution

A deletion is information that must reach the server/other devices. Physically removing the row loses it, so the note resurrects on next pull. A tombstone keeps the deletion syncable.

Q7. [Advanced] (Theory) Why does offline-first feel faster even on a good network?

Hint

Critical path.

Solution

Reads/writes hit local storage (sub-millisecond) instead of network round-trips (100–2000ms) on the critical path. The UI updates instantly from local data; sync happens invisibly, so there are no per-action spinners.

Q8. [Medium] (Theory) When might you choose a managed framework (PowerSync/Brick) over hand-building?

Hint

Ship vs learn.

Solution

When you want to ship standard CRUD-over-Postgres fast with battle-tested sync/conflict handling and less code, and don't need full custom control. Hand-building is for learning, custom needs, or avoiding lock-in.

Q9. [Advanced] (Theory) Why are UUIDs and soft deletes both consequences of the offline-first principle?

Hint

Before the server.

Solution

Both creation and deletion happen offline, before the server is involved. A record needs identity at creation (→ client UUID), and a deletion is data that must sync (→ tombstone). Both fall directly out of "local first, sync later."


Section B — Data modeling for sync (Q10–18)

Q10. [Basic] (Theory) What two categories of columns does an offline-first table have?

Hint

User vs engine.

Solution

Domain data (title, body) and sync metadata (id/UUID, updatedAt, version, isDirty, isDeleted).

Q11. [Basic] (Theory) What does isDirty mean and what does it drive?

Hint

Unsynced.

Solution

It marks rows with unsynced local changes and drives the push: the engine pushes dirty rows and clears the flag on success.

Q12. [Medium] (Coding) Write the Drift "delete" statement the offline-first way for note x.

Hint

Update, not delete.

Solution
(update(notes)..where((n) => n.id.equals('x'))).write(
  const NotesCompanion(isDeleted: Value(true), isDirty: Value(true)));

Q13. [Medium] (Theory) Why store updatedAt in UTC?

Hint

Common reference.

Solution

Devices/servers span time zones; UTC is a single common reference so timestamp comparisons in conflict resolution are meaningful.

Q14. [Medium] (Theory) What does the version column add over updatedAt?

Hint

Clock-free.

Solution

A monotonically incrementing counter gives clock-independent conflict detection ("you edited v3 but server has v5"), unlike timestamps which suffer clock drift.

Q15. [Medium] (Coding) Why filter isDeleted.equals(false) in the UI query?

Hint

Tombstones still exist.

Solution

Tombstoned rows remain in the table so the deletion can sync, but the user shouldn't see them — so the UI query excludes them.

Q16. [Advanced] (Theory) Why does Drift's reactive .watch() matter for offline-first specifically?

Hint

Background writes.

Solution

Background sync writes into the local DB, and reactive streams auto re-emit on any table change — so the UI updates when sync brings data without UI code. SSoT + reactive queries = free background UI updates.

Q17. [Medium] (Theory) What's the role of the NotesCompanion in updates?

Hint

Partial.

Solution

It lets you set only the columns you mean to change (others stay Value.absent()), so partial updates don't clobber other fields/metadata.

Q18. [Advanced] (Theory) Why include both updatedAt and version rather than one?

Hint

Different roles.

Solution

updatedAt is convenient for LWW ordering but clock-sensitive; version gives robust, clock-free conflict detection. Keeping both lets you start with timestamps and harden with versions.


Section C — Migrations & versioning (Q19–27)

Q19. [Basic] (Theory) What two things change for a Drift migration?

Hint

Version + strategy.

Solution

Increment schemaVersion and add the step(s) in onUpgrade (plus update the table). onCreate handles fresh installs.

Q20. [Basic] (Theory) Why are migrations higher-stakes in offline-first?

Hint

Only copy.

Solution

The local DB is the SSoT and may hold un-synced data that exists nowhere else, so a destructive migration causes permanent data loss — unlike a re-fetchable online cache.

Q21. [Medium] (Coding) Add a non-null color column (default '#FFF') in v3. Give the onUpgrade branch.

Hint

addColumn.

Solution
if (from < 3) { await m.addColumn(notes, notes.color); }

(with color declared .withDefault(const Constant('#FFF'))).

Q22. [Medium] (Theory) Why if (from < N) not if (from == N-1) in onUpgrade?

Hint

Long jump.

Solution

A user may upgrade across multiple versions at once; < N runs each step for everyone below that version, so all needed steps execute in order on a long jump.

Q23. [Medium] (Theory) Why must a new column have a default or be nullable?

Hint

Existing rows.

Solution

Existing rows have no value for it; SQLite can't add a non-null, no-default column to a populated table. A default/nullable gives old rows a valid value.

Q24. [Advanced] (Theory) How can a migration trigger an accidental full re-sync?

Hint

isDirty.

Solution

By setting isDirty = true (or bumping version) on rows, making the engine push the whole database next launch. Don't mark dirty for representation-only migrations.

Q25. [Medium] (Coding) A dev migrates via m.drop(notes); m.createTable(notes);. Why is it catastrophic?

Hint

Data loss.

Solution

It deletes all notes, including un-synced ones that exist only locally — irreversible loss. Migrate in place (addColumn/backfill), never drop user data.

Q26. [Advanced] (Theory) Why prefer "add + backfill + keep old" over dropping a column, and when can you drop?

Hint

Transition.

Solution

Dropping is destructive and might erase the only copy of un-synced data. Add/backfill preserves data during transition; drop only in a later release once all clients migrated and nothing un-synced depends on it.

Q27. [Advanced] (Theory) Which migration must you always test, and with what tool?

Hint

Long jump + verifier.

Solution

The oldest supported → latest upgrade, using Drift's schema verifier (schema dump/generate), asserting existing rows survive with sensible defaults.


Section D — Repository & reactive UI (Q28–36)

Q28. [Basic] (Theory) What is the repository's role?

Hint

Doorway.

Solution

The single doorway between UI and data — the only thing the UI talks to; it exposes domain ops and hides Drift/HTTP/sync.

Q29. [Basic] (Coding) What Riverpod provider type exposes the reactive notes list?

Hint

Stream.

Solution

A StreamProvider<List<Note>> wrapping the repository's watchNotes() stream (yielding AsyncValue).

Q30. [Medium] (Theory) Why does createNote return instantly even offline?

Hint

Local write only.

Solution

It only writes a local row and sets isDirty — no network call. It's durably saved on local commit; pushing happens later in the sync engine.

Q31. [Medium] (Coding) Trace what puts a newly created note on screen.

Hint

Reactive chain.

Solution

Repo write → local INSERT → Drift .watch() re-emits → notesProvider emits AsyncDataConsumerWidget rebuilds with the note.

Q32. [Medium] (Theory) Why can the sync engine be added later without changing widgets?

Hint

Behind the boundary.

Solution

The UI depends only on the repository interface and watches the local DB, never the network. Sync sits behind that boundary, writing to the same table whose reactive streams already update the UI.

Q33. [Medium] (Theory) Why should the repository return domain types, not HTTP DTOs?

Hint

Decouple.

Solution

So the UI is independent of storage/transport shape; schema/API/sync changes don't ripple into widgets, and the repository can reconcile local+remote behind a stable contract.

Q34. [Basic] (Coding) A widget does ref.watch(databaseProvider).select(...). What's wrong?

Hint

Leak.

Solution

It bypasses the repository, coupling UI to Drift. Move the query into the repository and watch a provider exposing it.

Q35. [Advanced] (Theory) Why does writing server-pulled notes into the local table update the UI for free?

Hint

Same table.

Solution

The UI watches the local table; any change (including sync writes) makes watchNotes() re-emit through notesProvider to the widget. The reactive read path is reused by sync automatically.

Q36. [Advanced] (Theory) Where does the sync engine attach, and why is that good design?

Hint

The seam.

Solution

Behind the repository (triggered by connectivity). Because the UI only knows the repository + reactive providers, sync is purely additive — no widget changes. Clean boundaries make big features addable without rewrites.


Section E — Connectivity (Q37–45)

Q37. [Basic] (Theory) What does connectivity_plus actually tell you?

Hint

Interface.

Solution

That a network interface exists (wifi/cellular/none) and when it changes — not whether the internet/your server is reachable.

Q38. [Basic] (Theory) What is a reachability check?

Hint

Place the call.

Solution

A tiny request (e.g. HTTP HEAD /health) with a short timeout that confirms your server actually answered. Failure/timeout → treat as offline.

Q39. [Medium] (Theory) How do you combine the two layers into one signal?

Hint

Gate then confirm.

Solution

Use connectivity_plus as a cheap gate (no interface → offline), then confirm with a reachability probe. Online = interface present and server reachable.

Q40. [Medium] (Theory) Why probe your own backend, not a generic site?

Hint

What sync talks to.

Solution

The sync engine talks to your backend, so that's what must be reachable. A generic site being up doesn't prove your API is reachable/unblocked.

Q41. [Medium] (Coding) Why trigger sync on wasOffline && isOnline rather than every "online" emission?

Hint

Edge.

Solution

To sync on the reconnect edge, not repeatedly while online — avoiding sync storms (especially with flapping connections).

Q42. [Medium] (Theory) Two defenses against flapping connections?

Hint

Debounce + resilient.

Solution

Debounce the online signal so brief drops/returns don't propagate, and make the sync engine resilient (idempotent, retry-capable) so a sync during a blip fails gracefully.

Q43. [Advanced] (Theory) Why must sync correctness not depend on the connectivity signal?

Hint

Best-effort hint.

Solution

The signal is best-effort and can be wrong (says online a moment before a request fails). Correctness must live in an idempotent, failure-handling sync engine; the signal is just "probably worth trying now."

Q44. [Basic] (Theory) Why is an offline banner reassurance, not an error?

Hint

Writes still work.

Solution

Writes already succeed locally; being offline doesn't block the user or lose data. The banner just notes that syncing is paused.

Q45. [Advanced] (Coding) A user on captive-portal wifi sees "online" and syncs time out. Diagnose + fix.

Hint

Add reachability.

Solution

connectivity_plus confirms only an interface; the portal blocks your API. Add a reachability probe to /health and define online as interface AND server reachable, so the portal reads as offline-for-sync.


Section F — The outbox / push (Q46–55)

Q46. [Basic] (Theory) What is an outbox?

Hint

Durable pending.

Solution

A durable record of pending changes needing to reach the server — written instantly on edit, drained when online, items removed only on confirmed success.

Q47. [Basic] (Theory) When may a dirty row be marked clean?

Hint

After ack.

Solution

Only after the server acknowledges the push (and only if still at the pushed version). Sooner risks marking unsent changes clean.

Q48. [Medium] (Theory) How do client UUIDs give exactly-once semantics?

Hint

Idempotency key.

Solution

They're idempotency keys: with an upsert endpoint keyed by the UUID, a retried push updates the same record instead of duplicating. At-least-once + idempotent = effectively exactly-once.

Q49. [Medium] (Coding) Describe the dirty-during-push race and the fix.

Hint

Compare-and-clear.

Solution

If the user edits a row (v5→v6) during the push of v5, clearing isDirty after the v5 ack loses v6. Compare-and-clear: only clear if the row is still at the pushed version; a mid-push edit changes the version so the conditional update matches nothing and it stays dirty.

Q50. [Medium] (Theory) When is it safe to purge a tombstone?

Hint

After ack.

Solution

Only after the server acknowledges the delete. Purging earlier loses the deletion locally and the note resurrects on next pull.

Q51. [Medium] (Theory) Transient vs permanent failure — handle each how?

Hint

Retry vs surface.

Solution

Transient (timeout/503): leave dirty, retry with backoff. Permanent (400/401): don't loop — surface it (fix data/re-auth). Treating all the same loses data or spins forever.

Q52. [Basic] (Theory) In the dirty-flag design, what selects rows to push?

Hint

The flag.

Solution

All rows where isDirty = true; push each, clear on success, leave dirty on failure.

Q53. [Medium] (Coding) Write a backoff duration for attempt n (cap 5 min).

Hint

Exponential.

Solution
Duration backoff(int n) => Duration(seconds: (1 << n).clamp(1, 300));

Q54. [Advanced] (Theory) Dirty-flag vs operation-log outbox — when each?

Hint

State vs sequence.

Solution

Dirty-flag stores current state of changed rows — simple, for independent records (FieldNotes). Operation-log stores ordered ops (create/update/delete + payload) — preserves intent/order for collaborative/event-sourced data, at more complexity.

Q55. [Advanced] (Theory) Why design the endpoint as PUT /notes/{id} rather than POST /notes?

Hint

Idempotent.

Solution

PUT keyed by the client UUID is an idempotent upsert — safe to retry (no duplicates). A blind POST creates a new row each call, so a retried-after-timeout push duplicates the record.


Section G — Delta pull & merge (Q56–65)

Q56. [Basic] (Theory) What is delta pull?

Hint

Since cursor.

Solution

Fetching only records changed since the last sync (via a cursor), not the whole dataset each time.

Q57. [Basic] (Theory) What is the sync cursor?

Hint

Bookmark.

Solution

A value marking "how far I'm caught up" (a server change-sequence or serverUpdatedAt), stored locally and advanced after applying a delta.

Q58. [Medium] (Theory) Why must the pull response include deletions?

Hint

Otherwise lingers.

Solution

Otherwise the client never learns a record was deleted elsewhere, so it lingers forever. The server returns deletedIds (its tombstones) for the client to remove.

Q59. [Medium] (Coding) During merge, the local row is dirty and a remote change arrives. What do you do?

Hint

Don't clobber.

Solution

Treat it as a conflict (Part 8) — never overwrite. Overwriting a dirty row destroys un-synced local edits. Overwrite only when the local row is clean.

Q60. [Medium] (Theory) Why advance the cursor in the same transaction that applies a page?

Hint

Crash-safe.

Solution

So apply-and-bookmark are atomic and resumable: a crash mid-pull re-fetches and re-applies that page harmlessly rather than skipping or partially applying it.

Q61. [Medium] (Theory) Why "push then pull"?

Hint

Up-to-date world.

Solution

Pushing first puts local changes on the server before pulling, so the delta reflects an up-to-date world and conflicts are more likely resolved at push than discovered on pull — faster convergence.

Q62. [Medium] (Theory) How is pull made idempotent?

Hint

Upsert/delete by id.

Solution

Changes apply via upsert-by-id (re-applying yields the same row); deletions via delete-by-id (a no-op if absent); the cursor advances transactionally so re-runs are harmless.

Q63. [Basic] (Coding) What does a null cursor on first run cause?

Hint

Everything.

Solution

The server returns everything (paginated) — the initial full sync — and subsequent pulls become deltas.

Q64. [Advanced] (Theory) Why prefer a server change-sequence over device timestamps for the cursor?

Hint

One clock.

Solution

A server sequence comes from a single authoritative counter, avoiding cross-device clock drift, timezone issues, and missed/duplicated items at timestamp boundaries — giving exact "everything after here" semantics.

Q65. [Advanced] (Theory) How do remote changes reach the screen with no UI code?

Hint

SSoT + reactive.

Solution

Pull upserts into the local table (the SSoT); Drift's reactive .watch() re-emits and notesProvider rebuilds the UI. The reactive read path from Part 4 carries sync writes to the screen automatically.


Section H — Conflict resolution (Q66–76)

Q66. [Basic] (Theory) When does a conflict occur?

Hint

Two places.

Solution

When the same record is changed in two places since their last common sync, producing two divergent versions to reconcile.

Q67. [Basic] (Theory) What's the trade-off of last-write-wins?

Hint

Simple but lossy.

Solution

Trivial and deterministic, but silently discards the loser's edits and is clock-drift sensitive. Fine for latest-value fields, bad for rich content.

Q68. [Medium] (Theory) Why is a version counter a better conflict detector than timestamps?

Hint

Clock-free.

Solution

It's clock-independent: the server compares the client's base version against its current version; mismatch = conflict. No device clocks involved (optimistic concurrency, like ETag/If-Match).

Q69. [Medium] (Theory) What does server-authoritative versioning guarantee?

Hint

No silent clobber.

Solution

The server accepts a change only if it was based on the current version; otherwise it rejects and returns its state for the client to rebase. So updates can never silently clobber each other on the server.

Q70. [Medium] (Coding) Field-merge: base {T,B}, local {T,B2}, remote {T2,B}. Result?

Hint

Take each changed side.

Solution

{title: T2, body: B2} — only remote changed the title, only local changed the body, so keep both (no loss).

Q71. [Medium] (Theory) Why does field-level merge need the base version?

Hint

3-way.

Solution

To know which side actually changed each field (compare each side to the base — a 3-way merge). Without the base you can't distinguish a change from a non-change.

Q72. [Advanced] (Theory) When do you need version vectors instead of a counter?

Hint

Many replicas.

Solution

When many independent replicas edit offline and you must distinguish concurrent edits from causally ordered ones — a single counter can't; version vectors (per-replica counters) can.

Q73. [Advanced] (Theory) What are CRDTs and when are they worth it?

Hint

Auto-merge.

Solution

Data types whose concurrent edits always merge deterministically by mathematical properties — ideal for real-time collaborative structures (shared text/lists). Heavyweight; use a library/framework, not for simple records.

Q74. [Medium] (Theory) Default policy for delete-vs-edit, and why?

Hint

Don't lose content.

Solution

Edit wins (resurrect) for user content — losing freshly written content to a remote delete is the worse surprise. Unless the domain treats deletes as final.

Q75. [Medium] (Theory) What's the "keep both / conflicted copy" strategy for?

Hint

Never lose.

Solution

High-value content where silent resolution is unacceptable: keep both versions (e.g. a "conflicted copy") and let the user decide — never lose data.

Q76. [Advanced] (Theory) Give a sound practical strategy for FieldNotes.

Hint

Detect + merge + resurrect + copy.

Solution

Detect with a server version counter; resolve with field-level merge off the base (per-field LWW fallback); delete-vs-edit = edit wins; keep a conflicted copy for irreconcilable high-value cases; then mark dirty and re-push. Pick the simplest strategy that doesn't lose data users care about — explicitly.


Section I — Optimistic UI & sync status (Q77–85)

Q77. [Basic] (Theory) What is optimistic UI and why is it free here?

Hint

Reflects local DB.

Solution

Updating the UI immediately, assuming success. It's free because the UI reflects the instantly-written local DB (SSoT) — the screen updates before/independent of the server.

Q78. [Basic] (Theory) The three per-note sync states and their signals?

Hint

dirty/error/clean.

Solution

Pending (isDirty), failed (syncError != null), synced (clean). Derived from sync metadata.

Q79. [Medium] (Theory) Why derive sync status instead of storing it?

Hint

Consistency.

Solution

Deriving keeps status always consistent with the real metadata; a separate stored field could drift out of sync.

Q80. [Medium] (Coding) Why does the per-note badge auto-update?

Hint

Reactive row.

Solution

The note comes from a reactive stream; when the engine flips isDirty/syncError, the row changes, the stream re-emits, and the badge rebuilds with the new derived status.

Q81. [Medium] (Theory) Transient vs permanent failure in the UI?

Hint

Quiet vs loud.

Solution

Transient: stay pending, retry silently, no alarm. Permanent: set syncError, show failed with an actionable retry/fix.

Q82. [Medium] (Theory) Why should "offline with pending" messaging be calm?

Hint

Normal & safe.

Solution

Pending changes are normal and safe in offline-first (saved locally, sync later); presenting them as errors undermines trust in a working system.

Q83. [Advanced] (Theory) How does offline-first change rollback vs classic optimistic UI?

Hint

Flag, don't delete.

Solution

The local DB is authoritative for existence, so a rejected push doesn't undo the local data — it's marked failed until resolved. Rollback applies only to ephemeral server-authoritative actions; for user content you flag + let the user fix, never silently delete.

Q84. [Basic] (Coding) What's the derived status when isDirty=false and syncError=null?

Hint

Clean.

Solution

Synced — clean and matching the server.

Q85. [Advanced] (Theory) Why is a global "pending count" useful and how is it computed?

Hint

Count dirty.

Solution

It gives a single "up to date?" signal. Compute it reactively as a count of isDirty = true rows (a watched aggregate query), shown as "N change(s) to sync"/"all changes saved".


Section J — Background sync (Q86–93)

Q86. [Basic] (Theory) What is background sync and why best-effort?

Hint

OS-scheduled.

Solution

Running the engine while the app is closed, via workmanager (WorkManager/BGTaskScheduler). Best-effort because the OS decides if/when it runs, to protect battery.

Q87. [Basic] (Theory) Why re-initialize the DB/engine inside the background task?

Hint

Fresh isolate.

Solution

Background tasks run in a separate isolate with its own memory — the app's providers/singletons/open DB don't exist there, so you build them fresh (and close the DB after).

Q88. [Medium] (Theory) What does @pragma('vm:entry-point') do?

Hint

Survive tree-shaking.

Solution

Marks the dispatcher as a VM entry point so it isn't tree-shaken and can be invoked headlessly by the OS to start the background isolate.

Q89. [Medium] (Theory) Why a network constraint and an in-task reachability check?

Hint

Connected ≠ reachable.

Solution

The OS constraint avoids waking with no network; the in-task reachability probe handles "connected but not reachable" (captive portal/downtime) before attempting sync.

Q90. [Medium] (Theory) Why is foreground sync the guarantee and background the bonus?

Hint

Reliable vs OS-controlled.

Solution

Foreground triggers (start/resume/reconnect/manual) fire reliably when the user engages; background scheduling is OS-controlled and may not run. Guarantee freshness via foreground; use background to opportunistically improve it.

Q91. [Basic] (Theory) Android's minimum periodic interval?

Hint

~15.

Solution

About 15 minutes (a minimum/hint, not a guarantee; iOS is even less predictable).

Q92. [Advanced] (Theory) Why is idempotency essential for background sync specifically?

Hint

Overlap + retry.

Solution

Background and foreground syncs can overlap, and the OS may retry a "failed" task that half-succeeded. Idempotent ops (UUID upsert push, upsert/delete-by-id pull with transactional cursor) make repeated/concurrent syncNow() safe by construction.

Q93. [Advanced] (Theory) On iOS, how do you keep a rarely-reopening user's data fresh?

Hint

Sync on resume.

Solution

Make foreground sync on app launch/resume the reliable floor (always syncNow() on open), keep the outbox durable so nothing is lost, and treat background runs as a bonus. They're fully synced the moment they reopen.


Section K — Testing (Q94–100)

Q94. [Basic] (Theory) Why is the sync engine the most critical thing to test?

Hint

Rare + data loss.

Solution

Its bugs are rare, timing-dependent, and destroy irreplaceable data, and rarely reproduce manually. Simulating failures/conflicts/races in tests is the only reliable defense.

Q95. [Basic] (Theory) What two fakes underpin sync tests?

Hint

DB + API.

Solution

An in-memory Drift DB (NativeDatabase.memory() — real SQLite) and a stateful fake API you control to script conflicts/failures/latency/other devices.

Q96. [Medium] (Theory) Why a stateful fake API over a canned mock?

Hint

Behavior over calls.

Solution

Sync depends on server behavior across calls (version checks, remembered state, deltas/deletions). A stateful fake models that, enabling real multi-step scenarios; a canned mock can't.

Q97. [Medium] (Coding) How do you deterministically reproduce the dirty-during-push race in a test?

Hint

Inject latency.

Solution

Add latency to the fake's pushNote, start the push, edit the row before it resolves (bumping its version), await, then assert the row is still dirty (compare-and-clear preserved the edit).

Q98. [Medium] (Theory) Which test most directly guards against silent data loss?

Hint

Dirty not clobbered.

Solution

"Pull does not clobber a dirty local row": when a row is dirty and the server also changed it, pull must preserve the local edit (conflict-resolve, not overwrite).

Q99. [Advanced] (Theory) What does a two-device convergence test prove?

Hint

Eventual consistency.

Solution

That two independent devices sharing a server converge to identical state after syncing — the architecture's eventual-consistency promise, validating that push/pull/conflict/idempotency compose correctly end to end.

Q100. [Advanced] (Theory) Why test the oldest→newest migration in offline-first?

Hint

Irreplaceable data.

Solution

Migrations transform the local SSoT, which may hold un-synced data existing nowhere else; a broken migration causes irreversible loss. Testing the long jump (Drift schema verifier) proves existing rows survive with correct defaults across every upgrade path.


Coding Mini-Exercises

Ten larger problems. Try each before opening the solution. Exercise 10 is a capstone.

Exercise 1 — Sync-ready table. Write the Drift Notes table with all sync metadata.

Show solution
class Notes extends Table {
  TextColumn get id => text()();
  TextColumn get title => text().withDefault(const Constant(''))();
  TextColumn get body => text().withDefault(const Constant(''))();
  DateTimeColumn get updatedAt => dateTime()();
  IntColumn get version => integer().withDefault(const Constant(0))();
  BoolColumn get isDirty => boolean().withDefault(const Constant(true))();
  BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
  TextColumn get syncError => text().nullable()();
  @override
  Set<Column> get primaryKey => {id};
}

(Part 2/Part 9)

Exercise 2 — Local-first create. Write createNote that's instant and sync-ready.

Show solution
Future<Note> createNote({String title = '', String body = ''}) =>
  db.into(db.notes).insertReturning(NotesCompanion.insert(
    id: const Uuid().v4(),
    title: title, body: body,
    updatedAt: DateTime.now().toUtc(),
  )); // isDirty defaults true; no network

Exercise 3 — Soft delete + purge. Write the soft delete and the post-ack purge.

Show solution
// soft delete:
(update(db.notes)..where((n) => n.id.equals(id)))
  .write(const NotesCompanion(isDeleted: Value(true), isDirty: Value(true)));
// after server ack of the delete:
(delete(db.notes)..where((n) => n.id.equals(id))).go();

(Part 2/Part 6)

Exercise 4 — Compare-and-clear. Implement marking a row clean only if unchanged since push.

Show solution
(update(db.notes)..where((n) =>
    n.id.equals(pushed.id) & n.version.equals(pushed.version)))
  .write(NotesCompanion(isDirty: const Value(false), serverVersion: Value(v)));
// 0 rows if edited mid-push → stays dirty → re-pushed

(Part 6)

Exercise 5 — Online signal. Combine connectivity + reachability into a Stream<bool>.

Show solution
Stream<bool> watchOnline() async* {
  await for (final r in Connectivity().onConnectivityChanged) {
    if (r.contains(ConnectivityResult.none)) { yield false; }
    else { yield await reachability.canReachServer(); }
  }
}

(Part 5)

Exercise 6 — Merge guard. Write _applyRemoteChange that protects dirty local rows.

Show solution
Future<void> _applyRemoteChange(RemoteNote r) async {
  final local = await getById(r.id);
  if (local == null) return insertClean(r);
  if (!local.isDirty) return overwriteWithRemote(r);
  return resolveConflict(local, r);   // dirty + remote change → conflict
}

(Part 7)

Exercise 7 — Field merge. Implement a per-field pick using a base version.

Show solution
String pick(String base, String local, String remote) {
  final lc = local != base, rc = remote != base;
  if (lc && !rc) return local;
  if (rc && !lc) return remote;
  if (!lc && !rc) return base;
  return remoteNewer ? remote : local; // both changed → LWW
}

(Part 8)

Exercise 8 — Background dispatcher. Write a workmanager callbackDispatcher that syncs.

Show solution
@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((task, data) async {
    final db = AppDatabase(openConnection());      // fresh isolate
    final engine = SyncEngine(db: db, api: RestNoteApi());
    try { await engine.syncNow(); await db.close(); return true; }
    catch (_) { await db.close(); return false; }
  });
}

(Part 10)

Exercise 9 — Convergence test. Sketch a test proving two devices converge.

Show solution
final server = FakeServer();
final a = engineFor(newTestDb(), server);
final b = engineFor(newTestDb(), server);
await a.repo.createNote(title: 'A');
await b.repo.createNote(title: 'B');
for (var i = 0; i < 2; i++) { await a.syncNow(); await b.syncNow(); }
expect((await a.repo.allTitles()).toSet(), (await b.repo.allTitles()).toSet());

(Part 11)

Exercise 10 — Capstone: the sync engine. Assemble a minimal but correct SyncEngine.syncNow() tying together push (with compare-and-clear + idempotency), delta pull (with merge guard + cursor), and conflict handling. Annotate which part each piece comes from.

Show solution
class SyncEngine {
  SyncEngine({required this.db, required this.api});
  final AppDatabase db;
  final NoteApi api;

  Future<void> syncNow() async {
    await _push();   // Part 6: send local changes first
    await _pull();   // Part 7: then bring remote changes down
  }

  // ── PUSH (Part 6) ──────────────────────────────────────────
  Future<void> _push() async {
    final dirty = await (db.select(db.notes)..where((n) => n.isDirty.equals(true))).get();
    for (final note in dirty) {
      try {
        final res = await api.pushNote(note);            // idempotent (UUID upsert)
        if (res.conflict) { await _resolve(note, res.serverNote!); continue; } // Part 8
        if (note.isDeleted) {
          await (db.delete(db.notes)..where((n) => n.id.equals(note.id))).go(); // purge after ack
        } else {
          // compare-and-clear: only clear if still at pushed version (Part 6 race fix)
          await (db.update(db.notes)..where((n) =>
              n.id.equals(note.id) & n.version.equals(note.version)))
            .write(NotesCompanion(isDirty: const Value(false), syncError: const Value(null)));
        }
      } on PermanentSyncException catch (e) {
        await (db.update(db.notes)..where((n) => n.id.equals(note.id)))
          .write(NotesCompanion(syncError: Value(e.message)));   // Part 9: surface
      } catch (_) {
        /* transient: leave dirty, retry next sync (Part 6) */
      }
    }
  }

  // ── PULL (Part 7) ──────────────────────────────────────────
  Future<void> _pull() async {
    var cursor = await _readCursor();
    var more = true;
    while (more) {
      final res = await api.pullChanges(cursor: cursor, limit: 200);
      await db.transaction(() async {                    // atomic apply + cursor
        for (final r in res.changed) {
          final local = await _getById(r.id);
          if (local == null) { await _insertClean(r); }
          else if (!local.isDirty) { await _overwrite(r); }   // safe
          else { await _resolve(local, r); }                  // Part 8: conflict
        }
        for (final id in res.deletedIds) {
          final local = await _getById(id);
          if (local != null && !local.isDirty) {
            await (db.delete(db.notes)..where((n) => n.id.equals(id))).go();
          } // else delete-vs-edit → edit wins (Part 8)
        }
        await _writeCursor(res.nextCursor);
      });
      cursor = res.nextCursor; more = res.hasMore;
    }
  }

  // ── CONFLICT (Part 8): field merge off the base, then re-push ──
  Future<void> _resolve(Note local, RemoteNote remote) async {
    final base = await _lastSyncedSnapshot(local.id);
    await _writeFieldMerge(local, remote, base);          // marks dirty → re-pushed
  }
}

How it maps to the series: client UUIDs + upsert give idempotent push (Part 6); compare-and-clear fixes the dirty-during-push race (Part 6); the merge guard (!local.isDirty) protects offline edits (Part 7); the transactional cursor makes pull crash-safe (Part 7); field merge + edit-wins resolve conflicts (Part 8); permanent errors set syncError for the UI (Part 9); and the whole syncNow is idempotent, so connectivity and background triggers can call it freely. The local DB stays the SSoT, so the reactive UI updates for free, and it's all testable with an in-memory DB + fake API.

If you can write and defend this syncNow, you've mastered offline-first.


You made it

A hundred questions, ten exercises, and a from-scratch sync engine. If you worked them honestly, you can design and build offline-first Flutter apps end to end:

Pair this with the Flutter State Management, Internals, and Riverpod series, and you can architect a production Flutter app that works anywhere — signal or not. Now go build FieldNotes for real. 🚀