A PN-Counter is a conflict-free replicated data type (CRDT): a counter that any number of replicas can increment and decrement on their own, then merge in any order to converge on the exact same total. It solves a problem a plain number cannot — when several servers each accept writes with no central coordinator (a like count, a stock tally, a live-viewer number), you have to reconcile their edits later without losing any and without double-counting. You build it from two grow-only maps: P records how much each node has incremented, N how much each node has decremented, and the value is their difference.
type State = { P: Record<string, number>; N: Record<string, number> };
type Counter = {
increment(n?: number): Counter; // add n (default 1) to this node's increments
decrement(n?: number): Counter; // add n (default 1) to this node's decrements
value(): number; // sum(P) - sum(N)
merge(other: Counter | State): Counter; // fold in another replica by per-node max
state(): State; // a serializable { P, N } snapshot
};
function gPnCounterCrdt(nodeId: string): Counter;
const c = gPnCounterCrdt('node-a');
c.increment(); // +1
c.increment(4); // +4
c.decrement(2); // -2
c.value(); // 3
const a = gPnCounterCrdt('a');
const b = gPnCounterCrdt('b');
a.increment(3); // a only knows its own +3
b.increment(5); // b only knows its own +5
a.merge(b); // a folds in b by per-node maximum
a.value(); // 8 — both increments counted, neither lost
a.merge(b); // the same state redelivered...
a.value(); // 8 — merge is idempotent
P and one in N, keyed by its nodeId. A replica writes only its own slot; every other slot arrives through merge.P, then subtract every node's decrements in N. Both maps only ever grow.decrement (which grows N); you never subtract from P. That grow-only shape is what lets replicas merge safely.nodeId in either replica, keep the bigger P value and the bigger N value. This per-node maximum is what makes the merge convergent, commutative, and idempotent.nodeId into the factory (a host name or uuid in production). Never key a slot by Math.random() or a timestamp, or two replicas could clobber each other.