Case Study · Offline-First Mobile
Evenglow
Offline-first farm operations — stock, growing, and iron under one roofline. A mutation log on the device, a three-write commit protocol on a server with no transactions to lean on, and a sync engine that would rather stop than skip. Built for a barn with no signal, on the assumption that the phone in your pocket is the only copy.
The 30-second version
- Problem
- Farm work happens where there is no signal. Most farm software assumes a connection and quietly loses the entry when there isn’t one.
- Solution
- Every action writes to a mutation log on the device first. A sync engine reconciles later against a server with no multi-document transactions, using a commit protocol that would rather stop than skip.
- Proof
- 3,200+ tests across 267 files — and one TODO comment in 72,000+ lines of strict TypeScript.
- Unlocks
- A full day of records taken in airplane mode, syncing exactly once when the signal comes back.
Why it exists
The same person checks the hens and the carrots on the same walk.
A small mixed farm runs animals, growing, and machinery at once — poultry and ratites and ruminants, beds and polytunnels and succession sowing, hour meters and service intervals. The software for it is split across three products that each assume they are the only one, and none of them work in the one place the records are actually made.
A barn has no signal. Neither does the far gate, or the polytunnel, or most of the ground between them. An app that needs a network to write a line down is an app that gets written down on a hand instead, and transcribed that evening if it is transcribed at all.
So the network is not the source of truth here — the device is. The server is where devices reconcile, not where work is authored. That single inversion is what the whole engine below exists to make safe.
The thesis
The abstraction was built for a pivot that had already been argued against.
The project pivoted twice: a Next.js PWA to Capacitor, then Capacitor to React Native, deleting the old client both times. Before the second pivot was decided — and while the written recommendation still argued against React Native, precisely because it has no IndexedDB — the storage layer had already been extracted behind a port called LocalStore.
That is the whole reason the second pivot cost days of storage work instead of starting the durability story again from nothing. The port was written for a move that the reasoning of the day said would not happen, and it is the single most load-bearing file in the repository for exactly that reason.
The Phase 2 exit gate — mutations logged with the radio off, surviving process death, syncing without loss or duplication — was first earned in a browser against IndexedDB. The move to native SQLite changed the durability characteristics that gate was proving, so it was re-earned on device rather than carried across.
A gate proved on a storage layer you no longer ship is not a gate.
Architecture
Six stages, and a mutation is durable at the first one.
A record is written to device SQLite and is safe there before anything touches the network. Everything after that is reconciliation — and every stage is built on the assumption that it will be interrupted halfway through.
One SQLite transaction mints the sequence number, writes the outbox row, records an undo pre-image, projects the optimistic record, and advances the counter. Assigning clientSeq outside that transaction is how two mutations end up sharing a sequence number after a crash mid-write — at which point ordering is broken and nothing says so.
Single-flight: concurrent callers share the in-flight promise rather than starting a second batch, which is what makes “never parallel” true when a timer, a reconnect, and a tap all fire at once. A mutation leaves the outbox only on applied or duplicate; anything else parks in the rejected inbox.
serverTs is assigned inside a per-org async lane, immediately before the insert, clamped above every stamp already issued and seeded from the newest row on process start — so a stalled request or an NTP correction cannot make a row visible below a watermark already published.
Batches apply sequentially in clientSeq order against a client-minted ULID used as the Mongo _id, with $setOnInsert making a replay a no-op. A replay projects from the stored envelope, never from the replayed request — the subtle half of idempotency that most implementations reintroduce the bug in.
The read half ships the mutation log rather than projected documents, because the client already knows how to turn a mutation into a local record. One projection path serves both enqueue and hydration, so the two cannot drift.
An hourly pass decides rows whose client never came back, running the same apply and stamp code path rather than a second implementation of it. A second implementation is a second set of bugs, and the rarely-run one is where they hide.
The hard parts
Every one of these is a failure that stays invisible until it is expensive.
By the numbers
65,000+ lines of test against 72,000+ lines of strict TypeScript. The tenancy guard is the one worth naming: across the whole monorepo there is not a single .collection()call outside the one module allowed to make them, and not a single inline disable of the rule that enforces it. “Remember to include orgId” is exactly the instruction that layer exists to make unnecessary.
Under the hood
The stack, by layer.
What the claims actually mean
The interesting version of a sync engine is the one that says where it stops.
The repository carries a 50-finding adversarial audit of its own codebase, opening on the line that every finding in it is present in a tree where the whole suite passes. Several of those findings are still open, including one in the signup path. An audit that only indicts the code you already fixed is a press release.