@lazily-hub/lazily-js 0.19.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.6.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 | 57.798 ns | ± 20.941 ns | 46.398 ns | 124.744 ns | 100 |
37
- | cold_first_get | context | 669.220 ns | ± 8.630 ns | 670.000 ns | 802.610 ns | 100 |
38
- | dependency_fan_out | context / 32 | 7.448 us | ± 824.968 ns | 6.783 us | 34.205 us | 100 |
39
- | dependency_fan_out | context / 256 | 43.766 us | ± 1.064 us | 43.270 us | 49.513 us | 100 |
40
- | set_cell_invalidation | high_fan_out / 512 | 10.737 us | ± 376.524 ns | 10.113 us | 17.141 us | 100 |
41
- | memo_equality_suppression | context | 2.387 us | ± 193.801 ns | 2.310 us | 2.864 us | 100 |
42
- | effect_flushing | context | 211.187 ns | ± 20.150 ns | 221.482 ns | 524.491 ns | 100 |
43
- | batch_storms | context / 64 | 30.430 us | ± 1.295 us | 30.815 us | 49.775 us | 99 |
44
- | typed_cache_reads | context_cell | 31.325 ns | ± 1.052 ns | 30.255 ns | 58.102 ns | 100 |
45
- | typed_cache_reads | context_slot | 120.262 ns | ± 4.805 ns | 118.099 ns | 220.409 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.19.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/reactive.js CHANGED
@@ -36,8 +36,7 @@ function defaultEqual(a, b) {
36
36
  const bKeys = Object.keys(b);
37
37
  return (
38
38
  aKeys.length === bKeys.length &&
39
- aKeys.every((k) => Object.is(aKeys[k], bKeys[k])) &&
40
- aKeys.every((k) => defaultEqual(a[k], b[k]))
39
+ aKeys.every((k) => k in b && defaultEqual(a[k], b[k]))
41
40
  );
42
41
  }
43
42
 
