Command Palette

Search for a command to run...

Blog

Hors-ligne d'abord, parce que le chantier n'a pas de réseau

Publié le
Temps de lecture
9 min de lecture
Figures
5 figures
Tabaga Team

Ce que Batipro et Officine nous ont appris sur les bases locales, la synchronisation, les licences, et les bugs qui n'existent que sur une vraie machine.

Sept heures du matin, un chantier à la sortie de la ville. Le chef de chantier passe le portail, et son téléphone perd sa dernière barre. Il fera l'appel, notera deux livraisons de ciment, photographiera une poutre fissurée et saisira trois dépenses avant de revoir un réseau à dix-huit heures trente, sur le Wi-Fi du bureau. Derrière le comptoir d'une pharmacie à l'autre bout de la ville, un PC qui n'a jamais eu de serveur tient les registres de toute l'officine. Tous deux font tourner notre logiciel, et tous deux nous ont appris la même leçon par les deux bouts.

0
appel réseau sur le chemin d'écriture
51
migrations de schéma après deux mois d'usage quotidien
14 j
d'essai calculés sur l'appareil, sans backend
0

ligne perdue dans l'incident du renommage, grâce à un test

Le chantier n'a pas de réseau. Le logiciel, si.

07:00no signal3 pending writes09:30no signal11 pending writes12:00one bar19 pending writes15:00no signal27 pending writes18:30office wifioutbox drainedattendance · materials · expenses · photosevery write lands in SQLite immediately; the network is never on the pathsyncBATIPRO · A CHEF DE CHANTIER’S PHONE, AS THE RELEASE CHECKLIST TESTS IT
Fig. 1.Une journée sur le chantier : le téléphone perd le réseau au portail, la boîte d'envoi se remplit toute la journée, et tout part d'un coup depuis le bureau le soir.

La checklist de release de Batipro se termine par deux tests grandeur nature : une vraie paie, et une personne qui synchronise depuis un chantier sans signal. Ce deuxième test, c'est tout le produit en une phrase. Rien de ce qu'un ouvrier fait sur le chantier ne doit dépendre de la présence d'un réseau, et rien de ce qu'il a fait de la journée ne doit se perdre quand il revient.

Alors chaque écriture atterrit immédiatement dans SQLite. Pointages, matériaux, dépenses et photos sont des faits locaux dès l'instant où ils sont saisis. Dans cette conception, le réseau n'est ni lent ni peu fiable ; il n'est simplement pas sur le chemin. Quand le téléphone arrive au bureau le soir, une boîte d'envoi se vide dans l'ordre, et la journée part d'un coup.

Où vit la vérité

SQLiteevery screen, report and joinruns here, with no networkLaptop or PCfull local replicaSQLiterows, when a signal is thereAppwriterow shuttle, not a query engineoptional · last resortsource of truthbackup and sync onlyOFFICINE HAS NO SYNC AT ALL IN V1: ONE USER, ONE MACHINE, ONE ENCRYPTED FILE
Fig. 2.Où vit la vérité : chaque appareil détient toute la base ; le cloud, quand il y en a un, ne fait que transporter des lignes.

Le README de Batipro le dit en une ligne : toutes les données vivent dans une base SQLite sur l'appareil, via Drift, et l'app fonctionne entièrement hors-ligne. Le cloud est une couche optionnelle, de dernier recours, utilisée seulement quand on veut une sauvegarde ou une synchronisation. Cet ordre compte. Quand la base locale est le stockage principal, chaque écran, chaque rapport et chaque jointure tourne contre SQLite sur l'appareil, et le serveur, s'il existe, ne fait que transporter des lignes.

Officine va plus loin et n'a aucune synchronisation dans sa première version : un utilisateur, une machine, un fichier chiffré. Sur Windows, le paquet Flutter habituel sqflite ne fonctionne pas, alors l'app initialise sqflite_common_ffi et remplace la fabrique de base de données avant que quoi que ce soit d'autre ne tourne. Les migrations vivent dans un seul helper versionné avec onCreate et onUpgrade, et le schéma commence petit : clients, médecins, crédits, paiements de crédits, ordonnances, commandes, médicaments, avec un index sur la date de péremption parce que les alertes de péremption sont la fonctionnalité que les pharmaciens ont demandée en premier.

Le schéma de Batipro est aujourd'hui en version 51. Cinquante et une migrations, c'est à quoi ressemble un vrai produit après deux mois d'usage quotidien, et ce n'est survivable que parce que les migrations ont été versionnées dès la première.

