@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 +29 -11
- package/bench/crdt.bench.mjs +129 -0
- package/package.json +1 -1
- package/src/reactive.js +133 -92
- package/src/seq-crdt.js +33 -1
- package/src/text-crdt.js +103 -46
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.
|
|
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 |
|
|
37
|
-
| cold_first_get | context |
|
|
38
|
-
| dependency_fan_out | context / 32 |
|
|
39
|
-
| dependency_fan_out | context / 256 |
|
|
40
|
-
| set_cell_invalidation | high_fan_out / 512 |
|
|
41
|
-
| memo_equality_suppression | context | 2.
|
|
42
|
-
| effect_flushing | context |
|
|
43
|
-
| batch_storms | context / 64 |
|
|
44
|
-
| typed_cache_reads | context_cell |
|
|
45
|
-
| typed_cache_reads | context_slot |
|
|
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.
|
|
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) =>
|
|
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 =
|
|
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 =
|
|
91
|
-
this.dependents =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
306
|
-
this.#batchedCells
|
|
326
|
+
const cells = this.#batchedCells;
|
|
327
|
+
this.#batchedCells = new Set();
|
|
328
|
+
const roots = [];
|
|
307
329
|
for (const id of cells) {
|
|
308
|
-
this.#
|
|
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
|
|
357
|
+
const node = this.#nodes[id];
|
|
327
358
|
if (!(node instanceof EffectNode)) {
|
|
328
359
|
return;
|
|
329
360
|
}
|
|
330
|
-
this.#nodes
|
|
361
|
+
this.#nodes[id] = undefined;
|
|
331
362
|
this.#freeIds.push(id);
|
|
332
|
-
for (const dep of
|
|
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
|
|
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
|
|
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
|
|
405
|
+
const dep = this.#nodes[depId];
|
|
375
406
|
if (dep instanceof CellNode || dep instanceof SlotNode) {
|
|
376
|
-
|
|
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
|
|
412
|
+
const parent = this.#nodes[parentId];
|
|
382
413
|
if (parent instanceof SlotNode || parent instanceof EffectNode) {
|
|
383
|
-
parent.dependencies
|
|
414
|
+
edgeInsert(parent.dependencies, depId);
|
|
384
415
|
}
|
|
385
416
|
}
|
|
386
417
|
|
|
387
418
|
#removeDependentEdge(depId, parentId) {
|
|
388
|
-
const dep = this.#nodes
|
|
419
|
+
const dep = this.#nodes[depId];
|
|
389
420
|
if (dep instanceof CellNode || dep instanceof SlotNode) {
|
|
390
|
-
|
|
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
|
|
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
|
|
413
|
-
if (this.#nodes
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
515
|
-
for (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
541
|
-
|
|
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
|
|
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
|
|
611
|
+
const node = this.#nodes[id];
|
|
571
612
|
if (!(node instanceof EffectNode)) {
|
|
572
613
|
return;
|
|
573
614
|
}
|
|
574
|
-
const oldDeps =
|
|
575
|
-
node.dependencies
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
#
|
|
74
|
-
|
|
88
|
+
#invalidateOrdered() {
|
|
89
|
+
this.#orderedLiveCache = null;
|
|
90
|
+
this.#orderedAllCache = null;
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
#orderedIds(includeDeleted) {
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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(
|
|
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
|
|
154
|
-
out += this.#elems.get(
|
|
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
|
-
|
|
194
|
-
|
|
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(
|
|
234
|
+
const existing = this.#elems.get(oe.id._key);
|
|
200
235
|
if (existing) {
|
|
201
236
|
if (existing.deleted && oe.deleted) {
|
|
202
|
-
|
|
237
|
+
const merged =
|
|
203
238
|
existing.deleted.compareTo(oe.deleted) <= 0
|
|
204
239
|
? existing.deleted
|
|
205
240
|
: oe.deleted;
|
|
206
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
283
|
-
const id =
|
|
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
|
|
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
|
-
|
|
364
|
+
const merged =
|
|
317
365
|
existing.deleted.compareTo(deleted) <= 0 ? existing.deleted : deleted;
|
|
318
|
-
|
|
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(
|
|
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
|
}
|