@lazily-hub/lazily-js 0.20.0 → 0.21.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.
package/BENCHMARKS.md CHANGED
@@ -19,7 +19,7 @@ like-for-like single-process counterpart here and are intentionally omitted.
19
19
 
20
20
  <!-- benchmark-results:start -->
21
21
 
22
- Generated for package `@lazily-hub/lazily-js` version `0.19.0`.
22
+ Generated for package `@lazily-hub/lazily-js` version `0.20.0`.
23
23
 
24
24
  Environment: Node.js `26.4.0` on `linux x64`.
25
25
 
@@ -33,16 +33,34 @@ Mean wall-clock time per iteration; 95% CI half-width from the standard error.
33
33
 
34
34
  | Group | Case | Mean | 95% CI | p75 | p99 | Samples |
35
35
  |---|---|---:|---:|---:|---:|---:|
36
- | cached_reads | context | 31.707 ns | ± 1.826 ns | 29.867 ns | 78.222 ns | 100 |
37
- | cold_first_get | context | 698.810 ns | ± 46.591 ns | 600.000 ns | 1.410 us | 100 |
38
- | dependency_fan_out | context / 32 | 5.263 us | ± 858.863 ns | 4.753 us | 16.289 us | 100 |
39
- | dependency_fan_out | context / 256 | 47.575 us | ± 3.908 us | 45.595 us | 132.218 us | 100 |
40
- | set_cell_invalidation | high_fan_out / 512 | 6.692 us | ± 3.311 us | 5.840 us | 111.143 us | 100 |
41
- | memo_equality_suppression | context | 2.546 us | ± 256.417 ns | 2.553 us | 5.435 us | 100 |
42
- | effect_flushing | context | 129.166 ns | ± 11.208 ns | 131.217 ns | 406.377 ns | 100 |
43
- | batch_storms | context / 64 | 12.092 us | ± 123.522 ns | 12.286 us | 14.168 us | 100 |
44
- | typed_cache_reads | context_cell | 33.701 ns | ± 2.005 ns | 32.755 ns | 55.480 ns | 100 |
45
- | typed_cache_reads | context_slot | 42.440 ns | ± 2.855 ns | 40.074 ns | 106.268 ns | 100 |
36
+ | cached_reads | context | 31.966 ns | ± 1.575 ns | 30.743 ns | 70.839 ns | 100 |
37
+ | cold_first_get | context | 742.710 ns | ± 126.218 ns | 612.500 ns | 1.899 us | 100 |
38
+ | dependency_fan_out | context / 32 | 6.129 us | ± 1.563 us | 5.023 us | 41.656 us | 100 |
39
+ | dependency_fan_out | context / 256 | 48.997 us | ± 3.461 us | 47.868 us | 130.607 us | 100 |
40
+ | set_cell_invalidation | high_fan_out / 512 | 5.482 us | ± 1.327 us | 6.410 us | 18.184 us | 100 |
41
+ | memo_equality_suppression | context | 2.191 us | ± 356.052 ns | 3.813 us | 4.961 us | 100 |
42
+ | effect_flushing | context | 149.841 ns | ± 15.108 ns | 148.815 ns | 452.666 ns | 100 |
43
+ | batch_storms | context / 64 | 13.088 us | ± 281.500 ns | 13.352 us | 17.526 us | 100 |
44
+ | typed_cache_reads | context_cell | 29.928 ns | ± 0.847 ns | 29.585 ns | 46.606 ns | 100 |
45
+ | typed_cache_reads | context_slot | 46.437 ns | ± 5.381 ns | 39.915 ns | 179.573 ns | 100 |
46
+ | textcrdt_insert_str | 64 | 8.939 us | ± 2.581 us | 7.678 us | 50.727 us | 100 |
47
+ | textcrdt_insert_str | 256 | 28.383 us | ± 1.991 us | 25.568 us | 63.137 us | 100 |
48
+ | textcrdt_insert_str | 1024 | 111.611 us | ± 6.489 us | 124.136 us | 205.387 us | 100 |
49
+ | textcrdt_repeated_text | 64 | 40.149 us | ± 2.146 us | 36.146 us | 74.301 us | 100 |
50
+ | textcrdt_repeated_text | 256 | 181.856 us | ± 13.068 us | 187.465 us | 331.261 us | 100 |
51
+ | textcrdt_repeated_text | 1024 | 864.585 us | ± 45.266 us | 996.343 us | 1.519 ms | 100 |
52
+ | textcrdt_merge | 64 | 12.103 us | ± 798.827 ns | 12.955 us | 24.361 us | 100 |
53
+ | textcrdt_merge | 256 | 53.192 us | ± 4.002 us | 52.265 us | 143.726 us | 100 |
54
+ | textcrdt_merge | 1024 | 270.364 us | ± 17.669 us | 268.422 us | 593.144 us | 100 |
55
+ | textcrdt_delta_sync | 64 | 19.106 us | ± 1.372 us | 20.792 us | 29.232 us | 100 |
56
+ | textcrdt_delta_sync | 256 | 67.860 us | ± 5.469 us | 62.273 us | 196.534 us | 100 |
57
+ | textcrdt_delta_sync | 1024 | 262.078 us | ± 11.701 us | 257.410 us | 473.407 us | 100 |
58
+ | seqcrdt_insert_back | 64 | 82.803 us | ± 7.276 us | 79.240 us | 226.953 us | 100 |
59
+ | seqcrdt_insert_back | 256 | 1.561 ms | ± 57.393 us | 1.724 ms | 2.313 ms | 100 |
60
+ | seqcrdt_insert_back | 1024 | 43.643 ms | ± 971.721 us | 44.688 ms | 50.468 ms | 20 |
61
+ | seqcrdt_merge | 64 | 7.361 us | ± 624.875 ns | 8.053 us | 19.173 us | 100 |
62
+ | seqcrdt_merge | 256 | 26.977 us | ± 2.251 us | 30.373 us | 73.848 us | 100 |
63
+ | seqcrdt_merge | 1024 | 113.428 us | ± 6.981 us | 121.168 us | 252.144 us | 100 |
46
64
 
