10 KiB
CRDT completion — G-Set and LWW-Map
Package: @suaveplan/crdt
Status: PROPOSED
Type: feature
Why
The genesis port of @suaveplan/crdt ships seven CRDTs — GCounter, PNCounter, LWWRegister, MVRegister, ORSet, TwoPSet, and RGA — but it omits the two upstream CRDTs that round out the catalogue: GSet (grow-only set) and LWWMap (last-write-wins map).
GSet is the simplest set CRDT and the canonical building block for set-shaped state where elements are only ever added — feature flags reached, IDs seen, addresses indexed. Genesis's TwoPSet (two-phase set, supports add + remove with tombstones) and ORSet (observed-remove set, supports add + remove with unique tags) are heavier alternatives that pay for capabilities GSet consumers never need. Without GSet, every grow-only consumer either rolls their own or accepts a TwoPSet that allocates a removed-set they'll never populate.
LWWMap is the only map CRDT in the upstream catalogue and has no genesis equivalent — LWWRegister covers a single value, but multi-key state (per-tenant settings, per-record metadata, per-row last-modified columns) needs an LWW-Map. Genesis consumers currently have no way to express "merge two map states with last-write-wins per key, preserving tombstones for deletes." RGA is sequence-shaped, not key-shaped — it doesn't substitute.
Both upstream implementations are stable, in production use elsewhere, and verifiable against well-known commutative / associative / idempotent CRDT properties. This change ports both with the genesis-wide additions: injectable Clock, CrdtError-routed throws, and 100% line + property-based test coverage.
What changes
- ADDED:
crdts/g-set/g-setmodule —class GSet<T>implementingCRDT<Set<T>, GSetState<T>>withadd(value),has(value),value(),state(),merge(remote),toJSON(),fromJSON(json),clone(). Construction signaturenew GSet<T>(nodeId: string, options?: GSetOptions<T>)matches the genesis CRDT base shape. Also exportsGSetState<T>(state envelope) andGSetOptions<T>(initialElements?: T[]). - ADDED:
crdts/lww-map/lww-mapmodule —class LWWMap<V>implementingCRDT<Map<string, V>, LWWMapState<V>>withset(key, value, ts?),get(key),has(key),delete(key, ts?),keys(),value(),state(),merge(remote),toJSON(),fromJSON(json),clone(),reset(). Tombstones SHALL be preserved on deletes so causal ordering of subsequent re-adds resolves correctly. Tie-break on equal timestamps SHALL comparenodeIdlexicographically (consistent withLWWRegister). - ADDED:
LWWMapEntry<V>andLWWMapTombstonetypes —{ value, timestamp }and{ timestamp }respectively. - ADDED:
LWWMapState<V>type —{ type: "lww-map"; entries: Record<string, LWWMapEntry<V>>; tombstones: Record<string, LWWMapTombstone> }. - ADDED:
GSetState<T>type —{ type: "g-set"; elements: T[] }. - MODIFIED:
LWWMap.setandLWWMap.deleteaccept an optionalClockfrom@suaveplan/typesvia theLWWMapconstructor (defaultDate.now()). Identical to howLWWRegisterresolves an absent timestamp argument. - ADDED:
CRDT_CODES.GSET_INVALID_ELEMENTandCRDT_CODES.LWWMAP_INVALID_KEYerror codes for non-JSON-serializable input. - ADDED: per-module sibling
.mdfor the two new files (g-set.md,lww-map.md). - ADDED: subpath exports
./crdts/g-set/g-setand./crdts/lww-map/lww-mapinpackage.jsonfor tree-shaking.
Out of scope
- PN-Counter — already in genesis under
crdts/pn-counter. Not affected. - Causal CRDTs (Causal Length Set, Causal Tree, Add-Wins / Remove-Wins observed maps) — separate change once the static CRDT catalogue stabilises. They need a different state-envelope design (causal context vectors) than the timestamp-based LWW family.
- Delta-state CRDT optimization — shipping
delta()/applyDelta()methods to reduce sync payloads is its own change; this proposal stays full-state. - Persistence adapters — wiring
GSet/LWWMapto@suaveplan/db-sqlitefor durable storage is a separate change in the@suaveplan/crdt-persistencepackage (not yet created). - Migration tooling — converting an existing
TwoPSetto aGSet(when a consumer realises remove was never used) is left to consumers; no automated converter ships in this change.
Source mapping
This change derives from the upstream package at ../suaveplan/packages/universal/crdt/src and the upstream enterprise audit at ../suaveplan/enterprise/crdt.md.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
GSet<T> class with add / has / merge / value / clone |
packages/universal/crdt/src/sets/g-set/g-set.ts |
L1-L143 | Verbatim port; adapt to genesis CRDT<TValue, TState> interface (genesis uses value() not state().elements); adopt genesis CRDT registration error code style |
GSetState<T> and GSetOptions<T> types |
packages/universal/crdt/src/sets/g-set/g-set.types.ts |
L1-L23 | Verbatim port; merge type: "g-set" discriminator into state envelope to match genesis CRDT_TYPES discriminated union pattern |
LWWMap<V> class with set / get / has / delete / merge / value / clone / reset |
packages/universal/crdt/src/maps/lww-map/lww-map.ts |
L1-L208 | Verbatim port; replace Date.now() fallback with injectable Clock; tie-break compares nodeId lexicographically (same as upstream) |
LWWMapEntry<V> and LWWMapTombstone types |
packages/universal/crdt/src/maps/lww-map/lww-map.types.ts |
L1-L20 | Verbatim |
LWWMapState<V> type |
packages/universal/crdt/src/maps/lww-map/lww-map.types.ts |
L22-L30 | Verbatim port; merge type: "lww-map" discriminator into envelope to match genesis pattern |
| Tombstone-respecting merge semantics | packages/universal/crdt/src/maps/lww-map/lww-map.ts |
L150-L185 | Verbatim — mergeEntry skips remote entries older than a local tombstone; mergeTombstone removes a local entry when remote tombstone is newer |
| Tie-break on equal timestamp via nodeId | packages/universal/crdt/src/maps/lww-map/lww-map.ts |
L48-L54 | Genesis already implements this in LWWRegister; port the same comparison into LWWMap.set for cross-CRDT consistency |
Requirements with no source line
| Requirement | Justification |
|---|---|
Injectable Clock for timestamp generation in LWWMap.set / LWWMap.delete |
Genesis-wide CLAUDE.md determinism rule — no module-scope Date.now(). Upstream uses Date.now() directly; genesis takes a Clock parameter on the constructor (matches the pattern already established in @suaveplan/timing, @suaveplan/migration, @suaveplan/etl). |
CRDT_CODES.GSET_INVALID_ELEMENT and CRDT_CODES.LWWMAP_INVALID_KEY codes |
Genesis-wide policy that every thrown error route through @suaveplan/error (CLAUDE.md §Workflow rule 2). Upstream uses native throws; genesis adds typed codes for non-JSON-serializable input (e.g. BigInt keys in LWWMap, circular-reference values in GSet). |
type: "g-set" and type: "lww-map" state-envelope discriminators |
Genesis CRDT.fromJSON / CRDT.toJSON rely on a discriminator field for cross-CRDT serialisation. Upstream's CRDTEnvelope carries the type out-of-band; genesis inlines it for simpler serialisation. |
Subpath exports ./crdts/g-set/g-set and ./crdts/lww-map/lww-map |
Genesis-wide subpath-export rule (CLAUDE.md §6). Upstream ships a single barrel; genesis enforces tree-shakable per-feature subpaths. |
Verification approach
Each spec delta requirement carries a **Verified by:** line. The test files that will exist after this change ships:
packages/flow/crdt/src/crdts/g-set/g-set.test.ts—add,has,value,state,clone,toJSONround-trip; CRDT properties (idempotent / commutative / associativemerge) verified via property-based fixturespackages/flow/crdt/src/crdts/g-set/g-set.spec-conformance.test.ts— CRDT base interface conformance; type-level assertion thatGSet<T>isCRDT<Set<T>, GSetState<T>>; non-JSON-serializable element throwsCrdtError(GSET_INVALID_ELEMENT)packages/flow/crdt/src/crdts/lww-map/lww-map.test.ts—set,get,has,delete,keys,value,state,clone,toJSONround-trip; tombstone-respecting merge; tie-break by nodeId; concurrent set + delete resolution;reset()clears statepackages/flow/crdt/src/crdts/lww-map/lww-map.spec-conformance.test.ts— CRDT base interface conformance; injectableClockdeterministic; tombstone preserved across merge cycles; non-string key throwsCrdtError(LWWMAP_INVALID_KEY); CRDT properties (idempotent / commutative / associativemerge) verified via property-based fixturespackages/flow/crdt/src/errors/crdt-error.test.ts(extended) —GSET_INVALID_ELEMENTandLWWMAP_INVALID_KEYcodes registered
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/crdt to enumerate. Today grep -rl "@suaveplan/crdt" packages/ --include="package.json" returns only packages/flow/crdt/package.json itself, so this change is purely additive — no in-repo consumers exist yet.
@suaveplan/crdt(self) — [x] tests run / [x] docs reviewed / [ ] no action needed- (no in-repo dependents) — verified by
bunx turbo ls --filter=...@suaveplan/crdtreturning a single package.
If tests run is unchecked for any dependent, the change MUST NOT be folded into the canonical spec — finish that work first or split the proposal.
Migration impact
- All existing genesis CRDT consumers — no break.
GSetandLWWMapare net-new exports; the seven existing CRDTs are not touched. - Future consumers picking between set CRDTs — pick
GSetfor grow-only state; pickTwoPSetwhen remove is needed and re-adds are forbidden after remove; pickORSetwhen remove + concurrent re-add must both work (observed-remove semantics). LWWRegisterconsumers replicating per-key state — the migration target after this change ships isLWWMap. ReplacingMap<string, LWWRegister<V>>with a singleLWWMap<V>is mechanical — call sites change fromregister.set(value)tomap.set(key, value). Because no in-repo dependent currently does this, no consumer code needs to be touched in this change.