Le dossier qui a mangé la pharmacie

%APPDATA%\OldName\...the pharmacy's real records%APPDATA%\Officine\...new build, new folderrenamebefore: new folder is empty‘our records look deleted’, no error anywhere_adoptLegacyDatabase: copy, never movedboriginal stays as fallbackpinned by testsbrand_identity_testlegacy_adoption_testnot one row lost
Fig. 3.Le dossier qui a mangé la pharmacie : un produit renommé pointe vers un répertoire vide sans aucune erreur. Le correctif copie l'ancienne base vers l'avant et laisse l'originale intacte.

Celui-là nous a fait peur. Sur Windows, les noms de société et de produit dans le runner décident où vit la base, quelque chose comme %APPDATA%\Officine\Officine\officine.db. Changez l'une des deux chaînes, ou le nom du fichier, et un nouveau build pointe vers un dossier vide sans la moindre erreur. Pour le pharmacien, ses registres ont l'air supprimés.

Nous avons renommé le produit une fois, tôt. Le build adopte désormais l'ancienne base au premier lancement en la copiant vers l'avant, jamais en la déplaçant, pour que l'originale reste en place en secours. Deux tests épinglent ce comportement : l'un vérifie que les identifiants de marque ne dérivent jamais, l'autre qu'une base d'un premier build est reportée sans perdre une ligne. Le commit qui a clos l'incident porte exactement ce titre.

Traitez l'emplacement de stockage comme un contrat public. Écrivez-le, testez-le, et faites des renommages une migration, pas un renommage.

README de Batipro

La synchronisation est une fusion, pas un câble

ONE DRIFT TRANSACTIONrowupdatedAtoutboxpendinga crash can never lose a pending writeoffline: rows simply wait heredrain, in orderserver(table, rowId, JSON, serverVersion)assigns a monotonic serverVersionknows nothing about the schemapullpullother seatsconflict: last writer wins on updatedAt, tiebreak by serverVersionenough for 1 to 5 seats whose modules barely overlap; written down so nobody upgrades it to CRDTs
Fig. 4.Une écriture, une transaction : la ligne et son entrée de boîte d'envoi atterrissent ensemble ; le serveur ne fait qu'ordonner et transmettre ; les autres postes tirent.

Quand Batipro est passé d'un appareil à une petite équipe, nous avons conçu la synchronisation autour d'une phrase de nos propres notes : la partie difficile de la synchro, c'est la fusion, pas le transport. Vidage de la boîte d'envoi, ordre, résolution de conflits, rejeu idempotent, migration de schéma entre versions de l'app. Le moteur a donc d'abord été construit contre un transport LAN, et pointé vers le cloud seulement plus tard.

La conception qui en est sortie :

  • Chaque écriture place la ligne et une entrée de boîte d'envoi dans la même transaction Drift. Un crash ne peut jamais perdre une écriture en attente ; hors-ligne, les lignes attendent simplement dans la boîte d'envoi.
  • Le serveur attribue un serverVersion monotone à chaque ligne qu'il accepte. Les conflits sont résolus dernier-écrivain-gagne sur updatedAt, et les clients ne comparent jamais leurs horloges entre eux.
  • Le serveur est un transporteur de lignes, pas un moteur de requêtes. Il stocke (table, rowId, JSON, serverVersion) et ne sait rien du schéma. Rapports, jointures et agrégations restent locaux, là où la réplique complète existe déjà.
  • Photos et documents voyagent hors du socket, envoyés directement vers le stockage d'objets et indexés par ligne.
  • Un simple dernier-écrivain-gagne au niveau de la ligne suffit parce que les équipes font un à cinq postes et que les modules se chevauchent à peine entre les rôles. Nous avons écrit cette justification pour que personne ne « l'améliore » en CRDT plus tard.

Le relais ne transmet que du chiffré, chiffré côté client sous une clé d'entreprise qui ne quitte jamais la machine du propriétaire. L'enveloppe de sauvegarde est construite ; le chiffrement des deltas ne l'est pas, et le tableau d'état du dépôt le dit en orange. Des tableaux d'état honnêtes, c'est ainsi qu'un studio solo garde une feuille de route véridique.

Un backend que le client possède

