|
| 1 | +// SPDX-License-Identifier: CC-BY-SA-4.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 hyperpolymath |
| 3 | += affinescript-crdt |
| 4 | +:toc: macro |
| 5 | + |
| 6 | +Conflict-free replicated data types for local-first AffineScript apps. |
| 7 | +Replicas update their own copy and exchange whole states. `merge` is |
| 8 | +commutative, associative and idempotent, so replicas that have seen the same |
| 9 | +updates hold equal states, whatever order they merged in and however many |
| 10 | +times. |
| 11 | + |
| 12 | +toc::[] |
| 13 | + |
| 14 | +== Types |
| 15 | + |
| 16 | +[cols="1,3"] |
| 17 | +|=== |
| 18 | +| `Clock`, `Stamp` |
| 19 | +| A Lamport clock and its timestamps, totally ordered by `(counter, replica)`. |
| 20 | + `tick(clock)` stamps a local write. `observe(clock, stamp)` accounts for a |
| 21 | + stamp seen from another replica, so later local writes are ordered after it. |
| 22 | + |
| 23 | +| `Lww<T>` |
| 24 | +| A last-writer-wins register (`lww`, `lww_set`, `lww_value`, `lww_merge`). |
| 25 | + |
| 26 | +| `LwwMap<V>` |
| 27 | +| A string-keyed map of LWW entries (`map_set`, `map_remove`, `map_get`, |
| 28 | + `map_keys`, `map_merge`). Removal is a timestamped tombstone, so a later set |
| 29 | + revives the key and an earlier concurrent set loses. |
| 30 | + |
| 31 | +| `OrSet` |
| 32 | +| An observed-remove set of strings (`or_add`, `or_remove`, `or_contains`, |
| 33 | + `or_elements`, `or_merge`). An add wins over a concurrent remove; a remove |
| 34 | + deletes only the adds it has observed. |
| 35 | + |
| 36 | +| `PnCounter` |
| 37 | +| An increment/decrement counter (`pn_add`, `pn_value`, `pn_merge`). |
| 38 | +|=== |
| 39 | + |
| 40 | +States are plain values (sorted arrays of tuples and records), so they |
| 41 | +serialise directly, e.g. to JSON, for storage and sync. Each replica needs a |
| 42 | +unique id; stamps from one replica's clock are unique by construction, which |
| 43 | +is the precondition every LWW type relies on. |
| 44 | + |
| 45 | +== Example |
| 46 | + |
| 47 | +[source,affinescript] |
| 48 | +---- |
| 49 | +use Crdt::{clock, tick, map_empty, map_set, map_merge, map_get}; |
| 50 | +
|
| 51 | +let (c1, s1) = tick(clock("laptop")); |
| 52 | +let laptop = map_set(map_empty(), "title", "Draft", s1); |
| 53 | +let (c2, s2) = tick(clock("phone")); |
| 54 | +let phone = map_set(map_empty(), "title", "Final", s2); |
| 55 | +// Both replicas converge on the same winner ("phone" breaks the tie): |
| 56 | +map_get(map_merge(laptop, phone), "title") == map_get(map_merge(phone, laptop), "title") |
| 57 | +---- |
| 58 | + |
| 59 | +== Performance |
| 60 | + |
| 61 | +Merges are linear merge-joins over sorted entries. On the JS target, though, |
| 62 | +building an array with `++` copies it, so construction and merges cost |
| 63 | +O(n²) in the number of entries. A merge of a 2,000-entry map with a |
| 64 | +1,000-entry one measured 7–47 ms locally. That's fine for sync events; for |
| 65 | +very large states, shard by key. |
| 66 | + |
| 67 | +== Tests |
| 68 | + |
| 69 | +`test/crdt.test.js` contains property tests on the module compiled with |
| 70 | +Bun-ESM. Each uses deterministic seeds, and they cover: |
| 71 | + |
| 72 | +* three replicas applying 300 random operations each with random partial |
| 73 | + gossip, then converging in all six merge orders (LWW map, OR-set, |
| 74 | + PN-counter); |
| 75 | +* LWW maps whose key values equal the highest-stamped write across all |
| 76 | + replicas; |
| 77 | +* the merge laws (commutative, associative, idempotent) on random states for |
| 78 | + every type; |
| 79 | +* OR-set add-wins and observed-remove semantics; |
| 80 | +* PN-counter sums. |
| 81 | + |
| 82 | +Breaking `map_merge`'s tie rule makes the convergence and law tests fail. |
| 83 | + |
| 84 | +[source,sh] |
| 85 | +---- |
| 86 | +dune build # at the repo root |
| 87 | +bun test affinescript-crdt/test |
| 88 | +---- |
0 commit comments