@lazily-hub/lazily-js 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,317 @@
1
+ // Fugue/RGA-style character CRDT (cell-model.md § Free-text CRDT + re-parse,
2
+ // #lztextcrdt). Each character is an element with a unique `OpId` (counter,
3
+ // peer) and a left-origin (`origin`); deletes are sticky tombstones carrying
4
+ // the delete op's own id. The visible sequence order is a pure, deterministic
5
+ // function of the element set: a pre-order DFS of the origin tree where
6
+ // siblings of the same origin are visited in DESCENDING `OpId` order
7
+ // (most-recent first). Merge is commutative, associative, idempotent;
8
+ // concurrent same-point inserts keep both, ordered by the peer tiebreak.
9
+
10
+ // Element identity = (counter, peer). Total order is (counter, peer) ascending.
11
+ export class OpId {
12
+ constructor(counter, peer) {
13
+ this.counter = counter;
14
+ this.peer = peer;
15
+ Object.freeze(this);
16
+ }
17
+ compareTo(other) {
18
+ if (this.counter !== other.counter) {
19
+ return this.counter < other.counter ? -1 : 1;
20
+ }
21
+ if (this.peer !== other.peer) {
22
+ return this.peer < other.peer ? -1 : 1;
23
+ }
24
+ return 0;
25
+ }
26
+ static desc(a, b) {
27
+ return b.compareTo(a); // descending
28
+ }
29
+ }
30
+
31
+ class Elem {
32
+ constructor(ch, origin, deleted = null) {
33
+ this.ch = ch;
34
+ this.origin = origin; // OpId | null (null = document start)
35
+ this.deleted = deleted; // OpId | null (the delete op id, or null = live)
36
+ }
37
+ }
38
+
39
+ export class TextCrdt {
40
+ #elems = new Map(); // OpId-keyed (serialized as "counter:peer")
41
+ #peer;
42
+ #counter = 0;
43
+
44
+ constructor(peer) {
45
+ this.#peer = peer;
46
+ }
47
+
48
+ static fromStr(peer, str) {
49
+ const crdt = new TextCrdt(peer);
50
+ crdt.insertStr(0, str);
51
+ return crdt;
52
+ }
53
+
54
+ // Deep-copy elems, adopt a new peer, COPY the counter (so ids stay unique).
55
+ fork(peer) {
56
+ const copy = new TextCrdt(peer);
57
+ copy.#counter = this.#counter;
58
+ for (const [key, elem] of this.#elems) {
59
+ copy.#elems.set(key, new Elem(elem.ch, elem.origin, elem.deleted));
60
+ }
61
+ return copy;
62
+ }
63
+
64
+ clone() {
65
+ return this.fork(this.#peer);
66
+ }
67
+
68
+ #nextId() {
69
+ this.#counter += 1; // pre-increment: first id has counter == 1
70
+ return new OpId(this.#counter, this.#peer);
71
+ }
72
+
73
+ #key(id) {
74
+ return `${id.counter}:${id.peer}`;
75
+ }
76
+
77
+ #orderedIds(includeDeleted) {
78
+ // Group by origin.
79
+ const children = new Map(); // originKey -> OpId[]
80
+ for (const [key, elem] of this.#elems) {
81
+ const originKey = elem.origin ? this.#key(elem.origin) : "<root>";
82
+ let list = children.get(originKey);
83
+ if (!list) {
84
+ list = [];
85
+ children.set(originKey, list);
86
+ }
87
+ // Recover the OpId from the map iteration key is not stored, so store it.
88
+ list.push(this.#idFromKey(key));
89
+ }
90
+ // Sort each sibling list DESCENDING by OpId.
91
+ for (const list of children.values()) {
92
+ list.sort(OpId.desc);
93
+ }
94
+ // Iterative pre-order DFS.
95
+ const out = [];
96
+ const roots = children.get("<root>") ?? [];
97
+ const stack = [...roots].sort(OpId.desc); // highest pops first
98
+ while (stack.length > 0) {
99
+ const id = stack.pop();
100
+ const elem = this.#elems.get(this.#key(id));
101
+ if (!elem) {
102
+ continue;
103
+ }
104
+ if (includeDeleted || elem.deleted === null) {
105
+ out.push(id);
106
+ }
107
+ const kids = children.get(this.#key(id));
108
+ if (kids) {
109
+ // Push reversed so the highest-OpId child pops first.
110
+ for (let i = kids.length - 1; i >= 0; i--) {
111
+ stack.push(kids[i]);
112
+ }
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ #idFromKey(key) {
119
+ const [counter, peer] = key.split(":");
120
+ return new OpId(Number(counter), Number(peer));
121
+ }
122
+
123
+ insert(index, ch) {
124
+ const visible = this.#orderedIds(false);
125
+ const origin = index === 0 ? null : visible[index - 1] ?? null;
126
+ const id = this.#nextId();
127
+ this.#elems.set(this.#key(id), new Elem(ch, origin, null));
128
+ }
129
+
130
+ insertStr(index, str) {
131
+ let i = 0;
132
+ for (const ch of String(str)) {
133
+ this.insert(index + i, ch);
134
+ i++;
135
+ }
136
+ }
137
+
138
+ delete(index) {
139
+ const visible = this.#orderedIds(false);
140
+ const id = visible[index];
141
+ if (id === undefined) {
142
+ return; // no-op if out of range
143
+ }
144
+ const del = this.#nextId(); // always advance the clock (matches lazily-rs)
145
+ const elem = this.#elems.get(this.#key(id));
146
+ if (elem && elem.deleted === null) {
147
+ elem.deleted = del;
148
+ }
149
+ }
150
+
151
+ text() {
152
+ let out = "";
153
+ for (const id of this.#orderedIds(false)) {
154
+ out += this.#elems.get(this.#key(id)).ch;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // Count of live (non-tombstoned) elems over the whole map.
160
+ len() {
161
+ let count = 0;
162
+ for (const elem of this.#elems.values()) {
163
+ if (elem.deleted === null) {
164
+ count++;
165
+ }
166
+ }
167
+ return count;
168
+ }
169
+
170
+ is_empty() {
171
+ return this.len() === 0;
172
+ }
173
+
174
+ tombstoneCount() {
175
+ let count = 0;
176
+ for (const elem of this.#elems.values()) {
177
+ if (elem.deleted !== null) {
178
+ count++;
179
+ }
180
+ }
181
+ return count;
182
+ }
183
+
184
+ clock() {
185
+ return new OpId(this.#counter, this.#peer);
186
+ }
187
+
188
+ // State-based merge (whole-replica input). Commutative, associative,
189
+ // idempotent. Tombstones are sticky: any Some wins; concurrent deletes take
190
+ // the smaller delete id. Returns whether visible text changed.
191
+ merge(other) {
192
+ const before = this.text();
193
+ for (const [key, oe] of other.#elems) {
194
+ const id = other.#idFromKey(key);
195
+ this.#counter = Math.max(this.#counter, id.counter);
196
+ if (oe.deleted) {
197
+ this.#counter = Math.max(this.#counter, oe.deleted.counter);
198
+ }
199
+ const existing = this.#elems.get(key);
200
+ if (existing) {
201
+ if (existing.deleted && oe.deleted) {
202
+ existing.deleted =
203
+ existing.deleted.compareTo(oe.deleted) <= 0
204
+ ? existing.deleted
205
+ : oe.deleted;
206
+ } else if (oe.deleted) {
207
+ existing.deleted = oe.deleted;
208
+ }
209
+ } else {
210
+ this.#elems.set(key, new Elem(oe.ch, oe.origin, oe.deleted));
211
+ }
212
+ }
213
+ return this.text() !== before;
214
+ }
215
+
216
+ // Tombstone GC: collect a stable deleted element only when nothing references
217
+ // it as a left origin. Bottom-up: pass 1 collects unreferenced leaf
218
+ // tombstones, removing a leaf un-references its origin, so further passes
219
+ // collect interior tombstones until fixpoint. `isStable` is invoked on the
220
+ // DELETE op id (caller-supplied causal-stability policy).
221
+ gcWith(isStable) {
222
+ let removed = 0;
223
+ while (true) {
224
+ const referenced = new Set();
225
+ for (const elem of this.#elems.values()) {
226
+ if (elem.origin) {
227
+ referenced.add(this.#key(elem.origin));
228
+ }
229
+ }
230
+ const collectable = [];
231
+ for (const [key, elem] of this.#elems) {
232
+ if (elem.deleted !== null && isStable(elem.deleted) && !referenced.has(key)) {
233
+ collectable.push(key);
234
+ }
235
+ }
236
+ if (collectable.length === 0) {
237
+ break;
238
+ }
239
+ for (const key of collectable) {
240
+ this.#elems.delete(key);
241
+ removed++;
242
+ }
243
+ }
244
+ return removed;
245
+ }
246
+
247
+ // --- Delta sync (#lztextsync) ---
248
+
249
+ // Version vector: {peer -> greatest counter seen} over inserts + deletes. The
250
+ // compact frontier a replica sends so a partner can compute the ops it lacks.
251
+ versionVector() {
252
+ const vv = {};
253
+ const bump = (id) => {
254
+ vv[id.peer] = Math.max(vv[id.peer] ?? 0, id.counter);
255
+ };
256
+ for (const [key, elem] of this.#elems) {
257
+ bump(this.#idFromKey(key));
258
+ if (elem.deleted) {
259
+ bump(elem.deleted);
260
+ }
261
+ }
262
+ return vv;
263
+ }
264
+
265
+ // The ops `theirVv` has not observed — new inserts + newly-observed tombstones.
266
+ // Each TextOp is a plain {id, ch, origin, deleted} of {counter, peer} ids. A
267
+ // whole-state snapshot is `deltaSince({})`.
268
+ deltaSince(theirVv) {
269
+ const seen = (id) => id.counter <= (theirVv[id.peer] ?? 0);
270
+ const wire = (id) => (id ? { counter: id.counter, peer: id.peer } : null);
271
+ const out = [];
272
+ for (const [key, elem] of this.#elems) {
273
+ const id = this.#idFromKey(key);
274
+ const insertNew = !seen(id);
275
+ const deleteNew = elem.deleted !== null && !seen(elem.deleted);
276
+ if (insertNew || deleteNew) {
277
+ out.push({
278
+ id: wire(id),
279
+ ch: elem.ch,
280
+ origin: wire(elem.origin),
281
+ deleted: wire(elem.deleted),
282
+ });
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // Apply a delta op list (from `deltaSince`). Commutative, associative,
289
+ // idempotent — the same convergence contract as `merge`, from the transport
290
+ // form. Rebuilding a replica via `applyDelta` preserves OpId identity so later
291
+ // concurrent edits merge without duplication. Returns whether text changed.
292
+ applyDelta(ops) {
293
+ const before = this.text();
294
+ for (const op of ops) {
295
+ const id = new OpId(op.id.counter, op.id.peer);
296
+ const origin = op.origin ? new OpId(op.origin.counter, op.origin.peer) : null;
297
+ const deleted = op.deleted ? new OpId(op.deleted.counter, op.deleted.peer) : null;
298
+ this.#counter = Math.max(this.#counter, id.counter);
299
+ if (deleted) {
300
+ this.#counter = Math.max(this.#counter, deleted.counter);
301
+ }
302
+ const key = this.#key(id);
303
+ const existing = this.#elems.get(key);
304
+ if (existing) {
305
+ if (existing.deleted && deleted) {
306
+ existing.deleted =
307
+ existing.deleted.compareTo(deleted) <= 0 ? existing.deleted : deleted;
308
+ } else if (deleted) {
309
+ existing.deleted = deleted;
310
+ }
311
+ } else {
312
+ this.#elems.set(key, new Elem(op.ch, origin, deleted));
313
+ }
314
+ }
315
+ return this.text() !== before;
316
+ }
317
+ }