No Server-Side Merge, No Account, and the Save Still Converges Across Devices — G-Counters plus CAS
A KeyDo postmortem: anonymous, no login, multiple devices practicing offline — how do you keep the save eventually consistent without lost updates? The answer is splitting responsibility: the Worker does only versioned atomic access (CAS), and the frontend does a deterministic merge that is commutative, associative, and idempotent.
KeyDo is anonymous: no account, one passphrase per save, and multiple devices can each practice offline. That brings a distributed problem: without server-side semantic merging or introducing a login, how do you keep the same save eventually consistent across devices, with no lost updates under concurrency?
This remediation’s answer splits responsibility in two, with a clear boundary: the Worker guarantees only “versioned atomic access,” and the frontend does the “deterministic merge.” The Worker doesn’t understand semantics beyond SQL and doesn’t rewrite the frontend’s merge rules; the frontend doesn’t touch version-control atomicity. Each guards its own half.
The frontend half: a merge that must satisfy three laws
The save is not “last write overwrites” — that would inevitably let some device’s practice get wiped under concurrency. It’s a convergent model: each field merges with a commutative, associative, idempotent join. For any two valid saves a, b, merge(a, b) must simultaneously satisfy:
- commutativity:
merge(a,b) == merge(b,a)— order of arrival doesn’t matter; - associativity: three or more, any grouping order, same result;
- idempotency:
merge(a,a) == a— merging the same one repeatedly doesn’t change it.
Satisfy these three, and multiple devices merging each other in any order, any number of times, converge to the same result, with no central arbiter needed. This is essentially the CRDT approach. Per field:
Practice time is a G-Counter with an epoch. This is the key piece:
timeCounter = { epoch, legacy, devices: { "a1b2c3d4": ms, "e5f6...": ms } }
Each device only writes its own 8-hex slot, monotonically increasing. The merge is per-slot max — because each device’s slot only grows, taking max naturally satisfies all three laws. Total time = legacy + Σ devices. The epoch is a hatch left for “breaking corrections”: when epochs differ, the higher epoch wins entirely and the lower’s values never merge in. (For instance, to clear a batch of anomalously farmed time, bump the whole epoch, and the old values auto-void, rather than changing a boolean flag — a boolean gets reused by later fixes or other identities, an epoch doesn’t.)
The other fields each use their own join: unlock progress and revision take max (monotonic non-decreasing); weekly time first compares week and takes the newer, merging counters only within the same week, so cross-week offline data doesn’t pollute the current week; history records union-dedup by UUID then deterministically sort and slice to the latest 50; the best score uses a total-order comparison (primary metric desc → accuracy desc → time asc → id asc → record JSON asc), so even ties resolve deterministically.
The backend half: two mutually-exclusive SQL statements as CAS
The version isn’t an HTTP ETag; it’s a counter in the row, saves.version. A read returns {data, version}, missing row is {data: null, version: 0}; a write carries {expectedVersion, data}.
The key is not using a generic UPSERT, but two mutually-exclusive SQL statements — to prevent a “missing row + non-zero version” from accidentally inserting one:
-- First write: only reached when expectedVersion === 0
INSERT INTO saves (user_id, data, version, updated_at)
VALUES (?1, ?2, 1, ?3)
ON CONFLICT(user_id) DO NOTHING
RETURNING version, updated_at;
-- Update existing row: only reached when expectedVersion > 0
UPDATE saves SET data = ?2, version = version + 1, updated_at = ?3
WHERE user_id = ?1 AND version = ?4 -- ?4 = expectedVersion
RETURNING version, updated_at;
Both judge success by whether RETURNING returns a row: INSERT ... ON CONFLICT DO NOTHING returns nothing on a row collision, and UPDATE ... WHERE version = expectedVersion returns nothing when the version doesn’t match. No returned row = conflict, throw 409. This is compare-and-swap on one row, with not a single line of merge logic on the server.
Putting it together: on CAS failure, pull-merge-retry
The two halves together make one client sync round: GET the remote {data, version} → validate → join local and remote with the three-law-satisfying merge → submit with the just-pulled remote.version. On a 409 (another device wrote during your submit), back off a bit ([100, 300]ms plus jitter) and start over, up to 3 rounds.
Here the three laws aren’t theoretical fastidiousness — they’re what holds the whole retry’s correctness:
- retry safety rests on idempotency — a retry re-merges and resubmits without double-counting time;
- and even “the server committed but the response was lost in the network” converges: after a timeout the client re-GETs, re-merges, and resubmits, and because the merge is idempotent, the replay doesn’t double. This is exactly the convergent model’s edge over “incremental reporting” — with incremental reporting, once a response is lost you don’t know whether to resend, and resending might overcount.
A few devils in the details
Floats don’t satisfy associativity, so counter arithmetic needs care. The timeCounter’s total was originally summed in Object.values(devices) insertion order — but float addition isn’t associative, so the same devices, in different insertion order, sum to a different total, breaking the premise that “validate and merge results agree.” Changed to sort by key then sum. And trickier: topping legacy up to some floor, legacy + (floor - total) can underflow by one ULP due to rounding, still falling short. The new implementation binary-searches the IEEE-754 bit pattern for the smallest representable legacy that lifts the total to the floor — because for non-negative floats the bit pattern’s order matches the numeric order, you can binary-search.
Migrating old saves needs stable digest IDs for dedup. v1/v2 history records have no id. On migration, a self-implemented pure-JS SHA-256 hashes “mode + record canonical fields” into a UUID — so the same old record, migrated on multiple devices, gets the same id, and the history union dedups correctly; deduping by millisecond timestamp would miss.
The write path enforces a set of invariants. The canonical v3 validator accepts only a complete canonical save and enforces: the counter total must not fall below the monotonic evidence floor of “the user really practiced this long,” the week must not regress, device ids must be ^[a-f0-9]{8}$, serialized size must not exceed 100 KiB, and dangerous keys like __proto__ are rejected — any violation is a straight 422. The merge can be lenient; the write must be strict.
Takeaways
- Split responsibility: the storage layer does atomicity, the app layer does merging. The Worker guarantees only “versioned atomic access,” the frontend does deterministic merging — the server doesn’t understand business semantics and so doesn’t become a second source of truth for merge logic. CAS uses two mutually-exclusive SQL statements + a
RETURNING-empty check, not one line of merge code; - If the merge is commutative/associative/idempotent, convergence is free. Multiple devices merging in any order any number of times converge to the same result, with no central arbiter; a G-Counter (one slot per device, take max) is the standard way to make an “accumulating quantity” an idempotent merge;
- Idempotency is the bedrock of retry correctness. With it, CAS-failure retry and even “resend after a lost response” are safe — this is where the convergent model beats incremental reporting: incremental reporting daren’t resend on a lost response;
- Floats don’t satisfy associativity, so make every accumulation deterministic. Sort before summing, guard growth against precision stalls, guard top-ups against ULP underflow — in distributed consistency, even addition order is part of the invariant;
- An epoch beats a boolean flag. To make a breaking correction, bump the epoch and void the old values wholesale, rather than adding a “fixed” boolean — booleans get reused, epochs are monotonic and guarantee lower-epoch data never resurrects.
Comments