47
65
  <!-- benchmark-results:end -->
48
66
 
@@ -0,0 +1,129 @@
1
+ // CRDT-plane benchmarks for lazily-js — TextCrdt and SeqCrdt hot paths.
2
+ //
3
+ // The reactive-core benches (`context.bench.mjs`) do not cover the CRDT plane;
4
+ // the Phase 1 plan (#lztextordcache, #lztextinsertchain, #lzopidkeytuple,
5
+ // #lzseqstringifyeq) needed a baseline + regression target. Mirrors the
6
+ // `bench/harness.mjs` criterion-style API used by `context.bench.mjs` so the
7
+ // output table is directly comparable.
8
+ //
9
+ // Run: `node bench/crdt.bench.mjs`
10
+
11
+ import { bench, blackBox, run } from "./harness.mjs";
12
+ import { TextCrdt } from "../src/text-crdt.js";
13
+ import { SeqCrdt } from "../src/seq-crdt.js";
14
+
15
+ const INSERT_STR_SIZES = [64, 256, 1024];
16
+ const REPEATED_READ_ITERS = 100;
17
+
18
+ // --- TextCrdt: bulk insertStr (#lztextinsertchain is the main lever) -------
19
+ for (const size of INSERT_STR_SIZES) {
20
+ const payload = "a".repeat(size);
21
+ bench.batched(
22
+ "textcrdt_insert_str",
23
+ `${size}`,
24
+ () => new TextCrdt(1),
25
+ (crdt) => {
26
+ crdt.insertStr(0, payload);
27
+ blackBox(crdt.len());
28
+ },
29
+ );
30
+ }
31
+
32
+ // --- TextCrdt: repeated text() reads between mutations (#lztextordcache) ---
33
+ // One mutation, then N reads. Pre-cache this should be O(N log N) per read;
34
+ // post-cache it is O(1) per read after the first.
35
+ for (const size of INSERT_STR_SIZES) {
36
+ const payload = "a".repeat(size);
37
+ bench.batched(
38
+ "textcrdt_repeated_text",
39
+ `${size}`,
40
+ () => {
41
+ const crdt = new TextCrdt(1);
42
+ crdt.insertStr(0, payload);
43
+ return crdt;
44
+ },
45
+ (crdt) => {
46
+ let last = "";
47
+ for (let i = 0; i < REPEATED_READ_ITERS; i++) {
48
+ last = crdt.text();
49
+ }
50
+ blackBox(last);
51
+ },
52
+ );
53
+ }
54
+
55
+ // --- TextCrdt: state-based merge ------------------------------------------
56
+ // Two peers each insert N chars; merge b into a.
57
+ for (const size of INSERT_STR_SIZES) {
58
+ bench.batched(
59
+ "textcrdt_merge",
60
+ `${size}`,
61
+ () => {
62
+ const a = new TextCrdt(1);
63
+ const b = new TextCrdt(2);
64
+ a.insertStr(0, "a".repeat(size));
65
+ b.insertStr(0, "b".repeat(size));
66
+ return { a, b };
67
+ },
68
+ ({ a, b }) => {
69
+ blackBox(a.merge(b));
70
+ },
71
+ );
72
+ }
73
+
74
+ // --- TextCrdt: delta sync round-trip --------------------------------------
75
+ for (const size of INSERT_STR_SIZES) {
76
+ bench.batched(
77
+ "textcrdt_delta_sync",
78
+ `${size}`,
79
+ () => {
80
+ const a = new TextCrdt(1);
81
+ a.insertStr(0, "a".repeat(size));
82
+ const b = new TextCrdt(2);
83
+ return { a, b };
84
+ },
85
+ ({ a, b }) => {
86
+ const delta = a.deltaSince({});
87
+ blackBox(b.applyDelta(delta));
88
+ },
89
+ );
90
+ }
91
+
92
+ // --- SeqCrdt: bulk insertBack ---------------------------------------------
93
+ for (const size of INSERT_STR_SIZES) {
94
+ bench.batched(
95
+ "seqcrdt_insert_back",
96
+ `${size}`,
97
+ () => new SeqCrdt(1),
98
+ (crdt) => {
99
+ for (let i = 0; i < size; i++) {
100
+ crdt.insertBack(`id${i}`, i, i + 1);
101
+ }
102
+ blackBox(crdt.entryCount());
103
+ },
104
+ );
105
+ }
106
+
107
+ // --- SeqCrdt: merge with primitive values (#lzseqstringifyeq) -------------
108
+ for (const size of INSERT_STR_SIZES) {
109
+ bench.batched(
110
+ "seqcrdt_merge",
111
+ `${size}`,
112
+ () => {
113
+ const a = new SeqCrdt(1);
114
+ const b = new SeqCrdt(2);
115
+ for (let i = 0; i < size; i++) {
116
+ a.insertBack(`a${i}`, i, i + 1);
117
+ b.insertBack(`b${i}`, i * 10, i + 1);
118
+ }
119
+ return { a, b };
120
+ },
121
+ ({ a, b }) => {
122
+ blackBox(a.merge(b, Date.now() * 1000));
123
+ },
124
+ );
125
+ }
126
+
127
+ await run({
128
+ format: process.env.BENCH_FORMAT === "json" ? "json" : "markdown",
129
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazily-hub/lazily-js",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/seq-crdt.js CHANGED
@@ -93,6 +93,13 @@ export class LwwRegister {
93
93
  }
94
94
  }
95
95
 
96
+ // Structural deep equality (#lzseqstringifyeq): replaces JSON.stringify-based
97
+ // comparison (one of V8's slowest paths) with a typed dispatch that handles the
98
+ // common cases — primitives, Position, arrays, plain objects — without
99
+ // allocating a serialized string per side per comparison. Behavior is
100
+ // equivalent to JSON.stringify equality for plain JSON values, while skipping
101
+ // JSON's NaN/Infinity/null coercion and toJSON dispatch (neither is exercised
102
+ // by the values stored in SeqCrdt registers today).
96
103
  function valuesEqual(a, b) {
97
104
  if (a === b) {
98
105
  return true;
@@ -103,7 +110,32 @@ function valuesEqual(a, b) {
103
110
  if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
104
111
  return a === b;
105
112
  }
106
- return JSON.stringify(a) === JSON.stringify(b);
113
+ if (Array.isArray(a) || Array.isArray(b)) {
114
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
115
+ return false;
116
+ }
117
+ for (let i = 0; i < a.length; i++) {
118
+ if (!valuesEqual(a[i], b[i])) {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
123
+ }
124
+ const ka = Object.keys(a);
125
+ const kb = Object.keys(b);
126
+ if (ka.length !== kb.length) {
127
+ return false;
128
+ }
129
+ for (let i = 0; i < ka.length; i++) {
130
+ const k = ka[i];
131
+ if (!Object.prototype.hasOwnProperty.call(b, k)) {
132
+ return false;
133
+ }
134
+ if (!valuesEqual(a[k], b[k])) {
135
+ return false;
136
+ }
137
+ }
138
+ return true;
107
139
  }
108
140
 
109
141
  // Position: fractional-index byte key + originating peer.
package/src/text-crdt.js CHANGED
@@ -12,6 +12,10 @@ export class OpId {
12
12
  constructor(counter, peer) {
13
13
  this.counter = counter;
14
14
  this.peer = peer;
15
+ // Cached string key — avoids per-lookup allocation across the lifetime of
16
+ // an OpId (#lzopidkeytuple). Set before freeze so the field is immutable
17
+ // alongside the rest of the instance.
18
+ this._key = `${counter}:${peer}`;
15
19
  Object.freeze(this);
16
20
  }
17
21
  compareTo(other) {
@@ -29,7 +33,10 @@ export class OpId {
29
33
  }
30
34
 
31
35
  class Elem {
32
- constructor(ch, origin, deleted = null) {
36
+ // `id` is stored on the Elem so iteration over the map values can recover
37
+ // the OpId without re-parsing the string key (#lzopidkeytuple).
38
+ constructor(id, ch, origin, deleted = null) {
39
+ this.id = id; // OpId
33
40
  this.ch = ch;
34
41
  this.origin = origin; // OpId | null (null = document start)
35
42
  this.deleted = deleted; // OpId | null (the delete op id, or null = live)
@@ -37,9 +44,16 @@ class Elem {
37
44
  }
38
45
 
39
46
  export class TextCrdt {
40
- #elems = new Map(); // OpId-keyed (serialized as "counter:peer")
47
+ // String-keyed map (OpId._key -> Elem). The string is cached on each OpId at
48
+ // construction so this is a hash lookup with zero per-op allocation.
49
+ #elems = new Map();
41
50
  #peer;
42
51
  #counter = 0;
52
+ // Cached visible orderings (#lztextordcache). Both are invalidated by every
53
+ // mutation; populated lazily on the next read. Repeated `text()` between
54
+ // mutations is O(1) instead of O(N log N).
55
+ #orderedLiveCache = null; // OpId[] | null (excludes tombstones)
56
+ #orderedAllCache = null; // OpId[] | null (includes tombstones)
43
57
 
44
58
  constructor(peer) {
45
59
  this.#peer = peer;
@@ -55,8 +69,9 @@ export class TextCrdt {
55
69
  fork(peer) {
56
70
  const copy = new TextCrdt(peer);
57
71
  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));
72
+ for (const elem of this.#elems.values()) {
73
+ const e = new Elem(elem.id, elem.ch, elem.origin, elem.deleted);
74
+ copy.#elems.set(e.id._key, e);
60
75
  }
61
76
  return copy;
62
77
  }
@@ -70,22 +85,30 @@ export class TextCrdt {
70
85
  return new OpId(this.#counter, this.#peer);
71
86
  }
72
87
 
73
- #key(id) {
74
- return `${id.counter}:${id.peer}`;
88
+ #invalidateOrdered() {
89
+ this.#orderedLiveCache = null;
90
+ this.#orderedAllCache = null;
75
91
  }
76
92
 
77
93
  #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>";
94
+ // Cache hit (#lztextordcache).
95
+ if (includeDeleted) {
96
+ if (this.#orderedAllCache !== null) return this.#orderedAllCache;
97
+ } else {
98
+ if (this.#orderedLiveCache !== null) return this.#orderedLiveCache;
99
+ }
100
+
101
+ // Group by origin. Keys are origin._key (or "<root>"); values are OpId[]
102
+ // recovered directly from Elem.id — no string parse (#lzopidkeytuple).
103
+ const children = new Map();
104
+ for (const elem of this.#elems.values()) {
105
+ const originKey = elem.origin ? elem.origin._key : "<root>";
82
106
  let list = children.get(originKey);
83
107
  if (!list) {
84
108
  list = [];
85
109
  children.set(originKey, list);
86
110
  }
87
- // Recover the OpId from the map iteration key is not stored, so store it.
88
- list.push(this.#idFromKey(key));
111
+ list.push(elem.id);
89
112
  }
90
113
  // Sort each sibling list DESCENDING by OpId.
91
114
  for (const list of children.values()) {
@@ -97,14 +120,14 @@ export class TextCrdt {
97
120
  const stack = [...roots].sort(OpId.desc); // highest pops first
98
121
  while (stack.length > 0) {
99
122
  const id = stack.pop();
100
- const elem = this.#elems.get(this.#key(id));
123
+ const elem = this.#elems.get(id._key);
101
124
  if (!elem) {
102
125
  continue;
103
126
  }
104
127
  if (includeDeleted || elem.deleted === null) {
105
128
  out.push(id);
106
129
  }
107
- const kids = children.get(this.#key(id));
130
+ const kids = children.get(id._key);
108
131
  if (kids) {
109
132
  // Push reversed so the highest-OpId child pops first.
110
133
  for (let i = kids.length - 1; i >= 0; i--) {
@@ -112,26 +135,36 @@ export class TextCrdt {
112
135
  }
113
136
  }
114
137
  }
138
+ if (includeDeleted) {
139
+ this.#orderedAllCache = out;
140
+ } else {
141
+ this.#orderedLiveCache = out;
142
+ }
115
143
  return out;
116
144
  }
117
145
 
118
- #idFromKey(key) {
119
- const [counter, peer] = key.split(":");
120
- return new OpId(Number(counter), Number(peer));
121
- }
122
-
123
146
  insert(index, ch) {
124
147
  const visible = this.#orderedIds(false);
125
148
  const origin = index === 0 ? null : visible[index - 1] ?? null;
126
149
  const id = this.#nextId();
127
- this.#elems.set(this.#key(id), new Elem(ch, origin, null));
150
+ this.#invalidateOrdered();
151
+ this.#elems.set(id._key, new Elem(id, ch, origin, null));
128
152
  }
129
153
 
154
+ // Bulk insert with origin chaining (#lztextinsertchain): one `orderedIds()`
155
+ // pass + N chain appends instead of N full-tree rebuilds. Sequential chars
156
+ // chain naturally — char i+1's left-origin is char i's just-minted OpId —
157
+ // so the DFS visits them in chain order (counter strictly increases under
158
+ // one peer). Concurrent inserts at the same point still sort by the peer
159
+ // tiebreak, preserving the standard CRDT convergence contract.
130
160
  insertStr(index, str) {
131
- let i = 0;
161
+ const visible = this.#orderedIds(false);
162
+ let origin = index === 0 ? null : visible[index - 1] ?? null;
163
+ this.#invalidateOrdered();
132
164
  for (const ch of String(str)) {
133
- this.insert(index + i, ch);
134
- i++;
165
+ const id = this.#nextId();
166
+ this.#elems.set(id._key, new Elem(id, ch, origin, null));
167
+ origin = id; // chain: next char's left-origin is this id
135
168
  }
136
169
  }
137
170
 
@@ -142,16 +175,18 @@ export class TextCrdt {
142
175
  return; // no-op if out of range
143
176
  }
144
177
  const del = this.#nextId(); // always advance the clock (matches lazily-rs)
145
- const elem = this.#elems.get(this.#key(id));
178
+ const elem = this.#elems.get(id._key);
146
179
  if (elem && elem.deleted === null) {
180
+ this.#invalidateOrdered();
147
181
  elem.deleted = del;
148
182
  }
149
183
  }
150
184
 
151
185
  text() {
186
+ const ordered = this.#orderedIds(false);
152
187
  let out = "";
153
- for (const id of this.#orderedIds(false)) {
154
- out += this.#elems.get(this.#key(id)).ch;
188
+ for (const id of ordered) {
189
+ out += this.#elems.get(id._key).ch;
155
190
  }
156
191
  return out;
157
192
  }
@@ -190,26 +225,38 @@ export class TextCrdt {
190
225
  // the smaller delete id. Returns whether visible text changed.
191
226
  merge(other) {
192
227
  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);
228
+ let anyChange = false;
229
+ for (const oe of other.#elems.values()) {
230
+ this.#counter = Math.max(this.#counter, oe.id.counter);
196
231
  if (oe.deleted) {
197
232
  this.#counter = Math.max(this.#counter, oe.deleted.counter);
198
233
  }
199
- const existing = this.#elems.get(key);
234
+ const existing = this.#elems.get(oe.id._key);
200
235
  if (existing) {
201
236
  if (existing.deleted && oe.deleted) {
202
- existing.deleted =
237
+ const merged =
203
238
  existing.deleted.compareTo(oe.deleted) <= 0
204
239
  ? existing.deleted
205
240
  : oe.deleted;
206
- } else if (oe.deleted) {
241
+ if (merged !== existing.deleted) {
242
+ existing.deleted = merged;
243
+ anyChange = true;
244
+ }
245
+ } else if (oe.deleted && existing.deleted === null) {
207
246
  existing.deleted = oe.deleted;
247
+ anyChange = true;
208
248
  }
209
249
  } else {
210
- this.#elems.set(key, new Elem(oe.ch, oe.origin, oe.deleted));
250
+ // Adopt the elem with its existing OpId identity so later concurrent
251
+ // edits merge without duplication.
252
+ const adopted = new Elem(oe.id, oe.ch, oe.origin, oe.deleted);
253
+ this.#elems.set(oe.id._key, adopted);
254
+ anyChange = true;
211
255
  }
212
256
  }
257
+ if (anyChange) {
258
+ this.#invalidateOrdered();
259
+ }
213
260
  return this.text() !== before;
214
261
  }
215
262
 
@@ -234,7 +281,7 @@ export class TextCrdt {
234
281
  const referenced = new Set();
235
282
  for (const elem of this.#elems.values()) {
236
283
  if (elem.origin) {
237
- referenced.add(this.#key(elem.origin));
284
+ referenced.add(elem.origin._key);
238
285
  }
239
286
  }
240
287
  const collectable = [];
@@ -251,6 +298,9 @@ export class TextCrdt {
251
298
  removed++;
252
299
  }
253
300
  }
301
+ if (removed > 0) {
302
+ this.#invalidateOrdered();
303
+ }
254
304
  return removed;
255
305
  }
256
306
 
@@ -260,13 +310,11 @@ export class TextCrdt {
260
310
  // compact frontier a replica sends so a partner can compute the ops it lacks.
261
311
  versionVector() {
262
312
  const vv = {};
263
- const bump = (id) => {
313
+ for (const elem of this.#elems.values()) {
314
+ const id = elem.id;
264
315
  vv[id.peer] = Math.max(vv[id.peer] ?? 0, id.counter);
265
- };
266
- for (const [key, elem] of this.#elems) {
267
- bump(this.#idFromKey(key));
268
316
  if (elem.deleted) {
269
- bump(elem.deleted);
317
+ vv[elem.deleted.peer] = Math.max(vv[elem.deleted.peer] ?? 0, elem.deleted.counter);
270
318
  }
271
319
  }
272
320
  return vv;
@@ -279,8 +327,8 @@ export class TextCrdt {
279
327
  const seen = (id) => id.counter <= (theirVv[id.peer] ?? 0);
280
328
  const wire = (id) => (id ? { counter: id.counter, peer: id.peer } : null);
281
329
  const out = [];
282
- for (const [key, elem] of this.#elems) {
283
- const id = this.#idFromKey(key);
330
+ for (const elem of this.#elems.values()) {
331
+ const id = elem.id;
284
332
  const insertNew = !seen(id);
285
333
  const deleteNew = elem.deleted !== null && !seen(elem.deleted);
286
334
  if (insertNew || deleteNew) {
@@ -301,6 +349,7 @@ export class TextCrdt {
301
349
  // concurrent edits merge without duplication. Returns whether text changed.
302
350
  applyDelta(ops) {
303
351
  const before = this.text();
352
+ let anyChange = false;
304
353
  for (const op of ops) {
305
354
  const id = new OpId(op.id.counter, op.id.peer);
306
355
  const origin = op.origin ? new OpId(op.origin.counter, op.origin.peer) : null;
@@ -309,19 +358,27 @@ export class TextCrdt {
309
358
  if (deleted) {
310
359
  this.#counter = Math.max(this.#counter, deleted.counter);
311
360
  }
312
- const key = this.#key(id);
313
- const existing = this.#elems.get(key);
361
+ const existing = this.#elems.get(id._key);
314
362
  if (existing) {
315
363
  if (existing.deleted && deleted) {
316
- existing.deleted =
364
+ const merged =
317
365
  existing.deleted.compareTo(deleted) <= 0 ? existing.deleted : deleted;
318
- } else if (deleted) {
366
+ if (merged !== existing.deleted) {
367
+ existing.deleted = merged;
368
+ anyChange = true;
369
+ }
370
+ } else if (deleted && existing.deleted === null) {
319
371
  existing.deleted = deleted;
372
+ anyChange = true;
320
373
  }
321
374
  } else {
322
- this.#elems.set(key, new Elem(op.ch, origin, deleted));
375
+ this.#elems.set(id._key, new Elem(id, op.ch, origin, deleted));
376
+ anyChange = true;
323
377
  }
324
378
  }
379
+ if (anyChange) {
380
+ this.#invalidateOrdered();
381
+ }
325
382
  return this.text() !== before;
326
383
  }
327
384
  }