A world-class math input for the web
1export abstract class CRDT<V> {
2 abstract merge(other: CRDT<V>): CRDT<V>;
3 abstract getValue(): V;
4 abstract get DEBUG_data(): unknown;
5 // abstract setActorId(actorId: ActorID): CRDT<V>;
6}
7
8export type ActorID = string & { __brand: "ActorID" };
9export function createActorID(id: string): ActorID {
10 return id as ActorID;
11}
12
13type DotHash = string & { __brand: "DotHash" };
14
15export class Dot {
16 actorId: ActorID;
17 counter: number;
18
19 constructor(actorId: ActorID, counter: number) {
20 this.actorId = actorId;
21 this.counter = counter;
22 }
23
24 toString(): string {
25 return `${this.actorId}:${this.counter}`;
26 }
27
28 hash(): DotHash {
29 // TODO: Different/better hash function?
30 return btoa(`${this.actorId}:${this.counter}`) as DotHash;
31 }
32
33 equals(other: Dot): boolean {
34 return this.actorId === other.actorId && this.counter === other.counter;
35 }
36
37 compare(other: Dot): number {
38 if (this.counter !== other.counter) {
39 return this.counter - other.counter;
40 }
41 if (this.actorId < other.actorId) {
42 return -1;
43 }
44 if (this.actorId > other.actorId) {
45 return 1;
46 }
47 return 0;
48 }
49
50 static equals(a: Dot | null, b: Dot | null): boolean {
51 if (a === null || b === null) {
52 return a === b;
53 }
54 return a.equals(b);
55 }
56}
57
58export class GrowCounterCRDT extends CRDT<number> {
59 private data: Map<ActorID, number>;
60
61 constructor(data = new Map<ActorID, number>()) {
62 super();
63 this.data = data;
64 }
65
66 get DEBUG_data(): Map<ActorID, number> {
67 return this.data;
68 }
69
70 increment(actorId: ActorID, amount: number = 1): GrowCounterCRDT {
71 if (amount <= 0) {
72 throw new Error("Increment amount must be positive");
73 }
74 const current = this.data.get(actorId) || 0;
75
76 let newData = new Map(this.data);
77 newData.set(actorId, current + amount);
78 return new GrowCounterCRDT(newData);
79 }
80
81 getValue(): number {
82 let total = 0;
83 for (const value of this.data.values()) {
84 total += value;
85 }
86 return total;
87 }
88
89 merge(other: GrowCounterCRDT): GrowCounterCRDT {
90 const mergedData = new Map<ActorID, number>(this.data);
91 for (const [actorId, value] of other.data.entries()) {
92 const current = mergedData.get(actorId) ?? 0;
93 mergedData.set(actorId, Math.max(current, value));
94 }
95
96 return new GrowCounterCRDT(mergedData);
97 }
98}
99
100export class PNCounterCRDT extends CRDT<number> {
101 private positive: GrowCounterCRDT;
102 private negative: GrowCounterCRDT;
103
104 constructor(
105 positive: GrowCounterCRDT = new GrowCounterCRDT(),
106 negative: GrowCounterCRDT = new GrowCounterCRDT(),
107 ) {
108 super();
109 this.positive = positive;
110 this.negative = negative;
111 }
112
113 get DEBUG_data(): {
114 positive: Map<ActorID, number>;
115 negative: Map<ActorID, number>;
116 } {
117 return {
118 positive: this.positive.DEBUG_data,
119 negative: this.negative.DEBUG_data,
120 };
121 }
122
123 change(actorId: ActorID, amount: number): PNCounterCRDT {
124 if (amount > 0) {
125 return new PNCounterCRDT(
126 this.positive.increment(actorId, amount),
127 this.negative,
128 );
129 } else if (amount < 0) {
130 return new PNCounterCRDT(
131 this.positive,
132 this.negative.increment(actorId, -amount),
133 );
134 } else {
135 return this;
136 }
137 }
138
139 set(actorId: ActorID, value: number): PNCounterCRDT {
140 const currentValue = this.getValue();
141 const delta = value - currentValue;
142 return this.change(actorId, delta);
143 }
144
145 getValue(): number {
146 return this.positive.getValue() - this.negative.getValue();
147 }
148
149 merge(other: PNCounterCRDT): PNCounterCRDT {
150 return new PNCounterCRDT(
151 this.positive.merge(other.positive),
152 this.negative.merge(other.negative),
153 );
154 }
155}
156
157export class GrowSetCRDT<T> extends CRDT<Set<T>> {
158 private data: Set<T>;
159
160 constructor(data: Set<T> = new Set<T>()) {
161 super();
162 this.data = data;
163 }
164
165 get DEBUG_data(): Set<T> {
166 return this.data;
167 }
168
169 add(element: T): GrowSetCRDT<T> {
170 const newData = new Set(this.data);
171 newData.add(element);
172 return new GrowSetCRDT(newData);
173 }
174
175 getValue(): Set<T> {
176 return new Set(this.data);
177 }
178
179 merge(other: GrowSetCRDT<T>): GrowSetCRDT<T> {
180 const mergedData = new Set<T>(this.data);
181 for (const element of other.data) {
182 mergedData.add(element);
183 }
184 return new GrowSetCRDT(mergedData);
185 }
186}
187
188export class SetCRDT<T> extends CRDT<Set<T>> {
189 private addSet: GrowSetCRDT<T>;
190 private removeSet: GrowSetCRDT<T>;
191
192 constructor(
193 addSet: GrowSetCRDT<T> = new GrowSetCRDT<T>(),
194 removeSet: GrowSetCRDT<T> = new GrowSetCRDT<T>(),
195 ) {
196 super();
197 this.addSet = addSet;
198 this.removeSet = removeSet;
199 }
200
201 get DEBUG_data(): { addSet: Set<T>; removeSet: Set<T> } {
202 return {
203 addSet: this.addSet.DEBUG_data,
204 removeSet: this.removeSet.DEBUG_data,
205 };
206 }
207
208 add(element: T): SetCRDT<T> {
209 return new SetCRDT(this.addSet.add(element), this.removeSet);
210 }
211
212 remove(element: T): SetCRDT<T> {
213 return new SetCRDT(this.addSet, this.removeSet.add(element));
214 }
215
216 getValue(): Set<T> {
217 const result = new Set<T>();
218 for (const element of this.addSet.getValue()) {
219 if (!this.removeSet.getValue().has(element)) {
220 result.add(element);
221 }
222 }
223 return result;
224 }
225
226 merge(other: SetCRDT<T>): SetCRDT<T> {
227 return new SetCRDT(
228 this.addSet.merge(other.addSet),
229 this.removeSet.merge(other.removeSet),
230 );
231 }
232}
233
234export class LWWRegisterCRDT<T> extends CRDT<T> {
235 private data: { value: T; dot: Dot };
236
237 constructor(value: T, dot: Dot) {
238 super();
239 this.data = { value, dot };
240 }
241
242 get DEBUG_data() {
243 return this.data;
244 }
245
246 set(actorId: ActorID, value: T): LWWRegisterCRDT<T> {
247 const newCounter = this.data.dot.counter + 1;
248 return new LWWRegisterCRDT<T>(value, new Dot(actorId, newCounter));
249 }
250
251 getValue(): T {
252 return this.data.value;
253 }
254
255 merge(other: LWWRegisterCRDT<T>): LWWRegisterCRDT<T> {
256 const compare = other.data.dot.compare(this.data.dot);
257
258 if (compare === 0) {
259 if (this.data.value === other.data.value) {
260 return this;
261 } else {
262 throw new Error("Conflict detected in LWWRegisterCRDT merge");
263 }
264 } else if (compare > 0) {
265 return other;
266 } else {
267 return this;
268 }
269 }
270}
271
272export class LWWMapCRDT<K, V> extends CRDT<Map<K, V>> {
273 private data: Map<K, LWWRegisterCRDT<V>>;
274
275 constructor(data: Map<K, LWWRegisterCRDT<V>> = new Map()) {
276 super();
277 this.data = data;
278 }
279
280 get DEBUG_data(): Map<K, { value: V; dot: Dot }> {
281 const debugData = new Map<K, { value: V; dot: Dot }>();
282 for (const [key, register] of this.data.entries()) {
283 debugData.set(key, register.DEBUG_data);
284 }
285 return debugData;
286 }
287
288 set(actorId: ActorID, key: K, value: V): LWWMapCRDT<K, V> {
289 const newData = new Map(this.data);
290 const existingRegister = newData.get(key);
291 if (existingRegister) {
292 newData.set(key, existingRegister.set(actorId, value));
293 } else {
294 newData.set(key, new LWWRegisterCRDT<V>(value, new Dot(actorId, 1)));
295 }
296 return new LWWMapCRDT(newData);
297 }
298
299 getRegister(key: K): LWWRegisterCRDT<V> | undefined {
300 return this.data.get(key);
301 }
302
303 getValue(): Map<K, V> {
304 const result = new Map<K, V>();
305 for (const [key, register] of this.data.entries()) {
306 result.set(key, register.getValue());
307 }
308 return result;
309 }
310
311 merge(other: LWWMapCRDT<K, V>): LWWMapCRDT<K, V> {
312 const mergedData = new Map<K, LWWRegisterCRDT<V>>(this.data);
313 for (const [key, otherRegister] of other.data.entries()) {
314 const existingRegister = mergedData.get(key);
315 if (existingRegister) {
316 mergedData.set(key, existingRegister.merge(otherRegister));
317 } else {
318 mergedData.set(key, otherRegister);
319 }
320 }
321 return new LWWMapCRDT(mergedData);
322 }
323}
324
325class DotIdSet<V extends { id: Dot }> {
326 private data: Map<DotHash, V>;
327 constructor(data: Map<DotHash, V> | DotIdSet<V> = new Map<DotHash, V>()) {
328 this.data = data instanceof DotIdSet ? new Map(data.data) : new Map(data);
329 }
330
331 add(element: V): void {
332 this.data.set(element.id.hash(), element);
333 }
334
335 get(id: Dot): V | undefined {
336 return this.data.get(id.hash());
337 }
338
339 values(): IterableIterator<V> {
340 return this.data.values();
341 }
342
343 delete(id: Dot): void {
344 this.data.delete(id.hash());
345 }
346}
347
348export class SequenceCRDT<V, C extends CRDT<V>> extends CRDT<
349 { id: Dot; value: V }[]
350> {
351 private counter: number;
352 private data: DotIdSet<{
353 id: Dot;
354 left: LWWRegisterCRDT<Dot | null>;
355 deleted: boolean;
356 value: C;
357 }>;
358
359 constructor(
360 counter: number = 0,
361 data: DotIdSet<{
362 id: Dot;
363 left: LWWRegisterCRDT<Dot | null>;
364 deleted: boolean;
365 value: C;
366 }> = new DotIdSet(),
367 ) {
368 super();
369 this.counter = counter;
370 this.data = data;
371 }
372
373 get DEBUG_data() {
374 return this.data;
375 }
376
377 insert(
378 actorId: ActorID,
379 leftId: Dot | null,
380 value: C,
381 ): [SequenceCRDT<V, C>, Dot | null] {
382 const elementId = new Dot(actorId, this.counter + 1);
383
384 const newData = new DotIdSet(this.data);
385 newData.add({
386 id: elementId,
387 left: new LWWRegisterCRDT<Dot | null>(leftId, new Dot(actorId, 0)),
388 deleted: false,
389 value,
390 });
391
392 // Any existing elements that had leftId as their left should now point to the new element
393 for (const element of this.data.values()) {
394 const elemLeft = element.left.getValue();
395 if (Dot.equals(elemLeft, leftId)) {
396 newData.delete(element.id);
397 newData.add({
398 id: element.id,
399 left: element.left.set(actorId, elementId),
400 deleted: element.deleted,
401 value: element.value,
402 });
403 }
404 }
405
406 return [new SequenceCRDT(this.counter + 1, newData), elementId];
407 }
408
409 setItemValue(
410 actorId: ActorID,
411 id: Dot,
412 value: C | ((oldValue: C) => C),
413 ): SequenceCRDT<V, C> {
414 const newData = new DotIdSet(this.data);
415 for (const element of this.data.values()) {
416 if (element.id.equals(id)) {
417 newData.delete(element.id);
418 newData.add({
419 id: element.id,
420 left: element.left,
421 deleted: element.deleted,
422 value: typeof value === "function" ? value(element.value) : value,
423 });
424 break;
425 }
426 }
427 return new SequenceCRDT(this.counter, newData);
428 }
429
430 delete(actorId: ActorID, id: Dot): SequenceCRDT<V, C> {
431 const newData = new DotIdSet(this.data);
432 let deletedElement: {
433 id: Dot;
434 left: LWWRegisterCRDT<Dot | null>;
435 deleted: boolean;
436 value: CRDT<unknown>;
437 } | null = null;
438
439 for (const element of this.data.values()) {
440 if (element.id.equals(id)) {
441 if (element.deleted) return this; // Already deleted
442
443 deletedElement = element;
444 newData.delete(element.id);
445 newData.add({
446 id: element.id,
447 left: element.left,
448 deleted: true,
449 value: element.value,
450 });
451 break;
452 }
453 }
454
455 if (deletedElement) {
456 const deletedLeftId = deletedElement.left.getValue();
457 // Reassign right siblings to point to the deleted element's left
458 for (const element of this.data.values()) {
459 const elemLeft = element.left.getValue();
460 if (Dot.equals(elemLeft, deletedElement.id)) {
461 newData.delete(element.id);
462 newData.add({
463 id: element.id,
464 left: element.left.set(actorId, deletedLeftId),
465 deleted: element.deleted,
466 value: element.value,
467 });
468 }
469 }
470 }
471
472 return new SequenceCRDT(this.counter, newData);
473 }
474
475 getValue(): {
476 id: Dot;
477 DEBUG_left: Dot | null;
478 value: V;
479 }[] {
480 const getElementsWithLeftId = (leftId: Dot | null) => {
481 return new Set(
482 [...this.data.values()].filter((element) => {
483 const elementLeft = element.left.getValue();
484 if (leftId === null || elementLeft === null) {
485 return leftId === elementLeft;
486 }
487 return elementLeft.equals(leftId);
488 }),
489 );
490 };
491
492 const result: {
493 id: Dot;
494 DEBUG_left: Dot | null;
495 value: V;
496 }[] = [];
497 const visited = new Set<Dot>();
498
499 const isVisited = (id: Dot) => {
500 for (const visitedId of visited) {
501 if (Dot.equals(visitedId, id)) return true;
502 }
503 return false;
504 };
505
506 const appendRightSiblings = (leftId: Dot | null) => {
507 const siblings = getElementsWithLeftId(leftId);
508 const sortedSiblings = Array.from(siblings).sort((a, b) =>
509 a.id.compare(b.id),
510 );
511 for (const sibling of sortedSiblings) {
512 if (!isVisited(sibling.id)) {
513 if (!sibling.deleted) {
514 result.push({
515 id: sibling.id,
516 DEBUG_left: sibling.left.getValue(),
517 value: sibling.value.getValue(),
518 });
519 }
520 visited.add(sibling.id);
521 appendRightSiblings(sibling.id);
522 }
523 }
524 };
525
526 appendRightSiblings(null);
527
528 return result;
529 }
530
531 getValueWithCursor(
532 cursor: Dot | null,
533 ): [{ id: Dot; DEBUG_left: Dot | null; value: V }[], number] {
534 const elements = this.getValue();
535 if (cursor === null) {
536 return [elements, 0];
537 }
538
539 let notDeletedLeftId: Dot = cursor;
540 const getElementById = (id: Dot) => {
541 for (const element of this.data.values()) {
542 if (element.id.equals(id)) return element;
543 }
544 return null;
545 };
546 while (getElementById(notDeletedLeftId)?.deleted) {
547 const leftOfDeleted = getElementById(notDeletedLeftId)?.left.getValue();
548 if (leftOfDeleted === undefined) {
549 throw new Error("leftId points to non-existent element");
550 }
551 if (leftOfDeleted === null) {
552 return [elements, 0];
553 }
554 notDeletedLeftId = leftOfDeleted;
555 }
556
557 let index = 0;
558 for (const element of elements) {
559 if (element.id.equals(notDeletedLeftId)) {
560 return [elements, index + 1];
561 }
562 index++;
563 }
564
565 throw new Error("Cursor not found in sequence");
566 }
567
568 merge(other: SequenceCRDT<V, C>): SequenceCRDT<V, C> {
569 const mergedData = new DotIdSet(this.data);
570 for (const otherElement of other.data.values()) {
571 // Find if this element already exists
572 let exists = false;
573 for (const existingElement of mergedData.values()) {
574 if (existingElement.id.equals(otherElement.id)) {
575 // Merge the LWWMapCRDT values
576 const mergedValue = existingElement.value.merge(otherElement.value);
577 const mergedLeft = existingElement.left.merge(otherElement.left);
578 mergedData.delete(existingElement.id);
579 mergedData.add({
580 id: existingElement.id,
581 left: mergedLeft,
582 deleted: existingElement.deleted || otherElement.deleted,
583 value: mergedValue as C,
584 });
585 exists = true;
586 break;
587 }
588 }
589 if (!exists) {
590 mergedData.add({
591 id: otherElement.id,
592 left: otherElement.left,
593 deleted: otherElement.deleted,
594 value: otherElement.value,
595 });
596 }
597 }
598 return new SequenceCRDT(Math.max(this.counter, other.counter), mergedData);
599 }
600
601 setActorId(actorId: ActorID): SequenceCRDT<V, C> {
602 const newData = new DotIdSet<{
603 id: Dot;
604 left: LWWRegisterCRDT<Dot | null>;
605 deleted: boolean;
606 value: C;
607 }>();
608 for (const element of this.data.values()) {
609 newData.add({
610 id: new Dot(actorId, element.id.counter),
611 left: element.left,
612 deleted: element.deleted,
613 value: element.value,
614 });
615 }
616 return new SequenceCRDT(this.counter, newData);
617 }
618}