@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 +29 -11
- package/bench/crdt.bench.mjs +129 -0
- package/package.json +1 -1
- 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 | 31.
|
|
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/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
|
}
|