Offline-first is not a cache
In Grimoire Culinaire, the local database is the source of truth and Supabase is just a mirror to reconcile. What that inversion concretely changes in the code, and what it costs.
The kitchen has no wifi
Grimoire Culinaire is a recipe app. Its natural habitat is the kitchen: hands covered in flour, phone propped against the utensil pot, and wifi that works every other time depending on how thick the walls are. Before writing the first line of code, I made a product decision: the app works 100% without a network. No degraded mode, no banner saying "some features require a connection". Creating, editing, cooking, deleting: everything works in airplane mode.
That is not a checkbox on a technical checklist. It is a real usage problem that comes before the architecture. And the decision has a brutal consequence: it rules out the caching shortcut, where the server holds the truth and the phone keeps a copy that is allowed to lie.
Inverting the hierarchy
Concretely, the local Drift database (typed SQLite, on the Flutter side) is the source of truth. Supabase is just a mirror that gets reconciled whenever the network cooperates. For that inversion to hold, every recipe carries three sync columns directly in the local schema:
/// Synchronisée avec le serveur (false en local).
BoolColumn get isSynced =>
boolean().withDefault(const Constant(false))();
/// Date de dernière modification (ISO 8601).
DateTimeColumn get updatedAt => dateTime()();
/// Date de suppression soft (null = non supprimée). Utilisé pour la sync.
DateTimeColumn get deletedAt => dateTime().nullable()();Three columns carry the whole model
isSynced says whether the server knows about the latest version. updatedAt settles conflicts. deletedAt implements the soft delete: deleting a recipe offline does not erase it, it flags it as deleted with isSynced flipped back to false. The deletion is propagated to the server at the next sync, and the local row is only permanently removed once that sync is confirmed. In this model, a deletion is data like any other: it has to survive a restart and travel all the way to the server.
Sync as a routine, not an event
There is no "save to cloud" button and no spinner blocking the screen. Sync is a background routine with two rhythms. The first: fullSync at startup and on every reconnection, push first, pull second.
/// Sync complète : push local → pull remote. Appelé au startup et reconnect.
Future<void> fullSync() async {
if (_isSyncing) return;
_isSyncing = true;
_setStatus(SyncStatus.syncing);
try {
await _stampUserId();
await pushLocalChanges();
await pullRemoteChanges();
// Sync stats, wallet, achievements.
// ...
_setStatus(SyncStatus.idle);
} catch (e) {
_setStatus(SyncStatus.error);
} finally {
_isSyncing = false;
}
}The second rhythm: fire-and-forget
The second rhythm: pushSingleRecipe, triggered fire-and-forget after every create or update. If the call succeeds, great. If it fails, nothing breaks: the recipe keeps its isSynced=false and gets picked up by the next fullSync. The user never sees a save failure, because there is nothing to see: their data is already safe at home, locally. A small connectivity service exposes a boolean stream that kicks off a retry as soon as the network comes back.
The conflicts I accept
Reconciling two databases means accepting conflicts. My rule fits in the comment at the top of the sync service: "Conflicts: last-write-wins on updatedAt". When the pull hits a recipe that was modified both locally and on the server, the most recent one wins, full stop.
} else {
// Last-write-wins : la plus récente gagne.
if (remoteUpdatedAt.isAfter(localRecipe.updatedAt)) {
await repository.upsertFromRemote(remoteRecipe);
}
// Si local est plus récent, elle sera pushée au prochain cycle.
}What this choice loses, and why that is acceptable
This choice loses data in one specific case: two devices edit the same recipe offline, and the most recent one overwrites the other without merging fields. I accept that, because Grimoire is single-user. The losing scenario requires the same person to edit the same recipe on two phones, both offline, before either of them syncs. Against that risk, I prefer a model I fully understand over a merge machinery I would be debugging at two in the morning. The code even keeps a deliberately commented-out block, annotated "enable when multi-device is supported": the limitation is documented, not ignored. Last-write-wins is a local trade-off, not a universal recommendation.
What it costs
Offline-first is not free, and the repo bears the scars. The local schema is at version 12, with a MigrationStrategy that replays every step of its history: recipe tables in v2, presentation columns in v3, gamification in v4, and so on. On the server side, the sync columns were added after the fact through a SQL migration: deleted_at did not exist in Supabase, and the mirror had to be aligned with a Drift schema that had moved ahead.
And every new table pays the toll again. When I added stats, the wallet and achievements, each one had to write its own push, its own pull and its own conflict resolution. The fullSync that used to make two calls now makes eight. That is the structural price of the inversion: in a cache model, you invalidate and re-download; in an offline-first model, every piece of data must know how to travel in both directions.
What I take away
- A cache answers the question "what do we do if the network goes down". Offline-first answers "what do we do when the network comes back". The second question is by far the harder one.
- Three columns are enough to carry the whole model (isSynced, updatedAt, deletedAt), provided you put them in the schema from day one and treat deletion as data that travels.
- Last-write-wins is an acceptable choice for a single-user app, not a universal solution. What matters is writing down, in plain sight, the cases where it loses.
- Sync must be an invisible routine, never an event the user waits for. If there is a save spinner, the hierarchy was never really inverted.
- Every synced table pays the push/pull toll again. Offline-first is decided before the first line of code, because you pay for it everywhere afterwards.