@@ -74,10 +73,31 @@ export class SignalHandle {
74
73
 
75
74
  // -- Context -----------------------------------------------------------------
76
75
 
76
+ function edgeInsert(edges, id) {
77
+ for (let i = 0; i < edges.length; i++) {
78
+ if (edges[i] === id) {
79
+ return false;
80
+ }
81
+ }
82
+ edges.push(id);
83
+ return true;
84
+ }
85
+
86
+ function edgeRemove(edges, id) {
87
+ for (let i = 0; i < edges.length; i++) {
88
+ if (edges[i] === id) {
89
+ edges[i] = edges[edges.length - 1];
90
+ edges.pop();
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
77
97
  class CellNode {
78
98
  constructor(value) {
79
99
  this.value = value;
80
- this.dependents = new Set();
100
+ this.dependents = [];
81
101
  }
82
102
  }
83
103
 
@@ -87,8 +107,8 @@ class SlotNode {
87
107
  this.hasValue = false;
88
108
  this.memo = memo;
89
109
  this.compute = compute;
90
- this.dependencies = new Set();
91
- this.dependents = new Set();
110
+ this.dependencies = [];
111
+ this.dependents = [];
92
112
  this.dirty = false;
93
113
  this.forceRecompute = false;
94
114
  this.inProgress = false;
@@ -98,18 +118,19 @@ class SlotNode {
98
118
  class EffectNode {
99
119
  constructor(run) {
100
120
  this.run = run;
101
- this.dependencies = new Set();
121
+ this.dependencies = [];
102
122
  this.cleanup = null;
103
123
  this.forceRun = false;
104
124
  }
105
125
  }
106
126
 
107
127
  export class Context {
108
- #nodes = new Map();
128
+ #nodes = [];
109
129
  #nextId = 1;
110
130
  #freeIds = [];
111
131
  #trackingStack = [];
112
132
  #pendingEffects = [];
133
+ #pendingHead = 0;
113
134
  #scheduledEffects = new Set();
114
135
  #flushingEffects = false;
115
136
  #batchDepth = 0;
@@ -167,7 +188,7 @@ export class Context {
167
188
 
168
189
  #cellAny(value) {
169
190
  const id = this.#allocId();
170
- this.#nodes.set(id, new CellNode(value));
191
+ this.#nodes[id] = new CellNode(value);
171
192
  return id;
172
193
  }
173
194
 
@@ -185,7 +206,7 @@ export class Context {
185
206
 
186
207
  #slotAny(memo, compute) {
187
208
  const id = this.#allocId();
188
- this.#nodes.set(id, new SlotNode(compute, memo));
209
+ this.#nodes[id] = new SlotNode(compute, memo);
189
210
  return id;
190
211
  }
191
212
 
@@ -206,7 +227,7 @@ export class Context {
206
227
  const id = this.#allocId();
207
228
  const node = new EffectNode(run);
208
229
  node.forceRun = true; // force the initial run on registration
209
- this.#nodes.set(id, node);
230
+ this.#nodes[id] = node;
210
231
  this.#scheduleEffect(id, false);
211
232
  this.#flushEffects();
212
233
  return id;
@@ -232,7 +253,7 @@ export class Context {
232
253
  this.#registerDependency(id, frame);
233
254
  }
234
255
  this.#refreshSlot(id);
235
- const node = this.#nodes.get(id);
256
+ const node = this.#nodes[id];
236
257
  if (!(node instanceof SlotNode) || !node.hasValue) {
237
258
  throw new Error(`slot ${id} has no value`);
238
259
  }
@@ -244,7 +265,7 @@ export class Context {
244
265
  if (frame !== undefined) {
245
266
  this.#registerDependency(id, frame);
246
267
  }
247
- const node = this.#nodes.get(id);
268
+ const node = this.#nodes[id];
248
269
  if (!(node instanceof CellNode)) {
249
270
  throw new Error(`get_cell on non-cell id ${id}`);
250
271
  }
@@ -258,7 +279,7 @@ export class Context {
258
279
  }
259
280
 
260
281
  #setCellAny(id, value) {
261
- const node = this.#nodes.get(id);
282
+ const node = this.#nodes[id];
262
283
  if (!(node instanceof CellNode)) {
263
284
  throw new Error(`set_cell on non-cell id ${id}`);
264
285
  }
@@ -302,10 +323,20 @@ export class Context {
302
323
  }
303
324
 
304
325
  #flushBatched() {
305
- const cells = [...this.#batchedCells];
306
- this.#batchedCells.clear();
326
+ const cells = this.#batchedCells;
327
+ this.#batchedCells = new Set();
328
+ const roots = [];
307
329
  for (const id of cells) {
308
- this.#invalidateCellDependentsNow(id);
330
+ const node = this.#nodes[id];
331
+ if (node instanceof CellNode) {
332
+ for (const d of node.dependents) {
333
+ roots.push(d);
334
+ }
335
+ }
336
+ }
337
+ const effects = this.#markFrontier(roots);
338
+ for (let i = 0; i < effects.length; i++) {
339
+ this.#scheduleEffect(effects[i][0], effects[i][1]);
309
340
  }
310
341
  this.#flushEffects();
311
342
  }
@@ -318,18 +349,18 @@ export class Context {
318
349
 
319
350
  disposeEffect(handle) {
320
351
  const id = handle.id;
321
- const idx = this.#pendingEffects.indexOf(id);
352
+ const idx = this.#pendingEffects.indexOf(id, this.#pendingHead);
322
353
  if (idx !== -1) {
323
354
  this.#pendingEffects.splice(idx, 1);
324
355
  }
325
356
  this.#scheduledEffects.delete(id);
326
- const node = this.#nodes.get(id);
357
+ const node = this.#nodes[id];
327
358
  if (!(node instanceof EffectNode)) {
328
359
  return;
329
360
  }
330
- this.#nodes.delete(id);
361
+ this.#nodes[id] = undefined;
331
362
  this.#freeIds.push(id);
332
- for (const dep of [...node.dependencies]) {
363
+ for (const dep of node.dependencies) {
333
364
  this.#removeDependentEdge(dep, id);
334
365
  }
335
366
  if (node.cleanup) {
@@ -338,7 +369,7 @@ export class Context {
338
369
  }
339
370
 
340
371
  isEffectActive(handle) {
341
- return this.#nodes.get(handle.id) instanceof EffectNode;
372
+ return this.#nodes[handle.id] instanceof EffectNode;
342
373
  }
343
374
 
344
375
  disposeSignal(handle) {
@@ -350,7 +381,7 @@ export class Context {
350
381
  }
351
382
 
352
383
  isSet(handle) {
353
- const node = this.#nodes.get(handle.id);
384
+ const node = this.#nodes[handle.id];
354
385
  if (!(node instanceof SlotNode)) {
355
386
  return false;
356
387
  }
@@ -371,36 +402,39 @@ export class Context {
371
402
  }
372
403
 
373
404
  #registerDependency(depId, parentId) {
374
- const dep = this.#nodes.get(depId);
405
+ const dep = this.#nodes[depId];
375
406
  if (dep instanceof CellNode || dep instanceof SlotNode) {
376
- if (this.#instrument && !dep.dependents.has(parentId)) {
407
+ const added = edgeInsert(dep.dependents, parentId);
408
+ if (this.#instrument && added) {
377
409
  this.#counters.dependencyEdgesAdded++;
378
410
  }
379
- dep.dependents.add(parentId);
380
411
  }
381
- const parent = this.#nodes.get(parentId);
412
+ const parent = this.#nodes[parentId];
382
413
  if (parent instanceof SlotNode || parent instanceof EffectNode) {
383
- parent.dependencies.add(depId);
414
+ edgeInsert(parent.dependencies, depId);
384
415
  }
385
416
  }
386
417
 
387
418
  #removeDependentEdge(depId, parentId) {
388
- const dep = this.#nodes.get(depId);
419
+ const dep = this.#nodes[depId];
389
420
  if (dep instanceof CellNode || dep instanceof SlotNode) {
390
- if (this.#instrument && dep.dependents.has(parentId)) {
421
+ const removed = edgeRemove(dep.dependents, parentId);
422
+ if (this.#instrument && removed) {
391
423
  this.#counters.dependencyEdgesRemoved++;
392
424
  }
393
- dep.dependents.delete(parentId);
394
425
  }
395
426
  }
396
427
 
397
428
  // -- Internals: refresh / recompute (pull-based, glitch-free) ----------
398
429
 
399
430
  #refreshSlot(id) {
400
- const node = this.#nodes.get(id);
431
+ const node = this.#nodes[id];
401
432
  if (!(node instanceof SlotNode)) {
402
433
  return false;
403
434
  }
435
+ if (node.hasValue && !node.dirty && !node.forceRecompute) {
436
+ return false;
437
+ }
404
438
  if (node.inProgress) {
405
439
  throw new Error(
406
440
  `lazily: circular dependency detected at slot ${id}; a computed/memo slot depends on itself`,
@@ -409,8 +443,8 @@ export class Context {
409
443
  node.inProgress = true;
410
444
  try {
411
445
  let dependencyChanged = false;
412
- for (const dep of [...node.dependencies]) {
413
- if (this.#nodes.get(dep) instanceof SlotNode && this.#refreshSlot(dep)) {
446
+ for (const dep of node.dependencies) {
447
+ if (this.#nodes[dep] instanceof SlotNode && this.#refreshSlot(dep)) {
414
448
  dependencyChanged = true;
415
449
  }
416
450
  }
@@ -430,10 +464,11 @@ export class Context {
430
464
  if (this.#instrument) {
431
465
  this.#counters.slotRecomputes++;
432
466
  }
433
- for (const dep of [...node.dependencies]) {
467
+ const oldDeps = node.dependencies;
468
+ node.dependencies = [];
469
+ for (const dep of oldDeps) {
434
470
  this.#removeDependentEdge(dep, id);
435
471
  }
436
- node.dependencies.clear();
437
472
  this.#trackingStack.push(id);
438
473
  let result;
439
474
  try {
@@ -457,13 +492,7 @@ export class Context {
457
492
  }
458
493
 
459
494
  #notifySlotValueChanged(id) {
460
- const node = this.#nodes.get(id);
461
- if (!(node instanceof SlotNode)) {
462
- return;
463
- }
464
- for (const d of [...node.dependents]) {
465
- this.#invalidateDependentFromChangedValue(d);
466
- }
495
+ this.#invalidateDependentsNow(id);
467
496
  }
468
497
 
469
498
  // -- Internals: invalidation propagation ------------------------------
@@ -475,58 +504,67 @@ export class Context {
475
504
  * @returns {boolean}
476
505
  */
477
506
  #invalidateCellDependentsNow(id) {
478
- const node = this.#nodes.get(id);
479
- if (!(node instanceof CellNode) || node.dependents.size === 0) {
480
- return false;
481
- }
482
- let scheduled = false;
483
- for (const d of [...node.dependents]) {
484
- if (this.#invalidateDependentFromChangedValue(d)) {
485
- scheduled = true;
486
- }
487
- }
488
- return scheduled;
507
+ return this.#invalidateDependentsNow(id);
489
508
  }
490
509
 
491
- /** @returns {boolean} whether an Effect was scheduled. */
492
- #invalidateDependentFromChangedValue(id) {
493
- if (this.#nodes.get(id) instanceof EffectNode) {
494
- this.#scheduleEffect(id, true);
495
- return true;
496
- }
497
- return this.#markSlotDirty(id, true);
498
- }
499
-
500
- /** @returns {boolean} whether an Effect was scheduled during this walk. */
501
- #markSlotDirty(id, force) {
502
- const node = this.#nodes.get(id);
503
- if (!(node instanceof SlotNode)) {
504
- return false;
505
- }
506
- const shouldPropagate = !node.dirty || (force && !node.forceRecompute);
507
- node.dirty = true;
508
- if (force) {
509
- node.forceRecompute = true;
510
- }
511
- if (!shouldPropagate) {
510
+ #invalidateDependentsNow(id) {
511
+ const node = this.#nodes[id];
512
+ let roots;
513
+ if (node instanceof CellNode) {
514
+ if (node.dependents.length === 0) {
515
+ return false;
516
+ }
517
+ roots = node.dependents;
518
+ } else if (node instanceof SlotNode) {
519
+ if (node.dependents.length === 0) {
520
+ return false;
521
+ }
522
+ roots = node.dependents;
523
+ } else {
512
524
  return false;
513
525
  }
514
- let scheduled = false;
515
- for (const d of [...node.dependents]) {
516
- if (this.#nodes.get(d) instanceof EffectNode) {
517
- this.#scheduleEffect(d, false);
518
- scheduled = true;
519
- } else if (this.#markSlotDirty(d, false)) {
520
- scheduled = true;
526
+ const effects = this.#markFrontier(roots);
527
+ for (let i = 0; i < effects.length; i++) {
528
+ this.#scheduleEffect(effects[i][0], effects[i][1]);
529
+ }
530
+ return effects.length > 0;
531
+ }
532
+
533
+ #markFrontier(roots) {
534
+ const effects = [];
535
+ const stack = [];
536
+ const forceStack = [];
537
+ for (const root of roots) {
538
+ stack.push(root);
539
+ forceStack.push(true);
540
+ }
541
+ while (stack.length > 0) {
542
+ const id = stack.pop();
543
+ const force = forceStack.pop();
544
+ const node = this.#nodes[id];
545
+ if (node instanceof SlotNode) {
546
+ const shouldPropagate = !node.dirty || (force && !node.forceRecompute);
547
+ node.dirty = true;
548
+ if (force) {
549
+ node.forceRecompute = true;
550
+ }
551
+ if (shouldPropagate) {
552
+ for (const depId of node.dependents) {
553
+ stack.push(depId);
554
+ forceStack.push(false);
555
+ }
556
+ }
557
+ } else if (node instanceof EffectNode) {
558
+ effects.push([id, force]);
521
559
  }
522
560
  }
523
- return scheduled;
561
+ return effects;
524
562
  }
525
563
 
526
564
  // -- Internals: effect scheduling / flush ------------------------------
527
565
 
528
566
  #scheduleEffect(id, force) {
529
- const node = this.#nodes.get(id);
567
+ const node = this.#nodes[id];
530
568
  if (!(node instanceof EffectNode)) {
531
569
  return;
532
570
  }
@@ -537,8 +575,9 @@ export class Context {
537
575
  this.#pendingEffects.push(id);
538
576
  if (this.#instrument) {
539
577
  this.#counters.effectQueuePushes++;
540
- if (this.#pendingEffects.length > this.#counters.maxEffectQueueDepth) {
541
- this.#counters.maxEffectQueueDepth = this.#pendingEffects.length;
578
+ const depth = this.#pendingEffects.length - this.#pendingHead;
579
+ if (depth > this.#counters.maxEffectQueueDepth) {
580
+ this.#counters.maxEffectQueueDepth = depth;
542
581
  }
543
582
  }
544
583
  }
@@ -551,8 +590,10 @@ export class Context {
551
590
  this.#flushingEffects = true;
552
591
  try {
553
592
  while (true) {
554
- const id = this.#pendingEffects.shift();
593
+ const id = this.#pendingEffects[this.#pendingHead++];
555
594
  if (id === undefined) {
595
+ this.#pendingEffects = [];
596
+ this.#pendingHead = 0;
556
597
  return;
557
598
  }
558
599
  this.#scheduledEffects.delete(id);
@@ -567,12 +608,12 @@ export class Context {
567
608
  if (!this.#effectShouldRun(id)) {
568
609
  return;
569
610
  }
570
- const node = this.#nodes.get(id);
611
+ const node = this.#nodes[id];
571
612
  if (!(node instanceof EffectNode)) {
572
613
  return;
573
614
  }
574
- const oldDeps = [...node.dependencies];
575
- node.dependencies.clear();
615
+ const oldDeps = node.dependencies;
616
+ node.dependencies = [];
576
617
  const cleanup = node.cleanup;
577
618
  node.cleanup = null;
578
619
  node.forceRun = false;
@@ -589,7 +630,7 @@ export class Context {
589
630
  } finally {
590
631
  this.#trackingStack.pop();
591
632
  }
592
- const current = this.#nodes.get(id);
633
+ const current = this.#nodes[id];
593
634
  if (current instanceof EffectNode) {
594
635
  current.cleanup = typeof nextCleanup === "function" ? nextCleanup : null;
595
636
  } else if (typeof nextCleanup === "function") {
@@ -598,7 +639,7 @@ export class Context {
598
639
  }
599
640
 
600
641
  #effectShouldRun(id) {
601
- const node = this.#nodes.get(id);
642
+ const node = this.#nodes[id];
602
643
  if (!(node instanceof EffectNode)) {
603
644
  return false;
604
645
  }
@@ -606,7 +647,7 @@ export class Context {
606
647
  return true;
607
648
  }
608
649
  for (const dep of node.dependencies) {
609
- if (this.#nodes.get(dep) instanceof SlotNode && this.#refreshSlot(dep)) {
650
+ if (this.#nodes[dep] instanceof SlotNode && this.#refreshSlot(dep)) {
610
651
  return true;
611
652
  }
612
653
  }
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
  }