Sets (GSet, ORSet)
Two set CRDTs:
| Type | Operations | When |
|---|---|---|
GSet<E> | add only | Append-only collections — observed kinds, seen users. |
ORSet<E> | add + remove | Membership sets that come and go. |
Both converge under concurrent writes; ORSet’s special trick is
resolving “concurrent add and remove of the same element” so
removes don’t accidentally delete a still-wanted item.
import { GSet } from 'actor-ts';
let s = GSet.empty<string>();s = s.add('apple');s = s.add('banana');s.value(); // → Set(['apple', 'banana'])State is a plain set; merge is a union. Cannot remove — once added, an element stays forever.
Use for:
- Observed event types the system has ever seen.
- Users that ever logged in (a
unique-users-everset). - Tags applied to a resource that don’t get untagged.
If you need removals, GSet is the wrong shape; reach for
ORSet.
Element identity
Section titled “Element identity”let s = GSet.empty<UserSession>({ identity: (u) => u.id });s = s.add({ id: 'user-42', name: 'Alice' });s = s.add({ id: 'user-42', name: 'Alice updated' });s.value().size; // → 1 (same identity)By default elements are deduplicated by JSON.stringify(element).
Pass an identity: (e) => string function for custom dedup —
useful when elements are objects with stable IDs but mutable
other fields.
ORSet (Observed-Remove Set)
Section titled “ORSet (Observed-Remove Set)”import { ORSet } from 'actor-ts';
let s = ORSet.empty<string>();s = s.add('node-a', 'apple');s = s.add('node-b', 'banana');s = s.remove('apple');s.value(); // → Set(['banana'])The trick: every add stamps the element with a unique tag;
remove(e) removes only the tags currently visible to the
removing replica.
This makes the “concurrent add + remove” case resolve toward add:
let a0 = ORSet.empty<string>().add('a', 'apple');let b0 = a0; // both see 'apple' with same tag
let a1 = a0.remove('apple'); // A removeslet b1 = b0.add('b', 'apple'); // B adds a NEW 'apple' (new tag)
a1.merge(b1).value(); // → Set(['apple']) — B's add survivesThe original ‘apple’ had the tag B never saw, so B’s fresh add introduces a new tag. When A and B merge, A’s remove only removed the original tag; B’s tag remains. Net result: ‘apple’ is in the set.
This “add wins” semantics is what makes ORSet the right choice for shopping carts, presence sets, subscription lists — sets where an item briefly removed by one party shouldn’t disappear if another party concurrently re-added it.
Tag generation
Section titled “Tag generation”Tags are replicaId × per-replica-counter. Each replica keeps a
monotonic counter; every add bumps it. This guarantees unique
tags even within the same millisecond, across the cluster.
The counter is part of the CRDT state — gossiping is what makes it correct. You don’t see or manipulate tags directly; they’re internal.
Element identity (same as GSet)
Section titled “Element identity (same as GSet)”const s = ORSet.empty<CartItem>({ identity: (i) => i.sku });s.add('node-a', { sku: 'book-1', price: 10 });s.add('node-b', { sku: 'book-1', price: 12 });// → still one entry (same SKU), but two tags// — the tags are independent of the value's identityThe identity function determines deduplication; the tag mechanics work per-add regardless.
Picking between them
Section titled “Picking between them”| Question | Type |
|---|---|
| Do you ever need to remove elements? | ORSet |
| Otherwise | GSet |
GSet is smaller (no tag bookkeeping) and simpler. Use
when “remove an item” isn’t part of the workflow.
ORSet’s extra cost is the tag tracking — for sets with high
churn (frequent add/remove cycles on the same element), the
internal state grows. See “When ORSet isn’t right” below.
When ORSet isn’t right
Section titled “When ORSet isn’t right”Where to next
Section titled “Where to next”- Counters — for additive counts.
- Registers — for single-value writes.
- Maps — for keyed associations including ORMap.
- Designing data — the conceptual guide.