WTN App was een React Native-app voor digitale afvalbonnen in het Verenigd Koninkrijk, met Postgres op de server. Een lading volgde een keten van handtekeningen van producent naar vervoerder naar ontvanger. Een chauffeur moest een lading kunnen opnemen, tekenen en doorgeven zonder signaal, en de keten moest weer gelijk komen te staan zodra een van de partijen online kwam. De app wordt niet meer onderhouden, maar de technieken gelden nog altijd.
De codefragmenten in dit artikel, met hun velden, zijn vereenvoudigde illustraties, niet de echte code. Een uitleg zonder techniek staat in Een app die werkt zonder bereik.
SQLite op de telefoon
In een online-first app wacht een schrijfactie op de server, dus zonder netwerk heb je niets. Met SQLite op het toestel schrijft de app naar een lokale databank en leest de interface daaruit. Synchroniseren wordt een aparte taak die draait wanneer er netwerk is.
SQLite past daarbij om twee redenen. De data staat op schijf, dus ze overleeft een herstart of een app die door het besturingssysteem afgesloten wordt. Je hebt ook transacties: een nieuw event en de bijgewerkte lokale weergave worden samen gecommit, dus een app die halverwege stopt, laat geen half werk achter.
Een append-only event stream per keten
Elke keten had een eigen event stream, waarin je alleen kan toevoegen. Een wijziging is een nieuw event, en bestaande events blijven staan. Dat past bij offline werken om een paar redenen.
Twee toestellen die offline dezelfde rij aanpassen, geven een conflict waarbij het ene het andere overschrijft. Twee toestellen die events toevoegen, geven twee events, en die kan je allebei bewaren en daarna interpreteren.
Herhalen is veilig. Valt het netwerk weg midden in een upload, dan weet de client niet of de server het event gekregen heeft. Als elk event een id heeft dat op het toestel gegenereerd is, stuurt de client het gewoon opnieuw en negeert de server wat hij al heeft.
De volgorde hoeft alleen binnen een keten te kloppen. Je hebt geen globale volgorde over alle data nodig, en de kans op gelijktijdige schrijvers per keten is klein. Reken daarbij niet op de klok van het toestel, want die kan verkeerd staan. Een teller per toestel of een verwijzing naar het vorige event zegt meer, en de server legt vast in welke volgorde hij de events ontvangen heeft.
Je krijgt er ook een audittrail bij, en bij een document waar later telt wie wat wanneer getekend heeft, is dat handig.
// Vereenvoudigde illustratie, niet de echte code.
type Role = "producer" | "carrier" | "receiver";
type ChainEvent = {
id: string; // op het toestel gegenereerd, maakt herhalen veilig
chainId: string;
actor: { userId: string; role: Role };
type: "fields_edited" | "signed";
payload: Record<string, unknown>;
recordedAt: string; // klok van het toestel, alleen ter informatie
};
Een projectie op de server
De server leidde uit de events de actuele stand van elke keten af. Zo’n projectie is een functie van events naar staat, die je wegschrijft naar gewone tabellen waaruit je dashboards, zoekopdrachten en exports voedt. Omdat de events de bron blijven, kan je een projectie opnieuw opbouwen als je de logica aanpast.
Een telefoon zonder netwerk heeft ook een weergave nodig. In dit soort opzet vouw je dezelfde events lokaal op tot een staat in SQLite. Die lokale staat is een voorlopige versie, en de server heeft het laatste woord.
Conflictregels per rol
De regels waren eenvoudig: producenten bewerken producentvelden, vervoerders bewerken vervoerdersvelden, en niemand kan opnieuw tekenen voor iemand anders.
Algemene merge-strategieën zoals last write wins passen slecht bij een document met juridisch gewicht, omdat ze stilletjes een wijziging laten verdwijnen. Als je de velden per rol verdeelt, overlappen de schrijfacties van verschillende rollen niet meer, en valt een hele klasse conflicten weg. Wat overblijft, is dezelfde rol op twee toestellen. Daarvoor heb je nog altijd een eigen regel nodig.
// Vereenvoudigde illustratie, niet de echte code.
const FIELDS_BY_ROLE: Record<Role, string[]> = {
producer: ["wasteCode", "quantity", "siteAddress"],
carrier: ["vehicleReg", "collectedAt"],
receiver: ["receivedAt", "acceptedQuantity"],
};
function isAllowed(event: ChainEvent): boolean {
if (event.type === "signed") {
return event.payload.role === event.actor.role;
}
const allowed = FIELDS_BY_ROLE[event.actor.role];
return Object.keys(event.payload).every((field) => allowed.includes(field));
}
De client kan die regels ook toepassen, zodat de interface een verboden actie niet aanbiedt. De controle die telt, gebeurt op de server, en omdat de client offline werkt, kan die een event pas achteraf weigeren. Je interface moet kunnen tonen dat iets dat lokaal gelukt leek, later toch niet aanvaard is.
De overdracht met een QR-code
De producent toonde een kort geldige QR-code met het keten-ID en een HMAC van de snapshot van de producent. De vervoerder scande die zonder netwerk.
Een QR-code werkt tussen twee telefoons zonder bereik, zonder dat ze eerst moeten koppelen. Met een ID en een HMAC blijft de code klein en dus makkelijk scanbaar op een schermpje buiten. Het event van de vervoerder verwijst dan naar exact die versie van de gegevens van de producent. Zodra de events van beide kanten op de server staan, kan die de HMAC tegen de snapshot controleren, ongeacht wie eerst online kwam.
// Vereenvoudigde illustratie, niet de echte code.
const snapshot = canonicalJson(producerFields(chain));
const mac = hmacSha256(key, `${chain.id}.${expiresAt}.${snapshot}`);
const qr = encodeQr({ chainId: chain.id, expiresAt, mac });
Een paar dingen om op te letten als je dit zelf bouwt. Een HMAC werkt over bytes, dus beide kanten moeten de snapshot op exact dezelfde manier serialiseren. Een HMAC is ook geen digitale handtekening: iedereen met de sleutel kan hem berekenen. Wat de controle bewijst, hangt dus af van wie de sleutel heeft. Moet je per ondertekenaar kunnen aantonen wie iets getekend heeft, dan kijk je naar asymmetrische handtekeningen met een sleutel per toestel. De korte geldigheid beperkt hoelang een screenshot van de code bruikbaar is, maar offline hangt die controle af van de klok van het toestel. De server kan ze pas achteraf hard afdwingen.
Een sync-badge op elk scherm
Elk scherm toonde een badge met drie toestanden:
- Lokaal opgeslagen: de transactie op dit toestel is gecommit.
- Naar de server verstuurd: de server heeft de events ontvangen.
- Bevestigd door alle partijen: de andere partijen in de keten hebben ze bevestigd.
Een enkele indicator “gesynchroniseerd” zou de laatste twee op één hoop gooien, terwijl dat voor een chauffeur iets anders is. Offline-first verbergt het netwerk voor de schrijfactie, dus de interface moet ergens zeggen hoe ver een wijziging geraakt is.
Wat je er nog bij moet plannen
Een telefoon die lang offline is geweest, kan events sturen in een oud formaat. Je server moet oude versies blijven aanvaarden, en elk event heeft een versienummer nodig. Reken ook op oude apps die nieuwe events terugkrijgen, en bepaal wat ze daarmee doen.
Streams groeien alleen maar. Per keten blijft dat beperkt, omdat een keten op een bepaald moment af is. Wie streams heeft zonder einde, zal over snapshots moeten nadenken.
Lees ook
Wat is jouw idee?
Vertel me wat je wil bouwen. Ik probeer binnen één werkdag te antwoorden.