Pourquoi Appwrite pour la couche de synchronisation ? Parce qu'il peut être auto-hébergé. Firebase et Supabase sont uniquement cloud, ce qui impose une carte bancaire internationale à chaque client, et une carte internationale est exactement l'obstacle qui a façonné toute notre pile de paiement. Chaque client Batipro reçoit donc son propre projet Appwrite à son nom, avec trois options d'hébergement : Appwrite Cloud, un VPS en Algérie facturé en dinars avec une facture locale, ou une machine dans son propre bureau. Le code de l'app est identique dans les trois cas. Seule l'URL du point d'accès change.

Une licence qui marche par téléphone

installday 0Pro trial startsday 14trial endsfalls to Gratuit, never locked outactivationonline or by phonerequest code → activation codechannelper licencestable, or beta for one keyno in-app beta toggle, on purposea toggle means one support call to turn it on and no way to know which build was running when it brokeINSTALLERS: INNO SETUP IN CI · SHA-256 · ED25519 SIGNATURE · TAGS ONLY FROM MAIN
Fig. 5.Une licence qui survit à un réseau mort : l'essai est calculé sur l'appareil, l'activation peut se dicter au téléphone, et les canaux se règlent par licence, pas par build.

Une installation neuve démarre un essai de quatorze jours calculé localement, sans appel backend et sans jeton signé. Quand il expire, l'app retombe sur un palier gratuit au lieu d'enfermer l'utilisateur dehors. L'activation parle à une petite API de licences sur Cloudflare Workers, mais il existe aussi une activation hors-ligne : l'utilisateur dicte un code de demande au téléphone, la console du vendeur renvoie un code d'activation. Ce chemin existe parce que les premiers pilotes ont activé depuis des endroits où le téléphone était la seule chose à avoir du signal.

Les canaux de release sont par licence, pas par build. Chaque client demande stable ; la licence peut le remplacer par beta pour une seule clé. Nous n'avons volontairement pas livré d'interrupteur « builds bêta » dans l'app, parce qu'un interrupteur, c'est un appel au support pour l'activer et aucun moyen de savoir quel build tournait quand quelque chose a cassé.

Les installateurs sont construits par la CI avec Inno Setup, vérifiés par somme de contrôle et signés avec une clé Ed25519. Un installateur non signé ne doit pas être publiable, et un tag qui n'est pas sur main est refusé par le workflow.

Des bugs qui n'existent que sur de vraies machines

  • Notre fichier de schéma utilisait CREATE TABLE IF NOT EXISTS, donc ajouter une colonne ne faisait rien sur une base existante. L'outil de déploiement annonçait un succès, la colonne n'était pas là, et un endpoint d'administration répondait 500 contre la vraie base tout en passant chaque test unitaire contre le dépôt en mémoire. Le correctif : un vrai script de migration plus un test qui fait échouer le build en cas de dérive de schéma.
  • L'app a besoin de deux defines à la compilation : l'adresse du backend et la clé publique. Avec seulement la première, elle atteint le serveur puis refuse la licence qu'on lui remet. Avec seulement la seconde, elle ne quitte jamais la machine. Pendant un temps, les deux produisaient le même « activation échouée » qu'une mauvaise clé, ce qui vous envoie vérifier la console et la licence, les deux endroits où le problème n'est pas. Les messages d'erreur nomment désormais le define manquant.
  • Un export CSV ouvert dans Excel affichait un caractère parasite dans la première cellule. Un BOM UTF-8. Une ligne pour l'enlever, un après-midi pour le trouver.
  • Vingt-trois champs éditables détruisaient leur valeur quand l'interface était en anglais ou en arabe, parce que le formateur de nombres et l'analyseur n'étaient pas d'accord sur le séparateur décimal. Les bugs de locale se cachent sur le chemin d'écriture, pas sur le chemin de lecture.

Si vous construisez pour les mêmes endroits

  1. Décidez qui détient la vérité, l'appareil ou le serveur, et n'en débattez plus jamais.
  2. Mettez l'écriture et son entrée de boîte d'envoi dans une seule transaction. Tout le reste de la synchro est plus facile que de perdre une écriture.
  3. Construisez le moteur de synchro contre un faux transport d'abord.
  4. Épinglez le chemin de stockage, les identifiants et les migrations avec des tests. Ce sont les parties qu'un renommage casse en silence.
  5. Gardez un tableau d'état à trois couleurs et mettez-le à jour quand quelque chose n'est pas construit.
  6. Validez une release comme un client le ferait : une vraie paie, et une personne qui synchronise depuis un chantier sans signal.
Command Palette

Search for a command to run...