@brass-build/client 0.1.0 → 0.3.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/AGENTS.md +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
package/dist/handles.js
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Engine-neutral: Loro events are translated to a minimal `Change` shape at
|
|
6
6
|
// the handle boundary, so handle-only app code does not pin the SDK to Loro.
|
|
7
|
-
import {
|
|
8
|
-
import { PAGE_TAG,
|
|
9
|
-
import { BrassError } from './errors.js';
|
|
7
|
+
import { LoroMovableList, LoroMap, isContainer, } from 'loro-crdt';
|
|
8
|
+
import { PAGE_TAG, UNPAGED_MAX_RECORDS, PAGE_KEYS_FIELD, assertUsableRecordKey, classifyStoreKey, decodePageKeys, decodePageRecords, decodeRecordValue, encodePage, flattenPagedLookup, isReservedKey, overlayKeyFor, packWholeValue, parseSessionPageKey, repackRecord, parsePatchKey, patchKeyFor, applyRecordPatches, recordsPerPage, sampleBodyBytes, sessionPageKey, tombstoneKeyFor, unpackRecord, } from './record-paging.js';
|
|
10
9
|
import { MutateBatcher } from './mutate-batching.js';
|
|
11
10
|
// ---- Plain-JS-proxy machinery, scoped per-container --------------------
|
|
12
11
|
function isPlainObject(v) {
|
|
@@ -42,9 +41,47 @@ function toPlainDeep(v) {
|
|
|
42
41
|
}
|
|
43
42
|
return v;
|
|
44
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* One live map entry as a plain value: a container through `toJSON`, any other
|
|
46
|
+
* value passed through.
|
|
47
|
+
*
|
|
48
|
+
* Every container kind is materialised, not just the two a record is usually
|
|
49
|
+
* written as: what a key holds is the app's choice, so an array under a record
|
|
50
|
+
* key arrives as a `LoroMovableList`, and a raw handle fails the tests a caller makes
|
|
51
|
+
* of it while the bulk read of the same store hands back a plain value.
|
|
52
|
+
*
|
|
53
|
+
* Returns the handle to wasm once the plain value is out. `m.get` mints a new
|
|
54
|
+
* wasm-bindgen handle every call, finalisation lags allocation, and wasm linear
|
|
55
|
+
* memory never shrinks, so a handle left undropped is held until the tab
|
|
56
|
+
* closes. This runs on every keyed read and every subscriber fire, so the
|
|
57
|
+
* arithmetic reaches tens of MB over a working session.
|
|
58
|
+
*/
|
|
59
|
+
function plainEntry(m, key) {
|
|
60
|
+
const v = m.get(key);
|
|
61
|
+
if (!isContainer(v))
|
|
62
|
+
return v;
|
|
63
|
+
const container = v;
|
|
64
|
+
try {
|
|
65
|
+
return container.toJSON();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
container.free();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Whether a key is set, without keeping the handle the read minted. A
|
|
72
|
+
* presence check discards the value, so the handle is pure waste; it is
|
|
73
|
+
* still a handle, and this runs once per key on an ordered-map write. */
|
|
74
|
+
function entryExists(m, key) {
|
|
75
|
+
const v = m.get(key);
|
|
76
|
+
if (v === undefined)
|
|
77
|
+
return false;
|
|
78
|
+
if (isContainer(v))
|
|
79
|
+
v.free();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
45
82
|
function assignPlain(target, value) {
|
|
46
83
|
// Recursively materialize nested objects / arrays as nested LoroMap /
|
|
47
|
-
//
|
|
84
|
+
// LoroMovableList containers so the CRDT mirrors the JS shape. Clear keys not in
|
|
48
85
|
// `value` so assignment is a replace, not a merge (property-level patching
|
|
49
86
|
// uses `d.entities[id].x = ...`).
|
|
50
87
|
for (const k of target.keys()) {
|
|
@@ -52,96 +89,209 @@ function assignPlain(target, value) {
|
|
|
52
89
|
target.delete(k);
|
|
53
90
|
}
|
|
54
91
|
for (const [k, rawV] of Object.entries(value)) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
assignEntry(target, k, rawV);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Write one value at one map key, in the shape that key should hold: a nested
|
|
96
|
+
// object / array becomes a child container mirroring the JS shape, anything
|
|
97
|
+
// else a plain value. The single definition of what assigning to a map entry
|
|
98
|
+
// means, shared by `assignPlain` and the mutate proxy's set trap, so the two
|
|
99
|
+
// cannot disagree about the shape a value lands in.
|
|
100
|
+
function assignEntry(target, key, rawValue) {
|
|
101
|
+
const value = materializeContainers(rawValue);
|
|
102
|
+
const existing = target.get(key);
|
|
103
|
+
// Plain-VALUE preservation: when the key holds a plain (non-container)
|
|
104
|
+
// object or array, the new structure is written as a plain value too, never
|
|
105
|
+
// promoted. This keeps a record body (`rec.params`, one JSON value whose
|
|
106
|
+
// whole-replace is the merge unit) a value across edits; promoting it would
|
|
107
|
+
// change the merge granularity and bloat the snapshot with per-element
|
|
108
|
+
// containers.
|
|
109
|
+
if (existing !== undefined &&
|
|
110
|
+
!isContainer(existing) &&
|
|
111
|
+
typeof existing === 'object' &&
|
|
112
|
+
existing !== null &&
|
|
113
|
+
typeof value === 'object' &&
|
|
114
|
+
value !== null) {
|
|
115
|
+
target.set(key, toPlainDeep(value));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (isPlainObject(value)) {
|
|
119
|
+
// Reuse an existing LoroMap at this key: a fresh `setContainer` mints a
|
|
120
|
+
// new container ID, which under Loro's UndoManager is a structural
|
|
121
|
+
// destroy+create that does NOT merge within the merge window. In-place
|
|
122
|
+
// field overwrites DO merge, which whole-entity-rewrites-at-drag-cadence
|
|
123
|
+
// expects.
|
|
124
|
+
const sub = existing instanceof LoroMap
|
|
125
|
+
? existing
|
|
126
|
+
: target.setContainer(key, new LoroMap());
|
|
127
|
+
assignPlain(sub, value);
|
|
128
|
+
}
|
|
129
|
+
else if (Array.isArray(value)) {
|
|
130
|
+
const sub = existing instanceof LoroMovableList
|
|
131
|
+
? existing
|
|
132
|
+
: target.setContainer(key, new LoroMovableList());
|
|
133
|
+
assignList(sub, value);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
target.set(key, value);
|
|
90
137
|
}
|
|
91
138
|
}
|
|
92
139
|
function assignList(target, value) {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
140
|
+
// Assigning a whole array writes the positions that differ, in place. Two
|
|
141
|
+
// properties come out of that, and the second is why a position is SET
|
|
142
|
+
// rather than deleted and re-inserted.
|
|
143
|
+
//
|
|
144
|
+
// A position already holding the value needs no op at all. Re-assigning an
|
|
145
|
+
// array is how an app states an edit to one of its rows (`rows[1].n = 2;
|
|
146
|
+
// m.rows = rows`), so most positions are unchanged, and an op written for an
|
|
147
|
+
// unchanged one is both a container a peer's concurrent field edit is
|
|
148
|
+
// detached from and one more thing for it to resolve against.
|
|
149
|
+
//
|
|
150
|
+
// A position that DOES differ is set, which is one op naming that position.
|
|
151
|
+
// Deleting and re-inserting states the same intent to a movable list, but
|
|
152
|
+
// the delete and the insert commute with a peer's concurrent write to the
|
|
153
|
+
// same position, so the pair leaves the entry stored twice while `set`
|
|
154
|
+
// leaves it stored once. That is the same reasoning `assignListPosition`
|
|
155
|
+
// carries, and a whole-array assign is the write that reaches it most.
|
|
96
156
|
const oldLen = target.length;
|
|
97
157
|
for (let i = 0; i < value.length; i++) {
|
|
98
158
|
const item = materializeContainers(value[i]);
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const child = target.insertContainer(i, new LoroMap());
|
|
103
|
-
assignPlain(child, item);
|
|
104
|
-
}
|
|
105
|
-
else if (Array.isArray(item)) {
|
|
106
|
-
// A nested list: rebuild as a child LoroList rather than inserting the
|
|
107
|
-
// array as an opaque value.
|
|
108
|
-
if (i < oldLen)
|
|
109
|
-
target.delete(i, 1);
|
|
110
|
-
const child = target.insertContainer(i, new LoroList());
|
|
111
|
-
assignList(child, item);
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
if (i < oldLen) {
|
|
115
|
-
target.delete(i, 1);
|
|
116
|
-
target.insert(i, item);
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
target.insert(i, item);
|
|
120
|
-
}
|
|
159
|
+
if (i >= oldLen) {
|
|
160
|
+
insertListAt(target, i, item);
|
|
161
|
+
continue;
|
|
121
162
|
}
|
|
163
|
+
if (samePlainValue(plainListItem(target, i), item))
|
|
164
|
+
continue;
|
|
165
|
+
assignListPosition(target, i, item);
|
|
122
166
|
}
|
|
123
167
|
if (value.length < oldLen) {
|
|
124
168
|
target.delete(value.length, oldLen - value.length);
|
|
125
169
|
}
|
|
126
170
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
/** Whether a position already holds this value, so assigning it writes
|
|
172
|
+
* nothing. Compared as plain data, which is the form both sides are already
|
|
173
|
+
* in: the stored side through `toJSON`, the incoming side as the app wrote
|
|
174
|
+
* it. A pair that differs only in key ORDER compares unequal and is
|
|
175
|
+
* rewritten, which costs an op and stores the same thing. */
|
|
176
|
+
function samePlainValue(stored, next) {
|
|
177
|
+
if (stored === next)
|
|
178
|
+
return true;
|
|
179
|
+
if (stored === null ||
|
|
180
|
+
next === null ||
|
|
181
|
+
typeof stored !== 'object' ||
|
|
182
|
+
typeof next !== 'object') {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return JSON.stringify(stored) === JSON.stringify(next);
|
|
186
|
+
}
|
|
187
|
+
// Insert one value at `idx` (same plain-vs-container handling as the indexed-
|
|
188
|
+
// set trap). The proxy's native mutators build on this so an insertion costs
|
|
189
|
+
// one op at one position instead of a full-list replay.
|
|
190
|
+
function insertListAt(target, idx, rawItem) {
|
|
132
191
|
const item = materializeContainers(rawItem);
|
|
133
192
|
if (isPlainObject(item)) {
|
|
134
193
|
const child = target.insertContainer(idx, new LoroMap());
|
|
135
194
|
assignPlain(child, item);
|
|
136
195
|
}
|
|
137
196
|
else if (Array.isArray(item)) {
|
|
138
|
-
const child = target.insertContainer(idx, new
|
|
197
|
+
const child = target.insertContainer(idx, new LoroMovableList());
|
|
139
198
|
assignList(child, item);
|
|
140
199
|
}
|
|
141
200
|
else {
|
|
142
201
|
target.insert(idx, item);
|
|
143
202
|
}
|
|
144
203
|
}
|
|
204
|
+
function insertListTail(target, rawItem) {
|
|
205
|
+
insertListAt(target, target.length, rawItem);
|
|
206
|
+
}
|
|
207
|
+
// Replace what an EXISTING position holds, in one op at that position.
|
|
208
|
+
//
|
|
209
|
+
// A movable list can set a position, where a plain one has to delete and
|
|
210
|
+
// insert. That matters beyond op count: the delete and the insert commute with
|
|
211
|
+
// a peer's concurrent write to the same position, so the pair leaves the entry
|
|
212
|
+
// stored twice, and `set` leaves it stored once.
|
|
213
|
+
function assignListPosition(target, idx, rawItem) {
|
|
214
|
+
const item = materializeContainers(rawItem);
|
|
215
|
+
if (isPlainObject(item)) {
|
|
216
|
+
const child = target.setContainer(idx, new LoroMap());
|
|
217
|
+
assignPlain(child, item);
|
|
218
|
+
}
|
|
219
|
+
else if (Array.isArray(item)) {
|
|
220
|
+
const child = target.setContainer(idx, new LoroMovableList());
|
|
221
|
+
assignList(child, item);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
target.set(idx, item);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** One element as plain data, for a mutator's return value. */
|
|
228
|
+
function plainListItem(target, idx) {
|
|
229
|
+
const v = target.get(idx);
|
|
230
|
+
if (isContainer(v))
|
|
231
|
+
return v.toJSON();
|
|
232
|
+
return v;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A record's BODY, wrapped so writing a field inside it lands.
|
|
236
|
+
*
|
|
237
|
+
* A body is one plain JSON value, not a container, so reading it hands back
|
|
238
|
+
* an ordinary object. Without this, `m[key].params.height = 3` mutates that
|
|
239
|
+
* object and stops there: nothing is written, and nothing says so, which is
|
|
240
|
+
* the shape of every field edit a developer would reach for first. The write
|
|
241
|
+
* is rebuilt into a copy and pushed back through `commit`, which reassigns
|
|
242
|
+
* the whole body, so the record stays the merge unit exactly as an explicit
|
|
243
|
+
* `m[key].params = {...}` does.
|
|
244
|
+
*
|
|
245
|
+
* Only objects and arrays are wrapped, and only as they are reached, so a
|
|
246
|
+
* mutation that assigns without reading allocates nothing.
|
|
247
|
+
*
|
|
248
|
+
* The traps compose on ONE working copy rather than deriving a fresh copy of
|
|
249
|
+
* the original per write. An array mutator (`push`, `splice`, `sort`,
|
|
250
|
+
* `reverse`) writes several properties in sequence, each reading what the
|
|
251
|
+
* last one left, so a per-write copy keeps only the final assignment and
|
|
252
|
+
* commits an array that was never asked for: `push` on `[1,2,3]` writes index
|
|
253
|
+
* 3 and then `length`, and the `length` write derived from the original
|
|
254
|
+
* commits `[1,2,3,<empty>]`. The working copy is shallow and every write
|
|
255
|
+
* replaces rather than mutates in place, so the value handed in is never
|
|
256
|
+
* touched.
|
|
257
|
+
*/
|
|
258
|
+
function writeThroughBody(value, commit) {
|
|
259
|
+
if (value === null || typeof value !== 'object')
|
|
260
|
+
return value;
|
|
261
|
+
const working = Array.isArray(value)
|
|
262
|
+
? [...value]
|
|
263
|
+
: { ...value };
|
|
264
|
+
const snapshot = () => Array.isArray(working)
|
|
265
|
+
? [...working]
|
|
266
|
+
: { ...working };
|
|
267
|
+
return new Proxy(working, {
|
|
268
|
+
get(target, prop, receiver) {
|
|
269
|
+
const inner = Reflect.get(target, prop, receiver);
|
|
270
|
+
if (typeof prop === 'symbol')
|
|
271
|
+
return inner;
|
|
272
|
+
if (inner === null || typeof inner !== 'object')
|
|
273
|
+
return inner;
|
|
274
|
+
return writeThroughBody(inner, (next) => {
|
|
275
|
+
working[prop] = next;
|
|
276
|
+
commit(snapshot());
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
set(_target, prop, next) {
|
|
280
|
+
if (typeof prop === 'symbol')
|
|
281
|
+
return false;
|
|
282
|
+
working[prop] = next;
|
|
283
|
+
commit(snapshot());
|
|
284
|
+
return true;
|
|
285
|
+
},
|
|
286
|
+
deleteProperty(_target, prop) {
|
|
287
|
+
if (typeof prop === 'symbol')
|
|
288
|
+
return false;
|
|
289
|
+
delete working[prop];
|
|
290
|
+
commit(snapshot());
|
|
291
|
+
return true;
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
145
295
|
// Exported because `Document.mutate` reuses the same proxy for its
|
|
146
296
|
// `entities` root. Load-bearing: `getOwnPropertyDescriptor` does a single
|
|
147
297
|
// `loroMap.get(key)` (O(1) per key, so `Object.keys()` is O(N)). Iterating
|
|
@@ -152,23 +302,49 @@ export function wrapLoroMapProxy(loroMap,
|
|
|
152
302
|
// mutation can hold thousands of them (`m[id].params = ...` in a loop),
|
|
153
303
|
// which reaches the same size a bulk write does while touching no
|
|
154
304
|
// top-level key.
|
|
155
|
-
batcher
|
|
305
|
+
batcher, divert,
|
|
306
|
+
// Whether this map's keys are RECORD keys, and so must stay out of the
|
|
307
|
+
// reserved space the store's own bookkeeping occupies. True for a root map,
|
|
308
|
+
// which is what can page; false inside a record, whose field names are the
|
|
309
|
+
// app's own and constrained by nothing.
|
|
310
|
+
guardKeys = false) {
|
|
156
311
|
const handler = {
|
|
157
312
|
get(_t, key) {
|
|
158
313
|
if (typeof key === 'symbol')
|
|
159
314
|
return undefined;
|
|
315
|
+
if (divert?.holds(key)) {
|
|
316
|
+
const held = divert.peek(key);
|
|
317
|
+
if (held === null || typeof held !== 'object')
|
|
318
|
+
return held;
|
|
319
|
+
return writeThroughBody(held, (next) => {
|
|
320
|
+
// Charged like the set trap's own assignment: a record filled field
|
|
321
|
+
// by field (`m[id] = {}` then `m[id].params = ...`) reaches the
|
|
322
|
+
// divert through here and nowhere else, so without this it is
|
|
323
|
+
// charged only whatever its first assigned value was and a bulk
|
|
324
|
+
// write built that way rides ops sized by the mutation.
|
|
325
|
+
batcher?.record(next);
|
|
326
|
+
divert.take(key, next);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
160
329
|
const v = loroMap.get(key);
|
|
330
|
+
if (!isContainer(v) && v !== null && typeof v === 'object') {
|
|
331
|
+
// A plain object / array body: hand back a view that writes a field
|
|
332
|
+
// edit through, rather than a detached copy that swallows it.
|
|
333
|
+
return writeThroughBody(v, (next) => {
|
|
334
|
+
handler.set(_t, key, next, _t);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
161
337
|
if (isContainer(v)) {
|
|
162
338
|
// Present an engine-neutral view, never the raw container, so app
|
|
163
339
|
// code can't take a hard dependency on the CRDT engine. A LoroMap
|
|
164
|
-
// recurses into another proxy; a
|
|
340
|
+
// recurses into another proxy; a LoroMovableList reads back via `toJSON`
|
|
165
341
|
// (not `toArray`, which is shallow and hands back live element
|
|
166
342
|
// handles that corrupt on reassignment; list editing goes through
|
|
167
343
|
// `parent.list(key).mutate(...)`). Other container types are outside
|
|
168
344
|
// the v1 portable surface, so project them via toJSON().
|
|
169
345
|
if (v instanceof LoroMap)
|
|
170
346
|
return wrapLoroMapProxy(v, batcher);
|
|
171
|
-
if (v instanceof
|
|
347
|
+
if (v instanceof LoroMovableList)
|
|
172
348
|
return v.toJSON();
|
|
173
349
|
return v.toJSON();
|
|
174
350
|
}
|
|
@@ -177,64 +353,54 @@ batcher) {
|
|
|
177
353
|
set(_t, key, value) {
|
|
178
354
|
if (typeof key === 'symbol')
|
|
179
355
|
return false;
|
|
356
|
+
if (guardKeys)
|
|
357
|
+
assertUsableRecordKey(key);
|
|
180
358
|
// Before the write, so a commit it triggers seals the preceding
|
|
181
359
|
// batch rather than splitting this record out of it.
|
|
182
360
|
batcher?.record(value);
|
|
183
|
-
|
|
184
|
-
// Plain-VALUE preservation: when the key holds a plain (non-container)
|
|
185
|
-
// object or array, the new structure is written as a plain value too,
|
|
186
|
-
// never promoted. This keeps a record body (`rec.params`, one JSON
|
|
187
|
-
// value whose whole-replace is the merge unit) a value across edits;
|
|
188
|
-
// promoting it would change the merge granularity and bloat the
|
|
189
|
-
// snapshot with per-element containers.
|
|
190
|
-
if (existing !== undefined &&
|
|
191
|
-
!isContainer(existing) &&
|
|
192
|
-
typeof existing === 'object' &&
|
|
193
|
-
existing !== null &&
|
|
194
|
-
typeof value === 'object' &&
|
|
195
|
-
value !== null) {
|
|
196
|
-
loroMap.set(key, toPlainDeep(value));
|
|
361
|
+
if (divert?.take(key, value))
|
|
197
362
|
return true;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// per frame instead of collapsing into the merge window.
|
|
204
|
-
const sub = existing instanceof LoroMap
|
|
205
|
-
? existing
|
|
206
|
-
: loroMap.setContainer(key, new LoroMap());
|
|
207
|
-
assignPlain(sub, value);
|
|
208
|
-
}
|
|
209
|
-
else if (Array.isArray(value)) {
|
|
210
|
-
const sub = existing instanceof LoroList
|
|
211
|
-
? existing
|
|
212
|
-
: loroMap.setContainer(key, new LoroList());
|
|
213
|
-
assignList(sub, value);
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
loroMap.set(key, value);
|
|
217
|
-
}
|
|
363
|
+
assignEntry(loroMap, key, value);
|
|
364
|
+
// Named to the batcher AFTER the write, never before: `record` above
|
|
365
|
+
// may have committed the batch this call arrived in, and a key named
|
|
366
|
+
// to that commit would be one whose write the commit does not carry.
|
|
367
|
+
batcher?.noteKeys([key]);
|
|
218
368
|
return true;
|
|
219
369
|
},
|
|
220
370
|
deleteProperty(_t, key) {
|
|
221
371
|
if (typeof key === 'symbol')
|
|
222
372
|
return false;
|
|
223
|
-
|
|
373
|
+
if (guardKeys)
|
|
374
|
+
assertUsableRecordKey(key);
|
|
375
|
+
batcher?.recordDelete(key);
|
|
376
|
+
divert?.drop(key);
|
|
224
377
|
loroMap.delete(key);
|
|
378
|
+
batcher?.noteKeys([key]);
|
|
225
379
|
return true;
|
|
226
380
|
},
|
|
227
381
|
has(_t, key) {
|
|
228
382
|
if (typeof key === 'symbol')
|
|
229
383
|
return false;
|
|
384
|
+
if (divert?.holds(key))
|
|
385
|
+
return true;
|
|
230
386
|
return loroMap.get(key) !== undefined;
|
|
231
387
|
},
|
|
232
388
|
ownKeys() {
|
|
233
|
-
|
|
389
|
+
if (!divert)
|
|
390
|
+
return loroMap.keys();
|
|
391
|
+
return [...new Set([...loroMap.keys(), ...divert.keys()])];
|
|
234
392
|
},
|
|
235
393
|
getOwnPropertyDescriptor(_t, key) {
|
|
236
394
|
if (typeof key === 'symbol')
|
|
237
395
|
return undefined;
|
|
396
|
+
if (divert?.holds(key)) {
|
|
397
|
+
return {
|
|
398
|
+
enumerable: true,
|
|
399
|
+
configurable: true,
|
|
400
|
+
writable: true,
|
|
401
|
+
value: divert.peek(key),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
238
404
|
// Single get per key. Do NOT iterate `loroMap.keys()` here: it makes
|
|
239
405
|
// Object.keys() O(N^2) at 50k entities.
|
|
240
406
|
const v = loroMap.get(key);
|
|
@@ -250,27 +416,95 @@ batcher) {
|
|
|
250
416
|
};
|
|
251
417
|
return new Proxy({}, handler);
|
|
252
418
|
}
|
|
253
|
-
// Array.prototype methods that mutate
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
419
|
+
// Array.prototype methods that mutate in place, applied to the container as
|
|
420
|
+
// the targeted edits they are rather than replayed over it.
|
|
421
|
+
//
|
|
422
|
+
// The list stores as a `LoroMovableList`, so a reposition is `move(from, to)`
|
|
423
|
+
// and a replacement is `set(pos, value)`, both of which name the position they
|
|
424
|
+
// touch. That keeps the ENTRY the merge unit: two peers reordering different
|
|
425
|
+
// entries commute, and two reordering the same one converge on a list holding
|
|
426
|
+
// it once, where a delete-plus-insert replay leaves it named twice. Each edit
|
|
427
|
+
// is also its own op, so the commit budget bounds these the way it bounds
|
|
428
|
+
// every other list write.
|
|
429
|
+
//
|
|
257
430
|
// `push` is fast-pathed natively above the generic fallback and never reaches
|
|
258
|
-
// this set.
|
|
259
|
-
const
|
|
260
|
-
'pop',
|
|
261
|
-
'shift',
|
|
262
|
-
'unshift',
|
|
263
|
-
'splice',
|
|
431
|
+
// this set; every other array method is a pure read.
|
|
432
|
+
const REORDERING_ARRAY_METHODS = new Set([
|
|
264
433
|
'sort',
|
|
265
434
|
'reverse',
|
|
435
|
+
]);
|
|
436
|
+
const REPLACING_ARRAY_METHODS = new Set([
|
|
266
437
|
'fill',
|
|
267
438
|
'copyWithin',
|
|
268
439
|
]);
|
|
440
|
+
/**
|
|
441
|
+
* Bring the container's order to `want` using moves, comparing by identity of
|
|
442
|
+
* the values already stored.
|
|
443
|
+
*
|
|
444
|
+
* A permutation is applied front to back: at each position, find where the
|
|
445
|
+
* wanted entry currently sits and move it up. Every entry lands in one move at
|
|
446
|
+
* most, and a position already correct costs nothing, so a `sort` over a
|
|
447
|
+
* nearly-ordered list writes nearly nothing.
|
|
448
|
+
*
|
|
449
|
+
* Duplicate values are matched left to right from the first position not yet
|
|
450
|
+
* settled, which is what keeps a list holding the same value twice from
|
|
451
|
+
* collapsing onto one of them.
|
|
452
|
+
*/
|
|
453
|
+
function applyOrder(target, want, batcher) {
|
|
454
|
+
const current = target.toJSON();
|
|
455
|
+
const print = (v) => JSON.stringify(v) ?? 'undefined';
|
|
456
|
+
// Where each value still sits, in order, so a lookup does not rescan.
|
|
457
|
+
const positions = new Map();
|
|
458
|
+
for (let i = 0; i < current.length; i += 1) {
|
|
459
|
+
const k = print(current[i]);
|
|
460
|
+
const at = positions.get(k);
|
|
461
|
+
if (at)
|
|
462
|
+
at.push(i);
|
|
463
|
+
else
|
|
464
|
+
positions.set(k, [i]);
|
|
465
|
+
}
|
|
466
|
+
for (let i = 0; i < want.length && i < current.length; i += 1) {
|
|
467
|
+
const k = print(want[i]);
|
|
468
|
+
const at = positions.get(k);
|
|
469
|
+
if (!at || at.length === 0)
|
|
470
|
+
continue;
|
|
471
|
+
// The leftmost occurrence at or after `i`; anything before it is settled.
|
|
472
|
+
let pick = -1;
|
|
473
|
+
for (let j = 0; j < at.length; j += 1) {
|
|
474
|
+
if (at[j] >= i) {
|
|
475
|
+
pick = j;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (pick === -1)
|
|
480
|
+
continue;
|
|
481
|
+
const from = at[pick];
|
|
482
|
+
at.splice(pick, 1);
|
|
483
|
+
if (from === i)
|
|
484
|
+
continue;
|
|
485
|
+
// A move costs the entry, not the list, so it is charged like one write.
|
|
486
|
+
batcher?.record(want[i]);
|
|
487
|
+
target.move(from, i);
|
|
488
|
+
// Everything between the destination and the source shifted up by one.
|
|
489
|
+
for (const list of positions.values()) {
|
|
490
|
+
for (let j = 0; j < list.length; j += 1) {
|
|
491
|
+
const p = list[j];
|
|
492
|
+
if (p >= i && p < from)
|
|
493
|
+
list[j] = p + 1;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
269
498
|
// An array-proxy for list mutators: index assignments, `.push` / `.splice`,
|
|
270
|
-
// and length-set route into the underlying
|
|
499
|
+
// and length-set route into the underlying LoroMovableList so the user writes
|
|
271
500
|
// idiomatic JS. Reads delegate to a fresh snapshot per access so a sequence
|
|
272
501
|
// of reads inside the mutator sees each write.
|
|
273
|
-
function wrapLoroListProxy(loroList
|
|
502
|
+
function wrapLoroListProxy(loroList,
|
|
503
|
+
// Threaded into nested element proxies too, so a field edit inside an
|
|
504
|
+
// element counts. A list takes the same bulk writes a map does (an app
|
|
505
|
+
// building rows, the record store's own order list), and nothing else bounds
|
|
506
|
+
// what one call puts in a single op.
|
|
507
|
+
batcher) {
|
|
274
508
|
const handler = {
|
|
275
509
|
get(_t, key) {
|
|
276
510
|
if (key === 'length')
|
|
@@ -287,13 +521,58 @@ function wrapLoroListProxy(loroList) {
|
|
|
287
521
|
if (isContainer(v)) {
|
|
288
522
|
// Engine-neutral view only (see the map proxy's get trap).
|
|
289
523
|
if (v instanceof LoroMap)
|
|
290
|
-
return wrapLoroMapProxy(v);
|
|
291
|
-
if (v instanceof
|
|
524
|
+
return wrapLoroMapProxy(v, batcher);
|
|
525
|
+
if (v instanceof LoroMovableList)
|
|
292
526
|
return v.toJSON();
|
|
293
527
|
return v.toJSON();
|
|
294
528
|
}
|
|
529
|
+
if (v !== null && typeof v === 'object') {
|
|
530
|
+
// A plain object / array element, the same case the map proxy
|
|
531
|
+
// wraps: hand back a view that writes a field edit through instead
|
|
532
|
+
// of a detached copy that swallows it.
|
|
533
|
+
//
|
|
534
|
+
// The write-back replaces the element as a plain VALUE rather than
|
|
535
|
+
// going through the `set` trap, which promotes a plain object to a
|
|
536
|
+
// container. Promoting here would change what merges on a field
|
|
537
|
+
// edit, and a field edit is exactly the operation that should not
|
|
538
|
+
// move the merge boundary.
|
|
539
|
+
//
|
|
540
|
+
// `set` rather than a delete plus an insert, for the reason
|
|
541
|
+
// `assignListPosition` carries: the pair commutes with a peer's
|
|
542
|
+
// concurrent write to this position and leaves the element stored
|
|
543
|
+
// twice. `set` takes a plain value, so it keeps the shape this
|
|
544
|
+
// branch exists to preserve.
|
|
545
|
+
return writeThroughBody(v, (next) => {
|
|
546
|
+
batcher?.record(next);
|
|
547
|
+
if (idx < loroList.length)
|
|
548
|
+
loroList.set(idx, next);
|
|
549
|
+
else
|
|
550
|
+
loroList.insert(idx, next);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
295
553
|
return v;
|
|
296
554
|
}
|
|
555
|
+
// Reposition an entry, which is the operation a plain array cannot
|
|
556
|
+
// state: spelled as a removal plus an insertion it merges with a peer's
|
|
557
|
+
// reposition of the same entry by keeping both insertions, and the entry
|
|
558
|
+
// ends up in the list twice.
|
|
559
|
+
if (key === 'move') {
|
|
560
|
+
return (rawFrom, rawTo) => {
|
|
561
|
+
const len = loroList.length;
|
|
562
|
+
const from = Math.trunc(Number(rawFrom));
|
|
563
|
+
let to = Math.trunc(Number(rawTo));
|
|
564
|
+
if (!Number.isFinite(from) || from < 0 || from >= len)
|
|
565
|
+
return;
|
|
566
|
+
if (!Number.isFinite(to))
|
|
567
|
+
return;
|
|
568
|
+
to = to < 0 ? Math.max(len + to, 0) : Math.min(to, len - 1);
|
|
569
|
+
if (to === from)
|
|
570
|
+
return;
|
|
571
|
+
// A move writes one entry, so it is charged like one write.
|
|
572
|
+
batcher?.record(plainListItem(loroList, from));
|
|
573
|
+
loroList.move(from, to);
|
|
574
|
+
};
|
|
575
|
+
}
|
|
297
576
|
// `push` is the hot path (the ordered-map primitive appends every new
|
|
298
577
|
// key through it; apps bulk-build lists with it), so append natively.
|
|
299
578
|
// The toArray + replay below is O(list) PER call, turning N appends
|
|
@@ -301,27 +580,115 @@ function wrapLoroListProxy(loroList) {
|
|
|
301
580
|
// is O(1).
|
|
302
581
|
if (key === 'push') {
|
|
303
582
|
return (...items) => {
|
|
304
|
-
for (const item of items)
|
|
583
|
+
for (const item of items) {
|
|
584
|
+
// Before the write, so a commit it triggers seals the preceding
|
|
585
|
+
// batch rather than splitting this element out of it.
|
|
586
|
+
batcher?.record(item);
|
|
305
587
|
insertListTail(loroList, item);
|
|
588
|
+
}
|
|
589
|
+
return loroList.length;
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
// The mutators that name the positions they touch, so each writes those
|
|
593
|
+
// positions and leaves the rest of the list alone. This is what keeps an
|
|
594
|
+
// element the merge unit: two peers removing different entries produce
|
|
595
|
+
// two targeted deletes that commute, where a replay of the whole list
|
|
596
|
+
// from each produces two full re-insertions that both survive.
|
|
597
|
+
if (key === 'splice') {
|
|
598
|
+
return (...args) => {
|
|
599
|
+
if (args.length === 0)
|
|
600
|
+
return [];
|
|
601
|
+
const len = loroList.length;
|
|
602
|
+
const rawStart = Number(args[0]);
|
|
603
|
+
let start = Number.isFinite(rawStart) ? Math.trunc(rawStart) : 0;
|
|
604
|
+
start = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
|
|
605
|
+
const deleteCount = args.length === 1
|
|
606
|
+
? len - start
|
|
607
|
+
: Math.min(Math.max(Math.trunc(Number(args[1])) || 0, 0), len - start);
|
|
608
|
+
// Read before writing, so the return value matches Array#splice.
|
|
609
|
+
const removed = [];
|
|
610
|
+
for (let i = 0; i < deleteCount; i += 1) {
|
|
611
|
+
removed.push(plainListItem(loroList, start + i));
|
|
612
|
+
}
|
|
613
|
+
const items = args.slice(2);
|
|
614
|
+
// A splice that removes and inserts over the same positions is a
|
|
615
|
+
// REPLACEMENT of them, which `l.splice(i, 1, x)` is the common way
|
|
616
|
+
// to write. Setting each such position states that as one op naming
|
|
617
|
+
// it, where deleting the run and re-inserting states it as a pair
|
|
618
|
+
// that commutes with a peer's concurrent write to the same position
|
|
619
|
+
// and leaves both entries stored. Only the part of the splice that
|
|
620
|
+
// genuinely changes the list's length is a delete or an insert.
|
|
621
|
+
const replaced = Math.min(deleteCount, items.length);
|
|
622
|
+
for (let i = 0; i < replaced; i += 1) {
|
|
623
|
+
batcher?.record(items[i]);
|
|
624
|
+
assignListPosition(loroList, start + i, items[i]);
|
|
625
|
+
}
|
|
626
|
+
if (deleteCount > replaced) {
|
|
627
|
+
loroList.delete(start + replaced, deleteCount - replaced);
|
|
628
|
+
}
|
|
629
|
+
for (let i = replaced; i < items.length; i += 1) {
|
|
630
|
+
batcher?.record(items[i]);
|
|
631
|
+
insertListAt(loroList, start + i, items[i]);
|
|
632
|
+
}
|
|
633
|
+
return removed;
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (key === 'pop') {
|
|
637
|
+
return () => {
|
|
638
|
+
const len = loroList.length;
|
|
639
|
+
if (len === 0)
|
|
640
|
+
return undefined;
|
|
641
|
+
const last = plainListItem(loroList, len - 1);
|
|
642
|
+
loroList.delete(len - 1, 1);
|
|
643
|
+
return last;
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (key === 'shift') {
|
|
647
|
+
return () => {
|
|
648
|
+
if (loroList.length === 0)
|
|
649
|
+
return undefined;
|
|
650
|
+
const first = plainListItem(loroList, 0);
|
|
651
|
+
loroList.delete(0, 1);
|
|
652
|
+
return first;
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (key === 'unshift') {
|
|
656
|
+
return (...items) => {
|
|
657
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
658
|
+
batcher?.record(items[i]);
|
|
659
|
+
insertListAt(loroList, i, items[i]);
|
|
660
|
+
}
|
|
306
661
|
return loroList.length;
|
|
307
662
|
};
|
|
308
663
|
}
|
|
309
664
|
// Remaining array methods: bind to a deep-plain `toJSON` snapshot so a
|
|
310
665
|
// callback reading an element's fields (`findIndex((c) => c.id === x)`,
|
|
311
666
|
// `find`, `filter`, `map`) sees plain objects, not the raw child
|
|
312
|
-
// containers `toArray` hands back (whose fields read undefined). A
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// Best-effort for v1; efficient mutation uses indexed assignment /
|
|
316
|
-
// length-set / `push`.
|
|
667
|
+
// containers `toArray` hands back (whose fields read undefined). A pure
|
|
668
|
+
// read writes nothing back, so a `find` inside a mutate leaves the list
|
|
669
|
+
// alone.
|
|
317
670
|
const arr = loroList.toJSON();
|
|
318
671
|
const val = arr[key];
|
|
319
672
|
if (typeof val === 'function') {
|
|
320
673
|
return (...args) => {
|
|
321
674
|
const fn = val;
|
|
322
675
|
const result = fn.apply(arr, args);
|
|
323
|
-
|
|
324
|
-
|
|
676
|
+
// The mutators run against the snapshot to decide the answer, then
|
|
677
|
+
// that answer is applied to the container as the edits it implies:
|
|
678
|
+
// moves for a reorder, a set per position for a replacement. What
|
|
679
|
+
// reaches the document is the positions that actually changed.
|
|
680
|
+
if (REORDERING_ARRAY_METHODS.has(key)) {
|
|
681
|
+
applyOrder(loroList, arr, batcher);
|
|
682
|
+
}
|
|
683
|
+
else if (REPLACING_ARRAY_METHODS.has(key)) {
|
|
684
|
+
for (let i = 0; i < arr.length && i < loroList.length; i += 1) {
|
|
685
|
+
const next = arr[i];
|
|
686
|
+
if (loroList.get(i) === next)
|
|
687
|
+
continue;
|
|
688
|
+
batcher?.record(next);
|
|
689
|
+
assignListPosition(loroList, i, next);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
325
692
|
return result;
|
|
326
693
|
};
|
|
327
694
|
}
|
|
@@ -337,21 +704,18 @@ function wrapLoroListProxy(loroList) {
|
|
|
337
704
|
}
|
|
338
705
|
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
|
339
706
|
const idx = Number(key);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
else {
|
|
353
|
-
loroList.insert(idx, value);
|
|
354
|
-
}
|
|
707
|
+
// Before the write, so a commit it triggers seals the preceding batch
|
|
708
|
+
// rather than splitting this element out of it.
|
|
709
|
+
batcher?.record(value);
|
|
710
|
+
// Replacing a position is `set`, which is ONE op at that position. A
|
|
711
|
+
// delete plus an insert is what the list has to do without one, and it
|
|
712
|
+
// leaves the entry duplicated when a peer edits the same position
|
|
713
|
+
// concurrently, because a list CRDT commutes the deletes and keeps
|
|
714
|
+
// both insertions.
|
|
715
|
+
if (idx < loroList.length)
|
|
716
|
+
assignListPosition(loroList, idx, value);
|
|
717
|
+
else
|
|
718
|
+
insertListAt(loroList, idx, value);
|
|
355
719
|
return true;
|
|
356
720
|
}
|
|
357
721
|
return false;
|
|
@@ -361,38 +725,444 @@ function wrapLoroListProxy(loroList) {
|
|
|
361
725
|
}
|
|
362
726
|
const TAG_FIELD = 'type';
|
|
363
727
|
const BODY_FIELD = 'params';
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
728
|
+
// The record keys one page packs, read off the live container without
|
|
729
|
+
// materialising its bodies. Two fields out of wasm rather than the whole
|
|
730
|
+
// container: a store's bodies are its bulk, and an index build asks every page
|
|
731
|
+
// this question. `key` has already been classified as a page, so an entry that
|
|
732
|
+
// decodes to nothing is a corrupt page rather than something else.
|
|
733
|
+
function pageKeyList(m, key) {
|
|
368
734
|
const v = m.get(key);
|
|
369
|
-
if (v instanceof LoroMap)
|
|
370
|
-
return
|
|
371
|
-
|
|
735
|
+
if (!(v instanceof LoroMap))
|
|
736
|
+
return [];
|
|
737
|
+
try {
|
|
738
|
+
return decodePageKeys(v.get(PAGE_KEYS_FIELD));
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
v.free();
|
|
742
|
+
}
|
|
372
743
|
}
|
|
373
744
|
// Decode a live paged `instances` map to the flat `key -> { type, params }`
|
|
374
745
|
// lookup. `toJSON` materialises the page containers to the plain shape the
|
|
375
746
|
// shared `flattenPagedLookup` consumes, so page flattening lives in one place.
|
|
376
747
|
// Only ever called on the handle's paged path (guarded by `_isPaged()` /
|
|
377
|
-
//
|
|
748
|
+
// the handle's page count), so the store is known paged. Pass `paged: true` rather than
|
|
378
749
|
// re-deriving it from a tag scan.
|
|
379
|
-
function flattenPagedMap(m) {
|
|
380
|
-
return flattenPagedLookup(m.toJSON(), true);
|
|
381
|
-
}
|
|
382
|
-
// Resolve a single record without flattening the whole store:
|
|
383
|
-
//
|
|
384
|
-
// proportional to one page, not the whole file.
|
|
385
|
-
function recordFromPage(m, recordKey,
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
750
|
+
function flattenPagedMap(m, into) {
|
|
751
|
+
return flattenPagedLookup(m.toJSON(), true, into);
|
|
752
|
+
}
|
|
753
|
+
// Resolve a single record without flattening the whole store: take the key's
|
|
754
|
+
// page from the index, decode only that page, find the triple. Keeps a per-key
|
|
755
|
+
// `get` proportional to one page, not the whole file.
|
|
756
|
+
function recordFromPage(m, recordKey, memo, index, patchIndex) {
|
|
757
|
+
const base = recordBeforePatches(m, recordKey, memo, index);
|
|
758
|
+
if (base === undefined)
|
|
388
759
|
return undefined;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
760
|
+
// The patches fold on LAST, over whichever copy the passes above resolved,
|
|
761
|
+
// matching `flattenPagedLookup` so a keyed read and a bulk read cannot
|
|
762
|
+
// disagree about an edited record.
|
|
763
|
+
const keys = patchIndex?.keysFor(recordKey);
|
|
764
|
+
if (keys === undefined || keys.length === 0)
|
|
765
|
+
return base;
|
|
766
|
+
const patches = [];
|
|
767
|
+
for (const key of keys) {
|
|
768
|
+
const parsed = parsePatchKey(key);
|
|
769
|
+
if (parsed === null)
|
|
770
|
+
continue;
|
|
771
|
+
patches.push({
|
|
772
|
+
peer: parsed.peer,
|
|
773
|
+
counter: parsed.counter,
|
|
774
|
+
fields: plainEntry(m, key),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return applyRecordPatches(base, patches);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Record key -> the patch keys editing it.
|
|
781
|
+
*
|
|
782
|
+
* Kept apart from `PagedKeyIndex` because the two cost different things. That
|
|
783
|
+
* index answers WHICH PAGE holds a record, which costs a read of every page's
|
|
784
|
+
* key list. A patch is a plain map key, so learning every patch costs a walk
|
|
785
|
+
* of the key list and no decode, which every keyed read can afford. Without it
|
|
786
|
+
* a `get` returns the page's pre-edit copy while `read` returns the edited
|
|
787
|
+
* one.
|
|
788
|
+
*/
|
|
789
|
+
class PatchIndex {
|
|
790
|
+
_byRecord = new Map();
|
|
791
|
+
_version = null;
|
|
792
|
+
/** Rebuild against `m` unless the document is at the version this index was
|
|
793
|
+
* built at. A mutation's own writes do not go through here: they call
|
|
794
|
+
* `note` / `clear`, which keep the index current while the open transaction
|
|
795
|
+
* holds the version still. */
|
|
796
|
+
refresh(m, version) {
|
|
797
|
+
if (this._version !== null && sameStateVersion(this._version, version)) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
this._version = version;
|
|
801
|
+
this._byRecord.clear();
|
|
802
|
+
for (const key of m.keys()) {
|
|
803
|
+
const parsed = parsePatchKey(key);
|
|
804
|
+
if (parsed === null)
|
|
805
|
+
continue;
|
|
806
|
+
const list = this._byRecord.get(parsed.recordKey);
|
|
807
|
+
if (list)
|
|
808
|
+
list.push(key);
|
|
809
|
+
else
|
|
810
|
+
this._byRecord.set(parsed.recordKey, [key]);
|
|
392
811
|
}
|
|
393
812
|
}
|
|
813
|
+
/** The patch keys editing `recordKey`, in no particular order (the fold
|
|
814
|
+
* orders them by counter and peer). */
|
|
815
|
+
keysFor(recordKey) {
|
|
816
|
+
return this._byRecord.get(recordKey);
|
|
817
|
+
}
|
|
818
|
+
/** The counter a new patch on `recordKey` must carry: one above every
|
|
819
|
+
* counter the record already holds.
|
|
820
|
+
*
|
|
821
|
+
* Per RECORD, because the fold's order has to be causal and this index is
|
|
822
|
+
* what a writer has merged. A writer that has seen an earlier patch on this
|
|
823
|
+
* record mints above it and so applies over it; two writers that have not
|
|
824
|
+
* seen each other mint the same number and are tied apart by peer id, which
|
|
825
|
+
* is the only case where the answer is arbitrary and the only case where it
|
|
826
|
+
* may be. Counting the writer's own edits instead orders patches by how
|
|
827
|
+
* busy each session was, which loses the later edit. */
|
|
828
|
+
nextCounterFor(recordKey) {
|
|
829
|
+
const keys = this._byRecord.get(recordKey);
|
|
830
|
+
if (keys === undefined)
|
|
831
|
+
return 1;
|
|
832
|
+
let highest = 0;
|
|
833
|
+
for (const key of keys) {
|
|
834
|
+
const parsed = parsePatchKey(key);
|
|
835
|
+
if (parsed === null)
|
|
836
|
+
continue;
|
|
837
|
+
const counter = Number(parsed.counter);
|
|
838
|
+
if (Number.isFinite(counter) && counter > highest)
|
|
839
|
+
highest = counter;
|
|
840
|
+
}
|
|
841
|
+
return highest + 1;
|
|
842
|
+
}
|
|
843
|
+
/** Record a patch this build just wrote, so a read later in the same
|
|
844
|
+
* mutation sees it without a rebuild. */
|
|
845
|
+
note(patchKey, recordKey) {
|
|
846
|
+
const list = this._byRecord.get(recordKey);
|
|
847
|
+
if (list)
|
|
848
|
+
list.push(patchKey);
|
|
849
|
+
else
|
|
850
|
+
this._byRecord.set(recordKey, [patchKey]);
|
|
851
|
+
}
|
|
852
|
+
/** Forget `recordKey`'s patches, for a caller that has just removed them. */
|
|
853
|
+
clear(recordKey) {
|
|
854
|
+
this._byRecord.delete(recordKey);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* The record the map holds under `recordKey` ITSELF, beside the pages rather
|
|
859
|
+
* than under a reserved key, or undefined when it holds none.
|
|
860
|
+
*
|
|
861
|
+
* A record under its own bare key is every record written before the map
|
|
862
|
+
* paged, plus what a client that did not know it was paged leaves behind. The
|
|
863
|
+
* store's own entries are the ones under `RESERVED_KEY_PREFIX`, so a record
|
|
864
|
+
* key never names one and the value is a record whatever it holds.
|
|
865
|
+
*
|
|
866
|
+
* Both the read path and the write path ask through this, so they cannot
|
|
867
|
+
* disagree about which entries of a paged map are records.
|
|
868
|
+
*/
|
|
869
|
+
function strayRecordAt(m, recordKey) {
|
|
870
|
+
if (isReservedKey(recordKey))
|
|
871
|
+
return undefined;
|
|
872
|
+
return plainEntry(m, recordKey);
|
|
873
|
+
}
|
|
874
|
+
function recordBeforePatches(m, recordKey, memo, index) {
|
|
875
|
+
// A reserved key names one of the store's own entries, never a record, so a
|
|
876
|
+
// read of one answers as it would for a key nothing holds.
|
|
877
|
+
if (isReservedKey(recordKey))
|
|
878
|
+
return undefined;
|
|
879
|
+
// A delete a page still holds is its own entry, and it outranks every copy
|
|
880
|
+
// of the record, so it is resolved first. That is one map read this path
|
|
881
|
+
// would not otherwise make, and it is what keeps a concurrent edit from
|
|
882
|
+
// resurrecting a record its own reader was told is gone.
|
|
883
|
+
if (entryExists(m, tombstoneKeyFor(recordKey)))
|
|
884
|
+
return undefined;
|
|
885
|
+
// Same precedence as `flattenPagedLookup`, so a keyed read and a bulk read
|
|
886
|
+
// cannot disagree about which copy of a record wins: an edit written beside
|
|
887
|
+
// the pages, then a record held under its own key, then the page.
|
|
888
|
+
//
|
|
889
|
+
// The overlay slot is checked before the index is consulted, and its
|
|
890
|
+
// presence decides on its own: a remote edit that landed after the index was
|
|
891
|
+
// built rewrites that container, which this read reaches whatever the index
|
|
892
|
+
// says.
|
|
893
|
+
const own = plainEntry(m, overlayKeyFor(recordKey));
|
|
894
|
+
if (own !== undefined)
|
|
895
|
+
return own;
|
|
896
|
+
const stray = strayRecordAt(m, recordKey);
|
|
897
|
+
if (stray !== undefined)
|
|
898
|
+
return stray;
|
|
899
|
+
// A page is filled in creation order, so the index is the only thing that
|
|
900
|
+
// says which one holds this record. No index, no page answer.
|
|
901
|
+
const pageKey = index?.readsFromPage(recordKey);
|
|
902
|
+
if (pageKey === undefined)
|
|
903
|
+
return undefined;
|
|
904
|
+
const page = plainEntry(m, pageKey);
|
|
905
|
+
if (memo)
|
|
906
|
+
return memo.resolve(pageKey, page).get(recordKey);
|
|
907
|
+
for (const packed of decodePageRecords(page)) {
|
|
908
|
+
if (packed[0] === recordKey)
|
|
909
|
+
return unpackRecord(packed);
|
|
910
|
+
}
|
|
394
911
|
return undefined;
|
|
395
912
|
}
|
|
913
|
+
/**
|
|
914
|
+
* Which page each record in a paged map lives in.
|
|
915
|
+
*
|
|
916
|
+
* A page is filled in creation order (see `SESSION_PAGE_PREFIX`), so the only
|
|
917
|
+
* way to find a record in one is to have read the pages' key lists. This reads
|
|
918
|
+
* them once and keeps the answer.
|
|
919
|
+
*
|
|
920
|
+
* Validity is stamped with the document's version, the same way `PatchIndex`
|
|
921
|
+
* stamps itself: one commit that both removes an entry and adds one leaves the
|
|
922
|
+
* map's entry COUNT where it found it while moving which keys are there, and a
|
|
923
|
+
* count-stamped index then answers from a page set the map no longer has, with
|
|
924
|
+
* nothing to correct it. That divergence has no read that resolves it, since a
|
|
925
|
+
* whole-store `read` walks the map and a keyed `get` walks the index, so the
|
|
926
|
+
* two disagree for as long as the handle lives; and a write consulting the
|
|
927
|
+
* stale index takes a key it already holds for an addition and seals a second
|
|
928
|
+
* copy of it into a new page.
|
|
929
|
+
*/
|
|
930
|
+
class PagedKeyIndex {
|
|
931
|
+
_stateVersion;
|
|
932
|
+
// Record key -> the page whose body PACKS it. One map for the whole store,
|
|
933
|
+
// so a build costs one insert per record: a second map alongside it costs
|
|
934
|
+
// far more than the extra inserts (measured at 250,000 records, 415 ms
|
|
935
|
+
// against 62 ms, the two together outgrowing what one keeps resident).
|
|
936
|
+
_inPage = new Map();
|
|
937
|
+
// The records a container of its own answers for, which outrank the page's
|
|
938
|
+
// copy on a read, and the ones a tombstone removes. Both are bounded by the
|
|
939
|
+
// records actually edited rather than by the store's size, so they stay
|
|
940
|
+
// small beside the page map.
|
|
941
|
+
//
|
|
942
|
+
// Kept apart from the page map because the two answer different questions
|
|
943
|
+
// and diverge: a record read through its overlay container is still packed
|
|
944
|
+
// into the page it was written into, and a page cannot be rewritten to drop
|
|
945
|
+
// it. That is exactly what decides whether deleting the record needs a
|
|
946
|
+
// tombstone or can just remove its container, so conflating the two loses
|
|
947
|
+
// the record's deletion.
|
|
948
|
+
_contained = new Set();
|
|
949
|
+
_removed = new Set();
|
|
950
|
+
// The document version this index's answers were last known to match, or
|
|
951
|
+
// null while it has never been built.
|
|
952
|
+
_version = null;
|
|
953
|
+
// Rebuilds this index has done. A rebuild decodes every page, so an edit
|
|
954
|
+
// that forces one costs the whole store rather than one record; a test
|
|
955
|
+
// reads this to pin that an ordinary edit does not.
|
|
956
|
+
rebuilds = 0;
|
|
957
|
+
constructor(_stateVersion) {
|
|
958
|
+
this._stateVersion = _stateVersion;
|
|
959
|
+
}
|
|
960
|
+
/** Whether the document has moved since the last build, and so whether this
|
|
961
|
+
* index has to be built again before it answers. */
|
|
962
|
+
stale() {
|
|
963
|
+
return (this._version === null ||
|
|
964
|
+
!sameStateVersion(this._version, this._stateVersion()));
|
|
965
|
+
}
|
|
966
|
+
/** Take this index's answers from a walk that is already resolving them (a
|
|
967
|
+
* whole-store read decodes every page, which is the same question), rather
|
|
968
|
+
* than decoding the store a second time on the first keyed read. The sink
|
|
969
|
+
* is called from `flattenPagedLookup`'s own three passes, so the entries
|
|
970
|
+
* arrive in the order `refresh` walks them.
|
|
971
|
+
*
|
|
972
|
+
* The stamp is dropped up front and set again by `stamp`, so a walk that
|
|
973
|
+
* throws leaves an index that rebuilds rather than an empty one that reads
|
|
974
|
+
* as current. */
|
|
975
|
+
harvest() {
|
|
976
|
+
this.rebuilds += 1;
|
|
977
|
+
this._version = null;
|
|
978
|
+
this._inPage.clear();
|
|
979
|
+
this._contained.clear();
|
|
980
|
+
this._removed.clear();
|
|
981
|
+
return {
|
|
982
|
+
page: (recordKey, pageKey) => {
|
|
983
|
+
this._inPage.set(recordKey, pageKey);
|
|
984
|
+
},
|
|
985
|
+
contained: (recordKey) => {
|
|
986
|
+
this._contained.add(recordKey);
|
|
987
|
+
},
|
|
988
|
+
removed: (recordKey) => {
|
|
989
|
+
this._removed.add(recordKey);
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
/** Stamp a harvested index as current. */
|
|
994
|
+
stamp() {
|
|
995
|
+
this._version = this._stateVersion();
|
|
996
|
+
}
|
|
997
|
+
/** Rebuild against `m` if the document has moved since the last build. */
|
|
998
|
+
refresh(m) {
|
|
999
|
+
const version = this._stateVersion();
|
|
1000
|
+
if (this._version !== null && sameStateVersion(this._version, version)) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
this.rebuilds += 1;
|
|
1004
|
+
this._inPage.clear();
|
|
1005
|
+
this._contained.clear();
|
|
1006
|
+
this._removed.clear();
|
|
1007
|
+
// Same passes, in the same order, as `flattenPagedLookup`: pages, then
|
|
1008
|
+
// records under their own key, then the edits beside them, then the
|
|
1009
|
+
// deletes. A key's location has to agree with which copy of it a read
|
|
1010
|
+
// returns, and resolving in one pass would make that depend on the order
|
|
1011
|
+
// the map hands its keys back, so a record a tombstone removes could be
|
|
1012
|
+
// indexed to the page still holding it.
|
|
1013
|
+
const strays = [];
|
|
1014
|
+
const overlays = [];
|
|
1015
|
+
const removed = [];
|
|
1016
|
+
for (const key of m.keys()) {
|
|
1017
|
+
const entry = classifyStoreKey(key);
|
|
1018
|
+
switch (entry.kind) {
|
|
1019
|
+
case 'record':
|
|
1020
|
+
strays.push(key);
|
|
1021
|
+
break;
|
|
1022
|
+
case 'overlay':
|
|
1023
|
+
overlays.push(entry.recordKey);
|
|
1024
|
+
break;
|
|
1025
|
+
case 'tombstone':
|
|
1026
|
+
removed.push(entry.recordKey);
|
|
1027
|
+
break;
|
|
1028
|
+
case 'page':
|
|
1029
|
+
// A page is asked for its KEY LIST alone. Materialising the whole
|
|
1030
|
+
// container instead would copy every record body in the store out of
|
|
1031
|
+
// wasm and then decompress and parse it, to learn what the keys
|
|
1032
|
+
// already say: measured at 250,000 records, 1.48 s against 0.45 s.
|
|
1033
|
+
for (const recordKey of pageKeyList(m, key)) {
|
|
1034
|
+
this._inPage.set(recordKey, key);
|
|
1035
|
+
}
|
|
1036
|
+
break;
|
|
1037
|
+
// A patch names no record of its own, so it must not be taken for a
|
|
1038
|
+
// stray. Which records they edit is `PatchIndex`'s business.
|
|
1039
|
+
case 'patch':
|
|
1040
|
+
case 'unknown':
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
for (const key of strays)
|
|
1045
|
+
this._contained.add(key);
|
|
1046
|
+
for (const recordKey of overlays)
|
|
1047
|
+
this._contained.add(recordKey);
|
|
1048
|
+
for (const recordKey of removed) {
|
|
1049
|
+
this._removed.add(recordKey);
|
|
1050
|
+
this._contained.delete(recordKey);
|
|
1051
|
+
}
|
|
1052
|
+
this._version = version;
|
|
1053
|
+
}
|
|
1054
|
+
/** Every record key the map stores, from the page key lists and the entries
|
|
1055
|
+
* that outrank them. Answered without decoding a page BODY, which is what
|
|
1056
|
+
* separates it from a whole-store read: the key lists alone are measured at
|
|
1057
|
+
* 0.45 s against 1.48 s for the bodies at 250,000 records. */
|
|
1058
|
+
liveKeys() {
|
|
1059
|
+
const out = new Set();
|
|
1060
|
+
for (const key of this._inPage.keys()) {
|
|
1061
|
+
if (!this._removed.has(key))
|
|
1062
|
+
out.add(key);
|
|
1063
|
+
}
|
|
1064
|
+
for (const key of this._contained)
|
|
1065
|
+
out.add(key);
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
/** Whether the map stores a record under `recordKey`. */
|
|
1069
|
+
has(recordKey) {
|
|
1070
|
+
if (this._removed.has(recordKey))
|
|
1071
|
+
return false;
|
|
1072
|
+
return this._contained.has(recordKey) || this._inPage.has(recordKey);
|
|
1073
|
+
}
|
|
1074
|
+
/** The page a keyed read should decode for `recordKey`, or undefined when a
|
|
1075
|
+
* container of its own answers for it (or it is not stored at all). */
|
|
1076
|
+
readsFromPage(recordKey) {
|
|
1077
|
+
if (this._contained.has(recordKey) || this._removed.has(recordKey)) {
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
return this._inPage.get(recordKey);
|
|
1081
|
+
}
|
|
1082
|
+
/** Whether some page's body still holds `recordKey`, and so whether deleting
|
|
1083
|
+
* it has to be stated as a tombstone. */
|
|
1084
|
+
packedInPage(recordKey) {
|
|
1085
|
+
return this._inPage.has(recordKey);
|
|
1086
|
+
}
|
|
1087
|
+
/** Record a page write this handle just made: every record `pageKey` now
|
|
1088
|
+
* packs. Takes the whole page rather than a record at a time so the version
|
|
1089
|
+
* is read once for the write instead of once per record, which a bulk write
|
|
1090
|
+
* reaches hundreds of thousands of times. */
|
|
1091
|
+
notePage(pageKey, recordKeys) {
|
|
1092
|
+
for (const recordKey of recordKeys) {
|
|
1093
|
+
this._inPage.set(recordKey, pageKey);
|
|
1094
|
+
this._contained.delete(recordKey);
|
|
1095
|
+
this._removed.delete(recordKey);
|
|
1096
|
+
}
|
|
1097
|
+
this._restamp();
|
|
1098
|
+
}
|
|
1099
|
+
/** Record that a container of its own now answers for `recordKey`. */
|
|
1100
|
+
noteContained(recordKey) {
|
|
1101
|
+
this._contained.add(recordKey);
|
|
1102
|
+
this._removed.delete(recordKey);
|
|
1103
|
+
this._restamp();
|
|
1104
|
+
}
|
|
1105
|
+
/** Note a write that moved no record between a page and a container, so the
|
|
1106
|
+
* stamp stays current and the next read does not rebuild.
|
|
1107
|
+
*
|
|
1108
|
+
* A patch is this: it names no record of its own, so writing or removing
|
|
1109
|
+
* one leaves every answer this index gives unchanged while moving the
|
|
1110
|
+
* document the stamp is taken against. Without it a session-paged store
|
|
1111
|
+
* rebuilds on the access after each edit, and a rebuild decodes every page:
|
|
1112
|
+
* measured at 20,000 records, 79 ms per edit against 2.8 ms. */
|
|
1113
|
+
noteSideEntries() {
|
|
1114
|
+
this._restamp();
|
|
1115
|
+
}
|
|
1116
|
+
/** Record that `recordKey` is no longer stored. What a page still holds is
|
|
1117
|
+
* left alone: the page keeps its copy, and a later re-add has to know that
|
|
1118
|
+
* removing the tombstone alone would resurrect it. */
|
|
1119
|
+
noteRemoved(recordKey) {
|
|
1120
|
+
this._removed.add(recordKey);
|
|
1121
|
+
this._contained.delete(recordKey);
|
|
1122
|
+
this._restamp();
|
|
1123
|
+
}
|
|
1124
|
+
// Carry the stamp forward over a write this index has just taken account of.
|
|
1125
|
+
// Every caller notes AFTER its writes, so the version read here is the one
|
|
1126
|
+
// the noted content matches. Committing does not move the version, so a
|
|
1127
|
+
// mutation that commits as it goes stays stamped throughout.
|
|
1128
|
+
//
|
|
1129
|
+
// An index that has never been built stays unbuilt: stamping one fresh would
|
|
1130
|
+
// leave it empty and authoritative.
|
|
1131
|
+
_restamp() {
|
|
1132
|
+
if (this._version === null)
|
|
1133
|
+
return;
|
|
1134
|
+
this._version = this._stateVersion();
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* A one-entry memo of the last decoded page, so repeated keyed reads inside
|
|
1139
|
+
* one page decode it once. Keyed on the page's stored BODY, which is one
|
|
1140
|
+
* immutable string, so any write that changes the page misses and a stale
|
|
1141
|
+
* read is not expressible.
|
|
1142
|
+
*/
|
|
1143
|
+
class PageMemo {
|
|
1144
|
+
_pageKey = null;
|
|
1145
|
+
_body = undefined;
|
|
1146
|
+
_records = new Map();
|
|
1147
|
+
resolve(pageKey, page) {
|
|
1148
|
+
const body = decodeRecordValue(page)?.body;
|
|
1149
|
+
if (this._pageKey === pageKey && this._body === body)
|
|
1150
|
+
return this._records;
|
|
1151
|
+
const records = new Map();
|
|
1152
|
+
for (const packed of decodePageRecords(page)) {
|
|
1153
|
+
records.set(packed[0], unpackRecord(packed));
|
|
1154
|
+
}
|
|
1155
|
+
this._pageKey = pageKey;
|
|
1156
|
+
this._body = body;
|
|
1157
|
+
this._records = records;
|
|
1158
|
+
return records;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/** Whether a page still holds this record, which decides whether a delete
|
|
1162
|
+
* needs a marker or can just remove the record's own container. */
|
|
1163
|
+
function pageHoldsRecord(recordKey, index) {
|
|
1164
|
+
return index !== null && index.packedInPage(recordKey);
|
|
1165
|
+
}
|
|
396
1166
|
// ---- Change deltas (`{ delta: true }` map subscribers) ----------------
|
|
397
1167
|
//
|
|
398
1168
|
// A delta subscriber reports which keys changed since the previous fire so a
|
|
@@ -406,54 +1176,75 @@ function recordFromPage(m, recordKey, pageCount) {
|
|
|
406
1176
|
// the changed PAGE. Recover record granularity with a per-subscription
|
|
407
1177
|
// page -> keys index, diffing a changed page's current keys against it; a
|
|
408
1178
|
// deleted record was in the prior key set and absent from the rewritten page.
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
function
|
|
419
|
-
|
|
420
|
-
|
|
1179
|
+
// How many path segments name `m` itself. An event's `path` is absolute from
|
|
1180
|
+
// the document root, so a descendant event's entry key is the segment at this
|
|
1181
|
+
// index: 1 for a root container (`path[0]` is the root key), one more for each
|
|
1182
|
+
// container it is nested inside.
|
|
1183
|
+
//
|
|
1184
|
+
// Measured from the container rather than from the handle's anchor chain
|
|
1185
|
+
// because the two can disagree: a scoped handle over a paged store resolves
|
|
1186
|
+
// its record through the copy that answers for it, which sits one level below
|
|
1187
|
+
// the root either way but under a key the anchor does not name.
|
|
1188
|
+
function containerDepth(m) {
|
|
1189
|
+
let depth = 0;
|
|
1190
|
+
let current = m;
|
|
1191
|
+
while (current) {
|
|
1192
|
+
depth += 1;
|
|
1193
|
+
current = current.parent();
|
|
1194
|
+
}
|
|
1195
|
+
return depth;
|
|
421
1196
|
}
|
|
422
1197
|
// Decode one page to its `key -> { type, params }` records in a single pass,
|
|
423
1198
|
// so a paged delta gets the changed records' VALUES from the same decode it
|
|
424
1199
|
// uses to find the changed keys (a second per-key decode would make a large
|
|
425
|
-
// initial build O(records x page-size)).
|
|
1200
|
+
// initial build O(records x page-size)). Null for a page key nothing holds,
|
|
1201
|
+
// which is how a delta tells a rewritten page from a removed one.
|
|
426
1202
|
function pageRecords(m, pageKey) {
|
|
427
|
-
const page =
|
|
428
|
-
if (
|
|
1203
|
+
const page = plainEntry(m, pageKey);
|
|
1204
|
+
if (page === undefined)
|
|
429
1205
|
return null;
|
|
430
1206
|
const records = new Map();
|
|
431
|
-
for (const packed of
|
|
432
|
-
|
|
433
|
-
records.set(packed[0], { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] });
|
|
434
|
-
}
|
|
1207
|
+
for (const packed of decodePageRecords(page)) {
|
|
1208
|
+
records.set(packed[0], unpackRecord(packed));
|
|
435
1209
|
}
|
|
436
1210
|
return records;
|
|
437
1211
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1212
|
+
/** Note the record a store entry answers for, if it answers for one. */
|
|
1213
|
+
function noteShadowedRecord(shadowed, key) {
|
|
1214
|
+
const entry = classifyStoreKey(key);
|
|
1215
|
+
switch (entry.kind) {
|
|
1216
|
+
case 'patch':
|
|
1217
|
+
shadowed.add(entry.parsed.recordKey);
|
|
1218
|
+
break;
|
|
1219
|
+
case 'overlay':
|
|
1220
|
+
case 'record':
|
|
1221
|
+
case 'tombstone':
|
|
1222
|
+
shadowed.add(entry.recordKey);
|
|
1223
|
+
break;
|
|
1224
|
+
case 'page':
|
|
1225
|
+
case 'unknown':
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
446
1228
|
}
|
|
447
|
-
function
|
|
448
|
-
const
|
|
449
|
-
for (const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
1229
|
+
function shadowedRecords(m) {
|
|
1230
|
+
const shadowed = new Set();
|
|
1231
|
+
for (const key of m.keys())
|
|
1232
|
+
noteShadowedRecord(shadowed, key);
|
|
1233
|
+
return shadowed;
|
|
1234
|
+
}
|
|
1235
|
+
function buildPagedDeltaIndex(m) {
|
|
1236
|
+
const pages = new Map();
|
|
1237
|
+
const shadowed = new Set();
|
|
1238
|
+
for (const key of m.keys()) {
|
|
1239
|
+
if (classifyStoreKey(key).kind === 'page') {
|
|
1240
|
+
pages.set(key, new Set(pageKeyList(m, key)));
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
noteShadowedRecord(shadowed, key);
|
|
453
1244
|
}
|
|
454
|
-
return
|
|
1245
|
+
return { pages, shadowed };
|
|
455
1246
|
}
|
|
456
|
-
function unpagedMapDelta(m,
|
|
1247
|
+
function unpagedMapDelta(m, depth, batch) {
|
|
457
1248
|
const setKeys = new Set();
|
|
458
1249
|
const deleted = new Set();
|
|
459
1250
|
for (const ev of batch.events) {
|
|
@@ -467,11 +1258,11 @@ function unpagedMapDelta(m, rootKey, batch) {
|
|
|
467
1258
|
setKeys.add(key);
|
|
468
1259
|
}
|
|
469
1260
|
}
|
|
470
|
-
else
|
|
471
|
-
// A descendant event (a field edit on
|
|
472
|
-
// Every batch event is within
|
|
473
|
-
//
|
|
474
|
-
const key = ev.path[
|
|
1261
|
+
else {
|
|
1262
|
+
// A descendant event (a field edit on an entry's nested container).
|
|
1263
|
+
// Every batch event is within this map's subtree, so the entry's key is
|
|
1264
|
+
// the path segment directly under it, at this map's own depth.
|
|
1265
|
+
const key = ev.path[depth];
|
|
475
1266
|
if (typeof key === 'string')
|
|
476
1267
|
setKeys.add(key);
|
|
477
1268
|
}
|
|
@@ -480,34 +1271,171 @@ function unpagedMapDelta(m, rootKey, batch) {
|
|
|
480
1271
|
setKeys.delete(key);
|
|
481
1272
|
const set = new Map();
|
|
482
1273
|
for (const key of setKeys)
|
|
483
|
-
set.set(key,
|
|
1274
|
+
set.set(key, plainEntry(m, key));
|
|
484
1275
|
return { set, deleted: [...deleted] };
|
|
485
1276
|
}
|
|
486
|
-
|
|
1277
|
+
/**
|
|
1278
|
+
* Read a changed page out to the records it holds, and name the ones it lost.
|
|
1279
|
+
*
|
|
1280
|
+
* A page is one value, so a change to it says nothing about WHICH of its
|
|
1281
|
+
* records moved; the index of what the page held before is what recovers that.
|
|
1282
|
+
* Both routes a page change arrives by resolve through here so they cannot
|
|
1283
|
+
* disagree about what a rewrite meant.
|
|
1284
|
+
*
|
|
1285
|
+
* A record something outside the page answers for is RESOLVED rather than read
|
|
1286
|
+
* out of the page, for the same reason the entry cases resolve: the page's copy
|
|
1287
|
+
* of it is one of several the store holds, and it is the one every read
|
|
1288
|
+
* discards. Reading it out hands a subscriber the value an edit replaced, or
|
|
1289
|
+
* puts a deleted record back into a view that then holds it for good.
|
|
1290
|
+
*/
|
|
1291
|
+
function expandPage(m, pageKey, index, set, deleted, expanded, resolve) {
|
|
1292
|
+
if (expanded.has(pageKey))
|
|
1293
|
+
return;
|
|
1294
|
+
expanded.add(pageKey);
|
|
1295
|
+
const before = index.pages.get(pageKey);
|
|
1296
|
+
const after = pageRecords(m, pageKey);
|
|
1297
|
+
if (after) {
|
|
1298
|
+
for (const [key, value] of after) {
|
|
1299
|
+
if (!index.shadowed.has(key)) {
|
|
1300
|
+
set.set(key, value);
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const live = resolve(key);
|
|
1304
|
+
if (live === undefined)
|
|
1305
|
+
deleted.add(key);
|
|
1306
|
+
else
|
|
1307
|
+
set.set(key, live);
|
|
1308
|
+
}
|
|
1309
|
+
index.pages.set(pageKey, new Set(after.keys()));
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
index.pages.delete(pageKey);
|
|
1313
|
+
}
|
|
1314
|
+
if (before) {
|
|
1315
|
+
for (const key of before) {
|
|
1316
|
+
if (after?.has(key))
|
|
1317
|
+
continue;
|
|
1318
|
+
// A record the page dropped that the store still answers for is not
|
|
1319
|
+
// gone, and the copy that answers for it is one this rewrite did not
|
|
1320
|
+
// touch, so the change says nothing about its value either.
|
|
1321
|
+
if (index.shadowed.has(key) && resolve(key) !== undefined)
|
|
1322
|
+
continue;
|
|
1323
|
+
deleted.add(key);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function pagedMapDelta(m, batch, index, resolve) {
|
|
487
1328
|
const set = new Map();
|
|
488
1329
|
const deleted = new Set();
|
|
1330
|
+
// Every entry that answers for a record is noted before any page is read
|
|
1331
|
+
// out. An entry and a page carrying the same record can arrive in either
|
|
1332
|
+
// order within one batch, and both write the record into `set`, so a page
|
|
1333
|
+
// read out first would report the copy the entry supersedes.
|
|
489
1334
|
for (const ev of batch.events) {
|
|
490
|
-
if (ev.target
|
|
1335
|
+
if (ev.target === m.id && ev.diff.type === 'map') {
|
|
1336
|
+
for (const changedKey of Object.keys(ev.diff.updated)) {
|
|
1337
|
+
noteShadowedRecord(index.shadowed, changedKey);
|
|
1338
|
+
}
|
|
491
1339
|
continue;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
1340
|
+
}
|
|
1341
|
+
const segment = ev.path[1];
|
|
1342
|
+
if (typeof segment === 'string')
|
|
1343
|
+
noteShadowedRecord(index.shadowed, segment);
|
|
1344
|
+
}
|
|
1345
|
+
// Pages this batch has already been read out of. One rewrite can reach here
|
|
1346
|
+
// from both directions (the map-level diff and the page's own container
|
|
1347
|
+
// event), and a page body is gzipped, so decoding it twice per batch is real
|
|
1348
|
+
// work on a store whose pages a pass rewrites all at once.
|
|
1349
|
+
const expanded = new Set();
|
|
1350
|
+
// A patch key names no record, so the record it edits is resolved once at
|
|
1351
|
+
// the end however many patches this batch carried for it. Reporting the
|
|
1352
|
+
// patch key itself would name a record no reader has, and dropping it (it
|
|
1353
|
+
// is neither an overlay key nor a page) would leave a subscriber's view
|
|
1354
|
+
// showing the pre-edit value.
|
|
1355
|
+
const patched = new Set();
|
|
1356
|
+
for (const ev of batch.events) {
|
|
1357
|
+
if (ev.target === m.id && ev.diff.type === 'map') {
|
|
1358
|
+
for (const changedKey of Object.keys(ev.diff.updated)) {
|
|
1359
|
+
const entry = classifyStoreKey(changedKey);
|
|
1360
|
+
switch (entry.kind) {
|
|
1361
|
+
case 'patch':
|
|
1362
|
+
patched.add(entry.parsed.recordKey);
|
|
1363
|
+
break;
|
|
1364
|
+
// An entry that names one record: the record's own container beside
|
|
1365
|
+
// the pages, or the record under its own bare key. Either is one of
|
|
1366
|
+
// several copies a paged store can hold, so the record is RESOLVED
|
|
1367
|
+
// rather than read off the entry and a subscriber is handed the
|
|
1368
|
+
// value a reader gets. A peer's patch outranks the container it was
|
|
1369
|
+
// written over, and a page copy survives the container going, so
|
|
1370
|
+
// the entry alone answers neither what the record holds nor whether
|
|
1371
|
+
// it is still there.
|
|
1372
|
+
case 'overlay':
|
|
1373
|
+
case 'record': {
|
|
1374
|
+
const value = resolve(entry.recordKey);
|
|
1375
|
+
if (value === undefined)
|
|
1376
|
+
deleted.add(entry.recordKey);
|
|
1377
|
+
else
|
|
1378
|
+
set.set(entry.recordKey, value);
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
// The marker arriving removes the record; the marker going is a
|
|
1382
|
+
// re-add, whose own entry the same batch carries, so the record is
|
|
1383
|
+
// resolved rather than reported from here.
|
|
1384
|
+
case 'tombstone': {
|
|
1385
|
+
if (entryExists(m, changedKey)) {
|
|
1386
|
+
deleted.add(entry.recordKey);
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
const value = resolve(entry.recordKey);
|
|
1390
|
+
if (value !== undefined)
|
|
1391
|
+
set.set(entry.recordKey, value);
|
|
1392
|
+
break;
|
|
1393
|
+
}
|
|
1394
|
+
case 'page':
|
|
1395
|
+
expandPage(m, changedKey, index, set, deleted, expanded, resolve);
|
|
1396
|
+
break;
|
|
1397
|
+
case 'unknown':
|
|
1398
|
+
break;
|
|
1399
|
+
}
|
|
499
1400
|
}
|
|
500
|
-
|
|
501
|
-
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
// An event on a container under the root, whose key is the path segment
|
|
1404
|
+
// there. Two shapes reach here.
|
|
1405
|
+
//
|
|
1406
|
+
// A field edit fires on the record's own container, and names that record.
|
|
1407
|
+
//
|
|
1408
|
+
// A PAGE fires here when a covering mutation rewrites it: the page's
|
|
1409
|
+
// container already exists, so the rewrite sets fields on it rather than
|
|
1410
|
+
// assigning a key of the store's own map, and the map-level diff carries
|
|
1411
|
+
// nothing. Reading only that diff drops every record in every page a pass
|
|
1412
|
+
// covered, which is the whole store for a pass over the whole store, and
|
|
1413
|
+
// drops them silently: the writer's own app repaints the few records that
|
|
1414
|
+
// took another route and leaves the rest drawn where they were.
|
|
1415
|
+
const segment = ev.path[1];
|
|
1416
|
+
if (typeof segment === 'string') {
|
|
1417
|
+
const entry = classifyStoreKey(segment);
|
|
1418
|
+
if (entry.kind === 'page') {
|
|
1419
|
+
expandPage(m, segment, index, set, deleted, expanded, resolve);
|
|
502
1420
|
}
|
|
503
|
-
if (
|
|
504
|
-
for
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1421
|
+
else if (entry.kind === 'overlay' || entry.kind === 'record') {
|
|
1422
|
+
// Resolved for the same reason the map-level cases are: the container
|
|
1423
|
+
// this event fired on is one copy of the record, not the answer.
|
|
1424
|
+
const value = resolve(entry.recordKey);
|
|
1425
|
+
if (value !== undefined)
|
|
1426
|
+
set.set(entry.recordKey, value);
|
|
508
1427
|
}
|
|
509
1428
|
}
|
|
510
1429
|
}
|
|
1430
|
+
for (const recordKey of patched) {
|
|
1431
|
+
if (set.has(recordKey) || deleted.has(recordKey))
|
|
1432
|
+
continue;
|
|
1433
|
+
const value = resolve(recordKey);
|
|
1434
|
+
if (value === undefined)
|
|
1435
|
+
deleted.add(recordKey);
|
|
1436
|
+
else
|
|
1437
|
+
set.set(recordKey, value);
|
|
1438
|
+
}
|
|
511
1439
|
for (const key of deleted)
|
|
512
1440
|
set.delete(key);
|
|
513
1441
|
return { set, deleted: [...deleted] };
|
|
@@ -516,70 +1444,152 @@ function pagedMapDelta(m, batch, pageIndex) {
|
|
|
516
1444
|
// OVERLAY of in-batch changes (key -> record, or a tombstone for a delete);
|
|
517
1445
|
// reads consult the overlay first then the stored page, so an edit is
|
|
518
1446
|
// visible to later reads in the same `fn` as on an unpaged live proxy.
|
|
519
|
-
// Afterwards
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
// rather than corrupt.
|
|
528
|
-
if (pageCount <= 0) {
|
|
529
|
-
throw new Error('Cannot edit a paged record store with no page_count.');
|
|
530
|
-
}
|
|
1447
|
+
// Afterwards `applyOverlay` writes each EDITED record to its own container
|
|
1448
|
+
// beside the pages, leaving every existing page byte-identical. That is what
|
|
1449
|
+
// keeps two people editing different records in one page from overwriting each
|
|
1450
|
+
// other: a page body is a single gzipped value, so rewriting it would make the
|
|
1451
|
+
// page the merge unit and hand the whole page to the last writer. A `pruneRefs`
|
|
1452
|
+
// sweep that iterates the whole draft is the only path that reads every page,
|
|
1453
|
+
// the rare structural-delete case already treated as O(file).
|
|
1454
|
+
function mutatePaged(m, fn, pager, batcher) {
|
|
531
1455
|
const TOMBSTONE = Symbol('deleted');
|
|
532
1456
|
// In-batch changes: key -> new record, or TOMBSTONE when deleted. Absent
|
|
533
1457
|
// means "unchanged, read from the page".
|
|
534
1458
|
const overlay = new Map();
|
|
1459
|
+
// One decoded page shared by every read this mutation makes. Reading a
|
|
1460
|
+
// record through the draft (`m[key].params`) resolves it once per field
|
|
1461
|
+
// touched, and the ordinary edit shape touches several, so without this a
|
|
1462
|
+
// single-record edit gunzips and parses its whole page a handful of times.
|
|
1463
|
+
// `applyOverlay` never rewrites a page, so a page decoded here stays valid
|
|
1464
|
+
// for the whole mutation.
|
|
1465
|
+
const memo = new PageMemo();
|
|
1466
|
+
// Taken ONCE, before the first write. The version advances as the mutation
|
|
1467
|
+
// writes, so taking it again per record would rebuild the index per record;
|
|
1468
|
+
// the write path keeps this instance current instead (`note` / `clear`).
|
|
1469
|
+
const patchIndex = pager ? pager.patches(m) : null;
|
|
535
1470
|
// The record a draft read should see: the overlay value if touched this
|
|
536
|
-
// batch, else the stored record
|
|
1471
|
+
// batch, else the stored record.
|
|
537
1472
|
const currentRecord = (key) => {
|
|
538
1473
|
if (overlay.has(key)) {
|
|
539
1474
|
const v = overlay.get(key);
|
|
540
1475
|
return v === TOMBSTONE ? undefined : v;
|
|
541
1476
|
}
|
|
542
|
-
return recordFromPage(m, key,
|
|
1477
|
+
return recordFromPage(m, key, memo, pager?.liveIndex ?? null, patchIndex);
|
|
543
1478
|
};
|
|
544
1479
|
// Per-record sub-proxy: a field assignment writes through to the overlay
|
|
545
1480
|
// record so a later read sees it and the page rewrite picks it up. Field
|
|
546
1481
|
// reads return the deep-plain stored value, so spreading `{ ...rec.params }`
|
|
547
1482
|
// never leaks a live handle.
|
|
548
1483
|
const recordProxy = (key) => {
|
|
1484
|
+
// The record a field write starts from. A value that is not an object has
|
|
1485
|
+
// no fields to keep, so the write starts from an empty record rather than
|
|
1486
|
+
// from that value's own entries (`'ab'` would contribute `0` and `1`).
|
|
1487
|
+
const fieldBase = () => {
|
|
1488
|
+
const rec = currentRecord(key);
|
|
1489
|
+
return isPlainObject(rec) ? { ...rec } : {};
|
|
1490
|
+
};
|
|
549
1491
|
return new Proxy({}, {
|
|
550
1492
|
get(_t, field) {
|
|
551
1493
|
if (typeof field === 'symbol')
|
|
552
1494
|
return undefined;
|
|
553
1495
|
const rec = currentRecord(key);
|
|
554
|
-
|
|
1496
|
+
const v = isPlainObject(rec) ? rec[field] : undefined;
|
|
1497
|
+
if (v !== null && typeof v === 'object') {
|
|
1498
|
+
// Same reason as the unpaged proxy: a body is a plain value, so
|
|
1499
|
+
// a field edit inside it has to be written back rather than
|
|
1500
|
+
// applied to the copy the read handed out.
|
|
1501
|
+
return writeThroughBody(v, (next) => {
|
|
1502
|
+
const base = fieldBase();
|
|
1503
|
+
base[field] = toPlainDeep(next);
|
|
1504
|
+
overlay.set(key, base);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
return v;
|
|
555
1508
|
},
|
|
556
1509
|
set(_t, field, value) {
|
|
557
1510
|
if (typeof field === 'symbol')
|
|
558
1511
|
return false;
|
|
559
|
-
const base =
|
|
1512
|
+
const base = fieldBase();
|
|
560
1513
|
base[field] = toPlainDeep(value);
|
|
561
1514
|
overlay.set(key, base);
|
|
562
1515
|
return true;
|
|
563
1516
|
},
|
|
1517
|
+
// A field removal has to write through here as an assignment does.
|
|
1518
|
+
// The default trap would delete from this proxy's own empty target
|
|
1519
|
+
// and report success, so `delete d[key].field` would apply on a small
|
|
1520
|
+
// store and silently do nothing once the store paged.
|
|
1521
|
+
deleteProperty(_t, field) {
|
|
1522
|
+
if (typeof field === 'symbol')
|
|
1523
|
+
return false;
|
|
1524
|
+
const base = fieldBase();
|
|
1525
|
+
if (!(field in base))
|
|
1526
|
+
return true;
|
|
1527
|
+
delete base[field];
|
|
1528
|
+
overlay.set(key, base);
|
|
1529
|
+
return true;
|
|
1530
|
+
},
|
|
1531
|
+
// Enumerating a record is what `{ ...rec }`, `Object.keys(rec)` and
|
|
1532
|
+
// `JSON.stringify(rec)` all reach for, so the record answers with its
|
|
1533
|
+
// stored fields here as it does through the unpaged proxy. Without
|
|
1534
|
+
// these the proxy enumerates its own empty target, and an app that
|
|
1535
|
+
// copies a record reads it as `{}` on a paged store while the same
|
|
1536
|
+
// code works on a small one.
|
|
1537
|
+
has(_t, field) {
|
|
1538
|
+
if (typeof field === 'symbol')
|
|
1539
|
+
return false;
|
|
1540
|
+
const rec = currentRecord(key);
|
|
1541
|
+
return isPlainObject(rec) && field in rec;
|
|
1542
|
+
},
|
|
1543
|
+
ownKeys() {
|
|
1544
|
+
const rec = currentRecord(key);
|
|
1545
|
+
return isPlainObject(rec) ? Object.keys(rec) : [];
|
|
1546
|
+
},
|
|
1547
|
+
getOwnPropertyDescriptor(_t, field) {
|
|
1548
|
+
if (typeof field === 'symbol')
|
|
1549
|
+
return undefined;
|
|
1550
|
+
const rec = currentRecord(key);
|
|
1551
|
+
if (!isPlainObject(rec) || !(field in rec))
|
|
1552
|
+
return undefined;
|
|
1553
|
+
return {
|
|
1554
|
+
enumerable: true,
|
|
1555
|
+
configurable: true,
|
|
1556
|
+
writable: true,
|
|
1557
|
+
value: rec[field],
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
564
1560
|
});
|
|
565
1561
|
};
|
|
566
1562
|
const draft = new Proxy({}, {
|
|
567
1563
|
get(_t, key) {
|
|
568
1564
|
if (typeof key === 'symbol')
|
|
569
1565
|
return undefined;
|
|
570
|
-
|
|
1566
|
+
const rec = currentRecord(key);
|
|
1567
|
+
if (rec === undefined)
|
|
571
1568
|
return undefined;
|
|
1569
|
+
// As on an unpaged map: a stored value that is not an object is handed
|
|
1570
|
+
// back as itself, and a plain array as a view that writes an element
|
|
1571
|
+
// edit through instead of into a copy the caller then drops. Only an
|
|
1572
|
+
// object has the fields the per-record sub-proxy answers for.
|
|
1573
|
+
if (!isPlainObject(rec)) {
|
|
1574
|
+
if (!Array.isArray(rec))
|
|
1575
|
+
return rec;
|
|
1576
|
+
return writeThroughBody(rec, (next) => {
|
|
1577
|
+
overlay.set(key, toPlainDeep(next));
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
572
1580
|
return recordProxy(key);
|
|
573
1581
|
},
|
|
574
1582
|
set(_t, key, value) {
|
|
575
1583
|
if (typeof key === 'symbol')
|
|
576
1584
|
return false;
|
|
1585
|
+
assertUsableRecordKey(key);
|
|
577
1586
|
overlay.set(key, toPlainDeep(value));
|
|
578
1587
|
return true;
|
|
579
1588
|
},
|
|
580
1589
|
deleteProperty(_t, key) {
|
|
581
1590
|
if (typeof key === 'symbol')
|
|
582
1591
|
return false;
|
|
1592
|
+
assertUsableRecordKey(key);
|
|
583
1593
|
overlay.set(key, TOMBSTONE);
|
|
584
1594
|
return true;
|
|
585
1595
|
},
|
|
@@ -610,52 +1620,584 @@ function mutatePaged(m, pageCount, fn) {
|
|
|
610
1620
|
fn(draft);
|
|
611
1621
|
if (overlay.size === 0)
|
|
612
1622
|
return;
|
|
613
|
-
applyOverlay(m,
|
|
614
|
-
}
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1623
|
+
applyOverlay(m, overlay, TOMBSTONE, patchIndex, memo, pager, batcher);
|
|
1624
|
+
}
|
|
1625
|
+
// The container holding one record beside the pages, reusing the one already
|
|
1626
|
+
// at this key. A fresh `setContainer` is a destroy+create rather than a write
|
|
1627
|
+
// (the same reason `assignPlain` reuses), so minting one per edit charges the
|
|
1628
|
+
// snapshot for every edit instead of for every edited RECORD: 2,000 edits to
|
|
1629
|
+
// one record cost 71.8 KB minting and 8.6 KB reusing. A drag, a slider, and a
|
|
1630
|
+
// rename are all repeated edits to one record, and every reader downloads the
|
|
1631
|
+
// difference on every open.
|
|
1632
|
+
function overlayContainer(m, overlayKey) {
|
|
1633
|
+
const existing = m.get(overlayKey);
|
|
1634
|
+
return existing instanceof LoroMap
|
|
1635
|
+
? existing
|
|
1636
|
+
: m.setContainer(overlayKey, new LoroMap());
|
|
1637
|
+
}
|
|
1638
|
+
// State that a record a page still holds is deleted. The marker carries
|
|
1639
|
+
// nothing but its own existence, so it is a plain value under a key of its
|
|
1640
|
+
// own: a field on the record's container would be a field of the app's record,
|
|
1641
|
+
// and a record that happened to carry it would read as its own deletion.
|
|
1642
|
+
function markDeleted(m, recordKey) {
|
|
1643
|
+
m.set(tombstoneKeyFor(recordKey), true);
|
|
1644
|
+
}
|
|
1645
|
+
// Take back a record's delete marker, and report whether there was one.
|
|
1646
|
+
// Called wherever a record's own container is written, since that container is
|
|
1647
|
+
// the record being there again.
|
|
1648
|
+
function clearTombstone(m, recordKey) {
|
|
1649
|
+
const key = tombstoneKeyFor(recordKey);
|
|
1650
|
+
if (!entryExists(m, key))
|
|
1651
|
+
return false;
|
|
1652
|
+
m.delete(key);
|
|
1653
|
+
return true;
|
|
1654
|
+
}
|
|
1655
|
+
// Write out what a paged mutation changed.
|
|
1656
|
+
//
|
|
1657
|
+
// An EDITED record is written OUTSIDE the page holding it, as a patch naming
|
|
1658
|
+
// the fields that changed or, once this session has patched it before, as a
|
|
1659
|
+
// container of its own. A page body is one value, so writing the edit into the
|
|
1660
|
+
// page would make the page the merge unit: two peers changing different
|
|
1661
|
+
// records in one page would resolve last-writer-wins, and an undo would
|
|
1662
|
+
// restore the whole page over a peer's record. What that costs is an entry per
|
|
1663
|
+
// record edited, bounded by the records actually edited rather than by the
|
|
1664
|
+
// store's size.
|
|
1665
|
+
//
|
|
1666
|
+
// The exception is a mutation that replaces EVERY record a page holds, which
|
|
1667
|
+
// `writeEdits` rewrites in place: nothing in the page is left for the rewrite
|
|
1668
|
+
// to resolve on another writer's behalf, so the merge unit does not move. That
|
|
1669
|
+
// is the one route by which an edited store returns to the entry count it
|
|
1670
|
+
// imported at, and it is why a pass over the whole store is much cheaper than
|
|
1671
|
+
// the same edits made a scattered subset at a time.
|
|
1672
|
+
//
|
|
1673
|
+
// A bulk ADD is written as fresh pages instead, in the writer's own key space,
|
|
1674
|
+
// which is safe for the same reason an edit in place is not: those keys are
|
|
1675
|
+
// new, so no peer holds a copy to lose.
|
|
1676
|
+
function applyOverlay(m, overlay, tombstone, patchIndex, memo, pager,
|
|
1677
|
+
// Commits as the writes accumulate, on the same size budget an unpaged
|
|
1678
|
+
// mutation uses. Without it every page a mutation writes rides one op,
|
|
1679
|
+
// whose size then scales with the mutation rather than with the budget.
|
|
1680
|
+
batcher) {
|
|
1681
|
+
let index = pager?.liveIndex ?? null;
|
|
1682
|
+
// Records that MIGHT be additions: nothing holds a container for them yet,
|
|
1683
|
+
// so each is either new or living in a page. Held back so a bulk add is
|
|
1684
|
+
// written as whole pages instead of a container each. A record that already
|
|
1685
|
+
// has a container keeps it: the page holding a record cannot be rewritten to
|
|
1686
|
+
// carry an edit (see above), and moving it would mean deleting a container a
|
|
1687
|
+
// peer may be writing to at that moment.
|
|
1688
|
+
const candidates = [];
|
|
1689
|
+
for (const [key, rec] of overlay) {
|
|
1690
|
+
const overlayKey = overlayKeyFor(key);
|
|
1691
|
+
if (rec === tombstone) {
|
|
1692
|
+
// Charged before the writes, so a commit it triggers closes the batch
|
|
1693
|
+
// that precedes this delete rather than splitting it out of one. A
|
|
1694
|
+
// paged store's deletes are unbounded where an unpaged store's stop at
|
|
1695
|
+
// the threshold, so this is the path where a bulk delete would
|
|
1696
|
+
// otherwise ride one op sized by the delete instead of by the budget.
|
|
1697
|
+
batcher?.recordDelete(key);
|
|
1698
|
+
// A record held under its own key outgoes the page on every read, so a
|
|
1699
|
+
// delete that left it would not remove the record. Off the hot read
|
|
1700
|
+
// path: only a delete pays for this.
|
|
1701
|
+
if (entryExists(m, key))
|
|
1702
|
+
m.delete(key);
|
|
1703
|
+
if (entryExists(m, overlayKey))
|
|
1704
|
+
m.delete(overlayKey);
|
|
1705
|
+
dropPatches(m, patchIndex, key);
|
|
1706
|
+
// A record a page still holds needs the delete stated, since the page
|
|
1707
|
+
// cannot be rewritten to drop it. One that exists only as its own
|
|
1708
|
+
// container is simply removed, so an add-then-delete leaves nothing and
|
|
1709
|
+
// no marker outlives the record it was about.
|
|
1710
|
+
if (pageHoldsRecord(key, index))
|
|
1711
|
+
markDeleted(m, key);
|
|
1712
|
+
index?.noteRemoved(key);
|
|
1713
|
+
batcher?.noteKeys([key]);
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
const r = rec;
|
|
1717
|
+
// A record already answering for itself beside the pages: rewrite that
|
|
1718
|
+
// container in place, so repeated edits merge per field and cost one write
|
|
1719
|
+
// each rather than a fresh container.
|
|
1720
|
+
if (m.get(overlayKey) !== undefined) {
|
|
1721
|
+
dropPatches(m, patchIndex, key);
|
|
1722
|
+
writeRecordContainer(m, key, r);
|
|
1723
|
+
index?.noteContained(key);
|
|
1724
|
+
batcher?.noteKeys([key]);
|
|
1725
|
+
batcher?.record(r);
|
|
1726
|
+
continue;
|
|
1727
|
+
}
|
|
1728
|
+
// A record under its own BARE key is edited where it already is, exactly
|
|
1729
|
+
// as an unpaged store edits it.
|
|
1730
|
+
//
|
|
1731
|
+
// Giving it an overlay container instead is a `setContainer` at a key no
|
|
1732
|
+
// peer holds yet, so two peers making that record's first edit at the same
|
|
1733
|
+
// time each mint one and the map entry resolves last-writer-wins, dropping
|
|
1734
|
+
// an edit outright. Writing into the container already there merges per
|
|
1735
|
+
// field, and leaves the record stored once rather than twice.
|
|
1736
|
+
if (strayRecordAt(m, key) !== undefined) {
|
|
1737
|
+
dropPatches(m, patchIndex, key);
|
|
1738
|
+
// The record is present again, so its delete marker goes, exactly as it
|
|
1739
|
+
// does when a record is written to a container of its own. A marker
|
|
1740
|
+
// outranks every copy of a record on a read, so an edit that left one
|
|
1741
|
+
// behind would store the new record and answer that it is gone.
|
|
1742
|
+
clearTombstone(m, key);
|
|
1743
|
+
assignEntry(m, key, r);
|
|
1744
|
+
index?.noteContained(key);
|
|
1745
|
+
batcher?.noteKeys([key]);
|
|
1746
|
+
batcher?.record(r);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (pager) {
|
|
1750
|
+
candidates.push([key, r]);
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
dropPatches(m, patchIndex, key);
|
|
1754
|
+
writeRecordContainer(m, key, r);
|
|
1755
|
+
index?.noteContained(key);
|
|
1756
|
+
batcher?.noteKeys([key]);
|
|
1757
|
+
batcher?.record(r);
|
|
1758
|
+
}
|
|
1759
|
+
if (candidates.length === 0)
|
|
1760
|
+
return;
|
|
1761
|
+
// Below a page's worth there is nothing to PAGE, so nothing here adds a
|
|
1762
|
+
// page; the two branches differ only in whether a bulk write refreshes the
|
|
1763
|
+
// index up front before telling an edit from an addition.
|
|
1764
|
+
const perPage = recordsPerPage(sampleBodyBytes(candidates, pagedBodyOf));
|
|
1765
|
+
const added = [];
|
|
1766
|
+
if (candidates.length >= perPage && pager) {
|
|
1767
|
+
index = pager.ensureIndex(m);
|
|
1768
|
+
const edits = [];
|
|
1769
|
+
for (const entry of candidates) {
|
|
1770
|
+
// The page already holds this record, so this is an EDIT rather than an
|
|
1771
|
+
// addition. That covers a record this store DELETED as well: the page
|
|
1772
|
+
// keeps its copy whatever the marker says, so re-adding it has to write
|
|
1773
|
+
// beside the page rather than into a second one.
|
|
1774
|
+
if (pager.holds(entry[0]) || index.packedInPage(entry[0])) {
|
|
1775
|
+
edits.push(entry);
|
|
1776
|
+
}
|
|
1777
|
+
else
|
|
1778
|
+
added.push(entry);
|
|
1779
|
+
}
|
|
1780
|
+
writeEdits(m, pager, index, patchIndex, edits, memo, batcher);
|
|
1781
|
+
}
|
|
1782
|
+
else if (pager) {
|
|
1783
|
+
for (const [key, r] of candidates) {
|
|
1784
|
+
if (pageHoldsRecord(key, index)) {
|
|
1785
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
writeRecordContainer(m, key, r);
|
|
1789
|
+
index?.noteContained(key);
|
|
1790
|
+
batcher?.noteKeys([key]);
|
|
1791
|
+
batcher?.record(r);
|
|
1792
|
+
}
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
else {
|
|
1796
|
+
for (const [key, r] of candidates) {
|
|
1797
|
+
writeRecordContainer(m, key, r);
|
|
1798
|
+
index?.noteContained(key);
|
|
1799
|
+
batcher?.noteKeys([key]);
|
|
1800
|
+
batcher?.record(r);
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const written = pager.writePages(m, added, perPage, batcher);
|
|
1805
|
+
for (let i = written; i < added.length; i += 1) {
|
|
1806
|
+
const [key, r] = added[i];
|
|
1807
|
+
writeRecordContainer(m, key, r);
|
|
1808
|
+
index.noteContained(key);
|
|
1809
|
+
batcher?.noteKeys([key]);
|
|
1810
|
+
batcher?.record(r);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
/**
|
|
1814
|
+
* Write a mutation's edits to records their pages hold.
|
|
1815
|
+
*
|
|
1816
|
+
* A page whose every record this mutation replaces is REWRITTEN in place, and
|
|
1817
|
+
* the patches on those records go with it. That is what keeps a transform over
|
|
1818
|
+
* the whole store from landing it in its worst state: a patch each would add
|
|
1819
|
+
* an entry per record, where rewriting leaves the store at the entry count it
|
|
1820
|
+
* had. A bulk pass naturally covers whole pages, because a read hands its keys
|
|
1821
|
+
* back grouped by page.
|
|
1822
|
+
*
|
|
1823
|
+
* The rewrite is safe against the edits that are not rewrites, which is every
|
|
1824
|
+
* other route an edit can take. A page body is one value, so a rewrite is
|
|
1825
|
+
* last-writer-wins on the PAGE, but a peer's patch and a peer's own container
|
|
1826
|
+
* both live outside it and both outrank it on read, so a concurrent edit
|
|
1827
|
+
* survives a rewrite that did not see it. Only another rewrite of the same
|
|
1828
|
+
* page can lose one, and that is two people transforming one model at once.
|
|
1829
|
+
*
|
|
1830
|
+
* Patches are dropped only for the records this rewrite absorbed, so a patch
|
|
1831
|
+
* written concurrently, under a key this writer never saw, is left to apply
|
|
1832
|
+
* over the rewritten page.
|
|
1833
|
+
*/
|
|
1834
|
+
function writeEdits(m, pager, index, patchIndex, edits, memo, batcher) {
|
|
1835
|
+
const byPage = new Map();
|
|
1836
|
+
const loose = [];
|
|
1837
|
+
for (const entry of edits) {
|
|
1838
|
+
const pageKey = index.readsFromPage(entry[0]);
|
|
1839
|
+
if (pageKey === undefined || pageKey === null) {
|
|
1840
|
+
loose.push(entry);
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
const group = byPage.get(pageKey);
|
|
1844
|
+
if (group)
|
|
1845
|
+
group.push(entry);
|
|
1846
|
+
else
|
|
1847
|
+
byPage.set(pageKey, [entry]);
|
|
1848
|
+
}
|
|
1849
|
+
for (const [pageKey, group] of byPage) {
|
|
1850
|
+
if (!rewritePage(m, pageKey, group, batcher)) {
|
|
1851
|
+
for (const [key, r] of group) {
|
|
1852
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
1853
|
+
}
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
for (const [key] of group)
|
|
1857
|
+
dropPatches(m, patchIndex, key);
|
|
1858
|
+
index.noteSideEntries();
|
|
1859
|
+
batcher?.noteKeys(group.map(([key]) => key));
|
|
1860
|
+
}
|
|
1861
|
+
for (const [key, r] of loose) {
|
|
1862
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Rewrite `pageKey`'s body with `group`'s values, or report that this mutation
|
|
1867
|
+
* does not cover the page and leave it untouched.
|
|
1868
|
+
*
|
|
1869
|
+
* Covering means every record the body holds is one this mutation replaces, or
|
|
1870
|
+
* one a container already answers for, whose page copy is dead either way. A
|
|
1871
|
+
* partial rewrite would be the merge unit moving from the record to the page,
|
|
1872
|
+
* which is what patches exist to prevent.
|
|
1873
|
+
*/
|
|
1874
|
+
function rewritePage(m, pageKey, group, batcher) {
|
|
1875
|
+
const page = plainEntry(m, pageKey);
|
|
1876
|
+
if (page === undefined)
|
|
1877
|
+
return false;
|
|
1878
|
+
// The RAW triples, not the memo's unpacked records: a triple carries either
|
|
1879
|
+
// a tag beside a body or one whole value, and `repackRecord` needs the
|
|
1880
|
+
// original to keep each one in the shape its readers expect.
|
|
1881
|
+
const held = decodePageRecords(page);
|
|
1882
|
+
if (held.length === 0)
|
|
1883
|
+
return false;
|
|
1884
|
+
const next = new Map(group);
|
|
1885
|
+
for (const packed of held) {
|
|
1886
|
+
if (next.has(packed[0]))
|
|
1887
|
+
continue;
|
|
1888
|
+
if (entryExists(m, overlayKeyFor(packed[0])))
|
|
1889
|
+
continue;
|
|
1890
|
+
if (entryExists(m, tombstoneKeyFor(packed[0])))
|
|
1891
|
+
continue;
|
|
1892
|
+
return false;
|
|
1893
|
+
}
|
|
1894
|
+
const triples = held.map((packed) => {
|
|
1895
|
+
const value = next.get(packed[0]);
|
|
1896
|
+
return value === undefined ? packed : repackRecord(packed, value);
|
|
1897
|
+
});
|
|
1898
|
+
const encoded = encodePage(triples);
|
|
1899
|
+
writePage(m, pageKey, encoded);
|
|
1900
|
+
// Charged by what the page PACKS rather than by the gzipped bytes it
|
|
1901
|
+
// occupies, so a record costs the same here as it costs assigned into an
|
|
1902
|
+
// unpaged map and one budget means one thing on every route.
|
|
1903
|
+
batcher?.recordChars(encoded.chars);
|
|
1904
|
+
return true;
|
|
1905
|
+
}
|
|
1906
|
+
// Write an encoded page into its container: the tag, the bodies, and the keys
|
|
1907
|
+
// they belong to. The two strings are set together because a reader zips them
|
|
1908
|
+
// by index, so a page carrying one without the other holds no records.
|
|
1909
|
+
function writePage(m, pageKey, encoded) {
|
|
1910
|
+
const container = overlayContainer(m, pageKey);
|
|
1911
|
+
container.set(TAG_FIELD, PAGE_TAG);
|
|
1912
|
+
container.set(BODY_FIELD, encoded.body);
|
|
1913
|
+
container.set(PAGE_KEYS_FIELD, encoded.keys);
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Remove the patches a whole-record write supersedes.
|
|
1917
|
+
*
|
|
1918
|
+
* A read folds patches on LAST, over whichever copy of the record the page and
|
|
1919
|
+
* container passes resolved, so a patch left behind by an earlier edit would
|
|
1920
|
+
* overwrite the record this write is putting there. Every site that makes a
|
|
1921
|
+
* record's own container authoritative (an edit, a re-add over a tombstone,
|
|
1922
|
+
* the tombstone itself) therefore clears them first.
|
|
1923
|
+
*
|
|
1924
|
+
* This races nothing. A patch key is never written twice, so a peer editing
|
|
1925
|
+
* the same record concurrently writes a key this cannot have seen, and that
|
|
1926
|
+
* patch survives to apply over the container, which is the merge the record
|
|
1927
|
+
* would have had anyway.
|
|
1928
|
+
*/
|
|
1929
|
+
function dropPatches(m, patchIndex, key) {
|
|
1930
|
+
const keys = patchIndex?.keysFor(key);
|
|
1931
|
+
if (keys === undefined)
|
|
1932
|
+
return;
|
|
1933
|
+
for (const patchKey of keys)
|
|
1934
|
+
m.delete(patchKey);
|
|
1935
|
+
patchIndex.clear(key);
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Write an edit to a record a page already holds.
|
|
1939
|
+
*
|
|
1940
|
+
* A patch carries only the fields this edit changed, under a key nothing will
|
|
1941
|
+
* write again, so the record stays in its page and two peers changing
|
|
1942
|
+
* different fields of it both keep theirs. A whole-record write cannot do
|
|
1943
|
+
* either: it takes the record out of its page for good, and the second writer
|
|
1944
|
+
* overwrites the first.
|
|
1945
|
+
*
|
|
1946
|
+
* Removing a field is the one edit a patch cannot state, since a patch is
|
|
1947
|
+
* applied by overlaying its keys. That falls back to the whole-record write,
|
|
1948
|
+
* which is correct and merely gives up the packing for that record.
|
|
1949
|
+
*
|
|
1950
|
+
* A record this session has ALREADY patched takes that fallback too. Patches
|
|
1951
|
+
* accumulate where a container's keys are rewritten in place, so a record
|
|
1952
|
+
* edited over and over (one drag is many edits to one record) would grow the
|
|
1953
|
+
* document without bound. Making the first edit a patch and the second a
|
|
1954
|
+
* container bounds it at one patch per record: an entropy sweep that touches
|
|
1955
|
+
* every record once stays packed, and a hot record costs what it always did.
|
|
1956
|
+
* The FIRST edit is the one that has to be a patch, because that is the moment
|
|
1957
|
+
* two peers editing one record would otherwise mint a container at the same
|
|
1958
|
+
* key and lose one of the two.
|
|
1959
|
+
*/
|
|
1960
|
+
function writeRecordEdit(m, pager, index, patchIndex, key, record, memo, batcher) {
|
|
1961
|
+
const previous = recordFromPage(m, key, memo, index, patchIndex);
|
|
1962
|
+
const patch = alreadyPatchedHere(patchIndex, key, pager.token)
|
|
1963
|
+
? null
|
|
1964
|
+
: changedFields(previous, record);
|
|
1965
|
+
if (patch === null) {
|
|
1966
|
+
dropPatches(m, patchIndex, key);
|
|
1967
|
+
writeRecordContainer(m, key, record);
|
|
1968
|
+
index?.noteContained(key);
|
|
1969
|
+
batcher?.noteKeys([key]);
|
|
1970
|
+
batcher?.record(record);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
if (Object.keys(patch).length === 0)
|
|
1974
|
+
return;
|
|
1975
|
+
const patchKey = pager.nextPatchKey(key, patchIndex);
|
|
1976
|
+
m.set(patchKey, toPlainDeep(patch));
|
|
1977
|
+
patchIndex?.note(patchKey, key);
|
|
1978
|
+
index?.noteSideEntries();
|
|
1979
|
+
batcher?.noteKeys([key]);
|
|
1980
|
+
batcher?.record(patch);
|
|
1981
|
+
}
|
|
1982
|
+
/** Whether this session already holds a patch on `key`. Another peer's patch
|
|
1983
|
+
* does not count: every peer's FIRST edit has to be a patch for two of them
|
|
1984
|
+
* editing one record to merge. */
|
|
1985
|
+
function alreadyPatchedHere(patchIndex, key, token) {
|
|
1986
|
+
const keys = patchIndex?.keysFor(key);
|
|
1987
|
+
if (keys === undefined)
|
|
1988
|
+
return false;
|
|
1989
|
+
for (const patchKey of keys) {
|
|
1990
|
+
if (parsePatchKey(patchKey)?.peer === token)
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
/** The fields `next` changes, or null when this edit is not one a patch can
|
|
1996
|
+
* express: a field was removed, or either side is not an object, and a patch
|
|
1997
|
+
* is applied by overlaying field names. */
|
|
1998
|
+
function changedFields(previous, next) {
|
|
1999
|
+
if (!isPlainObject(previous) || !isPlainObject(next))
|
|
2000
|
+
return null;
|
|
2001
|
+
for (const field of Object.keys(previous)) {
|
|
2002
|
+
if (!(field in next))
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
const patch = {};
|
|
2006
|
+
for (const [field, value] of Object.entries(next)) {
|
|
2007
|
+
const before = previous[field];
|
|
2008
|
+
if (before === value)
|
|
2009
|
+
continue;
|
|
2010
|
+
if (JSON.stringify(before) === JSON.stringify(value))
|
|
2011
|
+
continue;
|
|
2012
|
+
patch[field] = value;
|
|
2013
|
+
}
|
|
2014
|
+
return patch;
|
|
2015
|
+
}
|
|
2016
|
+
// Write one record beside the pages, under the key that shadows the page copy.
|
|
2017
|
+
// A record store's record is `{ type, params }`; an app's is whatever the app
|
|
2018
|
+
// assigned, and both are stored the same way here so the paged path carries an
|
|
2019
|
+
// app's own shape through unchanged.
|
|
2020
|
+
//
|
|
2021
|
+
// An OBJECT takes a container, field by field as PLAIN values: every field
|
|
2022
|
+
// stays a plain value rather than being promoted to a child container, which is
|
|
2023
|
+
// what keeps the RECORD the merge unit (see the proxy's plain-value
|
|
2024
|
+
// preservation). Any other value an app assigned (a number, a string, an
|
|
2025
|
+
// array) is stored as that value, because a container cannot hold one and
|
|
2026
|
+
// walking its entries into fields is what destroys it: `5` has none, so it
|
|
2027
|
+
// would store as an empty container, and `'hello'` has one per character.
|
|
2028
|
+
//
|
|
2029
|
+
// Takes back the record's delete marker, if it had one: this entry IS the
|
|
2030
|
+
// record being present. Every write that puts a record back clears the marker,
|
|
2031
|
+
// here and on the in-place edit of a record still under its own bare key,
|
|
2032
|
+
// because a marker outranks every copy of a record on a read, so a route that
|
|
2033
|
+
// left one would re-add a record nothing can read.
|
|
2034
|
+
function writeRecordContainer(m, recordKey, record) {
|
|
2035
|
+
// The RECORD's key, not the key it is written under. Both are strings, so a
|
|
2036
|
+
// caller that hands over the key it already computed writes the record one
|
|
2037
|
+
// prefix deeper, where every reader resolves a different record. The draft
|
|
2038
|
+
// refuses a reserved key at the write, so one arriving here is that mistake.
|
|
2039
|
+
assertUsableRecordKey(recordKey);
|
|
2040
|
+
clearTombstone(m, recordKey);
|
|
2041
|
+
const overlayKey = overlayKeyFor(recordKey);
|
|
2042
|
+
if (!isPlainObject(record)) {
|
|
2043
|
+
m.set(overlayKey, toPlainDeep(record));
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
const container = overlayContainer(m, overlayKey);
|
|
2047
|
+
for (const field of container.keys()) {
|
|
2048
|
+
if (!(field in record))
|
|
2049
|
+
container.delete(field);
|
|
2050
|
+
}
|
|
2051
|
+
for (const [field, value] of Object.entries(record)) {
|
|
2052
|
+
container.set(field, toPlainDeep(value));
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* One editing session's writer of dense pages, and the key index that finds
|
|
2057
|
+
* records in them.
|
|
2058
|
+
*
|
|
2059
|
+
* A page is one value, so two writers appending to the same page container
|
|
2060
|
+
* resolve last-writer-wins and one writer's records are lost. Each session
|
|
2061
|
+
* therefore writes only pages carrying its own token, and never rewrites one
|
|
2062
|
+
* it has sealed. Nothing is ever moved out of a container into a page, so no
|
|
2063
|
+
* write here can race a peer's edit of a record.
|
|
2064
|
+
*/
|
|
2065
|
+
class SessionPager {
|
|
2066
|
+
_paging;
|
|
2067
|
+
_index;
|
|
2068
|
+
_patchIndex = new PatchIndex();
|
|
2069
|
+
// Whether the index has been built. A pager over an unpaged root never needs
|
|
2070
|
+
// one, so it stays unbuilt until something asks the map where a record is.
|
|
2071
|
+
_live = false;
|
|
2072
|
+
_token;
|
|
2073
|
+
_next = -1;
|
|
2074
|
+
constructor(_paging) {
|
|
2075
|
+
this._paging = _paging;
|
|
2076
|
+
this._token = _paging.sessionToken();
|
|
2077
|
+
this._index = new PagedKeyIndex(() => _paging.stateVersion());
|
|
2078
|
+
}
|
|
2079
|
+
/** The index, if it is being kept; null when nothing has needed it. */
|
|
2080
|
+
get liveIndex() {
|
|
2081
|
+
return this._live ? this._index : null;
|
|
2082
|
+
}
|
|
2083
|
+
/** How many times the page index has been rebuilt. */
|
|
2084
|
+
get indexRebuilds() {
|
|
2085
|
+
return this._index.rebuilds;
|
|
2086
|
+
}
|
|
2087
|
+
/** Build the index if it does not exist, and bring it up to date. */
|
|
2088
|
+
ensureIndex(m) {
|
|
2089
|
+
this._live = true;
|
|
2090
|
+
this._index.refresh(m);
|
|
2091
|
+
return this._index;
|
|
2092
|
+
}
|
|
2093
|
+
/** The sink a whole-store read feeds the index through, or null when the
|
|
2094
|
+
* index already answers for the map as it stands. Close it with
|
|
2095
|
+
* `endHarvest` once the read has walked every entry. */
|
|
2096
|
+
beginHarvest() {
|
|
2097
|
+
if (this._live && !this._index.stale())
|
|
2098
|
+
return null;
|
|
2099
|
+
this._live = true;
|
|
2100
|
+
return this._index.harvest();
|
|
2101
|
+
}
|
|
2102
|
+
/** Stamp the harvested index as current. */
|
|
2103
|
+
endHarvest() {
|
|
2104
|
+
this._index.stamp();
|
|
2105
|
+
}
|
|
2106
|
+
/** The patch lookup, brought up to date against the document. Always kept,
|
|
2107
|
+
* unlike the page index: it costs a walk of the keys with no page decode,
|
|
2108
|
+
* and a keyed read that skipped it would return the record's pre-edit copy.
|
|
2109
|
+
*
|
|
2110
|
+
* A WRITE takes this once, before its first write, and the `note` / `clear`
|
|
2111
|
+
* calls under it keep that one instance current. Taking it again per record
|
|
2112
|
+
* would rebuild per record, since the version advances as the mutation
|
|
2113
|
+
* writes. */
|
|
2114
|
+
patches(m) {
|
|
2115
|
+
this._patchIndex.refresh(m, this._paging.stateVersion());
|
|
2116
|
+
return this._patchIndex;
|
|
2117
|
+
}
|
|
2118
|
+
/** Whether the map already stores a record under `key`. Only meaningful
|
|
2119
|
+
* after `ensureIndex`. */
|
|
2120
|
+
holds(key) {
|
|
2121
|
+
return this._index.has(key);
|
|
2122
|
+
}
|
|
2123
|
+
/** Whether some page's body holds `key`, which stays true after the record
|
|
2124
|
+
* is deleted: a page cannot be rewritten to drop it. Only meaningful after
|
|
2125
|
+
* `ensureIndex`. */
|
|
2126
|
+
packedInPage(key) {
|
|
2127
|
+
return this._index.packedInPage(key);
|
|
2128
|
+
}
|
|
2129
|
+
/** This writer's token, which names the page and patch keys it may write. */
|
|
2130
|
+
get token() {
|
|
2131
|
+
return this._token;
|
|
2132
|
+
}
|
|
2133
|
+
/** A patch key for an edit to `recordKey`: this session's token, plus a
|
|
2134
|
+
* counter one above what the record already carries, so the fold applies
|
|
2135
|
+
* this edit over every patch the writer had merged.
|
|
2136
|
+
*
|
|
2137
|
+
* `patchIndex` is the same instance `patches` hands out, which the write
|
|
2138
|
+
* path keeps current as it goes; the counter has to come from it rather
|
|
2139
|
+
* than from a rebuild, since a patch this mutation just wrote is one this
|
|
2140
|
+
* one must still mint above. */
|
|
2141
|
+
nextPatchKey(recordKey, patchIndex) {
|
|
2142
|
+
const counter = (patchIndex ?? this._patchIndex).nextCounterFor(recordKey);
|
|
2143
|
+
return patchKeyFor(recordKey, this._token, counter);
|
|
2144
|
+
}
|
|
2145
|
+
/** Write as many of `records` as fill whole pages, and return how many were
|
|
2146
|
+
* written. A remainder shorter than one page is left to the caller, which
|
|
2147
|
+
* gives it a container each. */
|
|
2148
|
+
writePages(m, records, perPage, batcher) {
|
|
2149
|
+
const pages = Math.floor(records.length / perPage);
|
|
2150
|
+
if (pages === 0)
|
|
2151
|
+
return 0;
|
|
2152
|
+
for (let p = 0; p < pages; p += 1) {
|
|
2153
|
+
const slice = records.slice(p * perPage, (p + 1) * perPage);
|
|
2154
|
+
const key = this._mintPageKey(m);
|
|
2155
|
+
const encoded = encodePage(slice.map(([k, r]) => packWholeValue(k, r)));
|
|
2156
|
+
writePage(m, key, encoded);
|
|
2157
|
+
this._index.notePage(key, slice.map(([k]) => k));
|
|
2158
|
+
batcher?.noteKeys(slice.map(([k]) => k));
|
|
2159
|
+
// Charged by what the page PACKS rather than by the gzipped bytes it
|
|
2160
|
+
// occupies: this is the only charge a bulk add into an already-paged
|
|
2161
|
+
// store takes, so pricing it compressed would let the same records ride
|
|
2162
|
+
// one op here and several on the route that pages part-way through.
|
|
2163
|
+
batcher?.recordChars(encoded.chars);
|
|
2164
|
+
}
|
|
2165
|
+
return pages * perPage;
|
|
2166
|
+
}
|
|
2167
|
+
// The next unused page key for this token. Seeded above whatever the store
|
|
2168
|
+
// already holds, so a session that reopens a document cannot overwrite the
|
|
2169
|
+
// pages an earlier session under the same token sealed, and stepped past any
|
|
2170
|
+
// key already occupied by something that is not one of this session's pages.
|
|
2171
|
+
_mintPageKey(m) {
|
|
2172
|
+
if (this._next < 0) {
|
|
2173
|
+
let highest = -1;
|
|
2174
|
+
for (const key of m.keys()) {
|
|
2175
|
+
const parsed = parseSessionPageKey(key);
|
|
2176
|
+
if (parsed && parsed.token === this._token) {
|
|
2177
|
+
highest = Math.max(highest, parsed.index);
|
|
633
2178
|
}
|
|
634
2179
|
}
|
|
2180
|
+
this._next = highest + 1;
|
|
635
2181
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (pageKeyFor(key, pageCount) !== pageKey)
|
|
641
|
-
continue;
|
|
642
|
-
const r = rec;
|
|
643
|
-
triples.push([key, String(r[TAG_FIELD] ?? ''), r[BODY_FIELD]]);
|
|
644
|
-
}
|
|
645
|
-
if (triples.length === 0) {
|
|
646
|
-
if (existing !== undefined)
|
|
647
|
-
m.delete(pageKey);
|
|
648
|
-
continue;
|
|
2182
|
+
let key = sessionPageKey(this._token, this._next);
|
|
2183
|
+
while (m.get(key) !== undefined) {
|
|
2184
|
+
this._next += 1;
|
|
2185
|
+
key = sessionPageKey(this._token, this._next);
|
|
649
2186
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
page.set(BODY_FIELD, encodePageBody(triples));
|
|
2187
|
+
this._next += 1;
|
|
2188
|
+
return key;
|
|
653
2189
|
}
|
|
654
2190
|
}
|
|
2191
|
+
// The value a page holds of one record, which is what `sampleBodyBytes` weighs
|
|
2192
|
+
// to size a page. An app's map packs each record's whole stored value as its
|
|
2193
|
+
// triple's body (`packWholeValue`), so the record itself is that value.
|
|
2194
|
+
function pagedBodyOf(entry) {
|
|
2195
|
+
return entry[1];
|
|
2196
|
+
}
|
|
655
2197
|
// A root anchor holds one container that a version checkout swaps in place
|
|
656
2198
|
// (`rebind`). Between rebinds `resolve()` returns the same instance, so the
|
|
657
2199
|
// per-container Loro subscription attaches to a stable identity. `LoroMap` and
|
|
658
|
-
// `
|
|
2200
|
+
// `LoroMovableList` roots share this; the kind is the type parameter.
|
|
659
2201
|
function rootAnchor(root) {
|
|
660
2202
|
let current = root;
|
|
661
2203
|
return {
|
|
@@ -695,6 +2237,12 @@ function childAnchor(parent, key, ctor) {
|
|
|
695
2237
|
cached = v;
|
|
696
2238
|
return cached;
|
|
697
2239
|
}
|
|
2240
|
+
// A container of the OTHER kind reads as absent, and the handle this
|
|
2241
|
+
// read minted is one nothing keeps. A live subscription resolves on
|
|
2242
|
+
// every change to the document it hangs off, so a handle left undropped
|
|
2243
|
+
// here ratchets wasm memory for the length of the session.
|
|
2244
|
+
if (isContainer(v))
|
|
2245
|
+
v.free();
|
|
698
2246
|
return null;
|
|
699
2247
|
},
|
|
700
2248
|
resolveOrCreate: () => {
|
|
@@ -707,15 +2255,109 @@ function childAnchor(parent, key, ctor) {
|
|
|
707
2255
|
cached = v;
|
|
708
2256
|
return cached;
|
|
709
2257
|
}
|
|
2258
|
+
// The key may hold a PLAIN value rather than a container: that is how a
|
|
2259
|
+
// record body is stored above the paging threshold, and how a promoted
|
|
2260
|
+
// record is stored beside its pages. Taking a scoped handle there is a
|
|
2261
|
+
// request for finer granularity on that record, so the container is
|
|
2262
|
+
// seeded from the value it replaces. Creating an empty one instead
|
|
2263
|
+
// discards every field the record already had, which the caller cannot
|
|
2264
|
+
// see coming: it asked for a handle, not for a delete.
|
|
710
2265
|
const created = p.setContainer(key, new ctor());
|
|
2266
|
+
if (created instanceof LoroMap && isPlainObject(v)) {
|
|
2267
|
+
assignPlain(created, v);
|
|
2268
|
+
}
|
|
2269
|
+
else if (created instanceof LoroMovableList && Array.isArray(v)) {
|
|
2270
|
+
assignList(created, v);
|
|
2271
|
+
}
|
|
711
2272
|
cached = created;
|
|
712
2273
|
return created;
|
|
713
2274
|
},
|
|
2275
|
+
// The plain value at this key, when it holds one instead of a container.
|
|
2276
|
+
// A read through a scoped handle answers from this rather than reporting
|
|
2277
|
+
// the key empty, so `read()` agrees with what the parent map returns.
|
|
2278
|
+
plain: () => {
|
|
2279
|
+
const p = parent.resolve();
|
|
2280
|
+
if (!p)
|
|
2281
|
+
return undefined;
|
|
2282
|
+
const v = p.get(key);
|
|
2283
|
+
// A container is what `resolve` answers for, whatever kind it is, so
|
|
2284
|
+
// what this reports is a plain value or nothing. The handle the read
|
|
2285
|
+
// minted goes back either way (see `plainEntry`).
|
|
2286
|
+
if (isContainer(v)) {
|
|
2287
|
+
v.free();
|
|
2288
|
+
return undefined;
|
|
2289
|
+
}
|
|
2290
|
+
return v;
|
|
2291
|
+
},
|
|
714
2292
|
invalidate: () => {
|
|
715
2293
|
cached = null;
|
|
716
2294
|
},
|
|
717
2295
|
};
|
|
718
2296
|
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Keep a composed handle's per-container subscription bound to whatever
|
|
2299
|
+
* currently answers for its entry.
|
|
2300
|
+
*
|
|
2301
|
+
* Which container that is moves under the app. A map key deleted and written
|
|
2302
|
+
* again holds a NEW one, and over a paged record store an edit is parked
|
|
2303
|
+
* beside the pages first, so for a while none answers for the record at all.
|
|
2304
|
+
* Loro's subscribe is per-container, so a subscription bound once goes silent
|
|
2305
|
+
* on the record it was taken for while `read()` follows that record, and the
|
|
2306
|
+
* app keeps rendering what it last heard with nothing to correct it.
|
|
2307
|
+
*
|
|
2308
|
+
* `root` is the topmost container of the handle's chain, which the document
|
|
2309
|
+
* holds for its whole life, so every write that could reach this entry passes
|
|
2310
|
+
* through its subtree and no delete takes the watch down with it. A watch on
|
|
2311
|
+
* the nearest live ancestor cannot promise that: deleting that ancestor
|
|
2312
|
+
* strands the subscription on a dead container, including for the write that
|
|
2313
|
+
* puts the ancestor back.
|
|
2314
|
+
*
|
|
2315
|
+
* Each fire compares what answers for the entry now against what the
|
|
2316
|
+
* subscription is bound to, and on a difference hands the subscriber the whole
|
|
2317
|
+
* entry before re-binding, the sequence `_resync` runs for a source swap.
|
|
2318
|
+
* `catchUp` runs inside the root's fire, so the subscription `attach` returns
|
|
2319
|
+
* misses the batch that made the difference, which is why the whole entry goes
|
|
2320
|
+
* first.
|
|
2321
|
+
*
|
|
2322
|
+
* The print covers an entry no container answers for: its value still moves (a
|
|
2323
|
+
* page rewritten around it, an edit beside the pages, a delete marker), and
|
|
2324
|
+
* comparing it is the only way to see that. It is taken only while nothing is
|
|
2325
|
+
* bound, so an entry with a container of its own costs one cached resolve per
|
|
2326
|
+
* fire and no read.
|
|
2327
|
+
*/
|
|
2328
|
+
function trackEntryContainer(root, spec) {
|
|
2329
|
+
if (!root)
|
|
2330
|
+
return () => { };
|
|
2331
|
+
let bound = spec.resolve();
|
|
2332
|
+
let print = bound === null ? spec.print(spec.read()) : '';
|
|
2333
|
+
let detach = bound === null ? () => { } : spec.attach(bound);
|
|
2334
|
+
const watch = root.subscribe(() => {
|
|
2335
|
+
const next = spec.resolve();
|
|
2336
|
+
if (next === bound) {
|
|
2337
|
+
// A bound container reports its own changes, so there is nothing to
|
|
2338
|
+
// compare and nothing to re-bind.
|
|
2339
|
+
if (next !== null)
|
|
2340
|
+
return;
|
|
2341
|
+
const value = spec.read();
|
|
2342
|
+
const current = spec.print(value);
|
|
2343
|
+
if (current === print)
|
|
2344
|
+
return;
|
|
2345
|
+
print = current;
|
|
2346
|
+
spec.catchUp(value);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
detach();
|
|
2350
|
+
bound = next;
|
|
2351
|
+
const value = spec.read();
|
|
2352
|
+
print = next === null ? spec.print(value) : '';
|
|
2353
|
+
spec.catchUp(value);
|
|
2354
|
+
detach = next === null ? () => { } : spec.attach(next);
|
|
2355
|
+
});
|
|
2356
|
+
return () => {
|
|
2357
|
+
watch();
|
|
2358
|
+
detach();
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
719
2361
|
// Shared per-container subscription bookkeeping for both handle kinds. Holds
|
|
720
2362
|
// the active { callback, detach } records so a version checkout can re-fire
|
|
721
2363
|
// and re-attach each against the swapped source, and a suspend / close can
|
|
@@ -725,12 +2367,75 @@ function childAnchor(parent, key, ctor) {
|
|
|
725
2367
|
// dedup, a different shape: it models no per-listener external resource, and
|
|
726
2368
|
// the Loro subscription each record must re-create on rebind is exactly
|
|
727
2369
|
// that.)
|
|
2370
|
+
/** Run a callback the SDK owns, without letting it break the caller. */
|
|
2371
|
+
/** Report a subscriber that threw, then carry on. One app callback failing
|
|
2372
|
+
* must not break a registration or stop the other subscribers, but swallowing
|
|
2373
|
+
* it leaves an app that stalled inside its own callback with nothing in the
|
|
2374
|
+
* console to read. */
|
|
2375
|
+
function reportSubscriberThrow(err) {
|
|
2376
|
+
console.error('[Brass] subscriber threw:', err);
|
|
2377
|
+
}
|
|
2378
|
+
function fireQuietly(cb) {
|
|
2379
|
+
try {
|
|
2380
|
+
cb();
|
|
2381
|
+
}
|
|
2382
|
+
catch (err) {
|
|
2383
|
+
reportSubscriberThrow(err);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
728
2386
|
class SubscriptionRegistry {
|
|
729
2387
|
_subs = new Set();
|
|
2388
|
+
_settled = new Set();
|
|
2389
|
+
// Whether a whole-container delivery is in progress. Always false while a
|
|
2390
|
+
// delivery is one synchronous call, and the reason `onSettled` is defined
|
|
2391
|
+
// against a delivery rather than against a call: when a delivery arrives in
|
|
2392
|
+
// several parts, registering mid-way waits for the end instead of reporting
|
|
2393
|
+
// a container the caller has only some of.
|
|
2394
|
+
_delivering = false;
|
|
2395
|
+
/**
|
|
2396
|
+
* Report each time this container has been delivered whole.
|
|
2397
|
+
*
|
|
2398
|
+
* Fires on registration, and again after every re-delivery: a version
|
|
2399
|
+
* checkout, a return to head, a hydrate. Repeating rather than one-shot is
|
|
2400
|
+
* what an app needs, since the work it hangs here (frame the camera, run the
|
|
2401
|
+
* first scan, decide whether seeding is safe) is owed again each time the
|
|
2402
|
+
* container is replaced under it. A one-shot signal is the bug four demos
|
|
2403
|
+
* hand-rolled, where a `firstFire` flag never re-arms and the camera never
|
|
2404
|
+
* frames the version the reader switched to.
|
|
2405
|
+
*
|
|
2406
|
+
* It does NOT fire when some OTHER subscriber registers: that delivery went
|
|
2407
|
+
* to one new subscriber, and says nothing to the callers already holding the
|
|
2408
|
+
* container.
|
|
2409
|
+
*
|
|
2410
|
+
* What it can claim is what this peer had converged to when the delivery
|
|
2411
|
+
* began. More always arrives afterwards.
|
|
2412
|
+
*/
|
|
2413
|
+
onSettled(cb) {
|
|
2414
|
+
this._settled.add(cb);
|
|
2415
|
+
if (!this._delivering)
|
|
2416
|
+
fireQuietly(cb);
|
|
2417
|
+
return () => {
|
|
2418
|
+
this._settled.delete(cb);
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
_settle() {
|
|
2422
|
+
// Copied before the walk: a callback that unsubscribes another would
|
|
2423
|
+
// otherwise remove it from the set mid-iteration, and an entry deleted
|
|
2424
|
+
// before it is reached is never visited.
|
|
2425
|
+
const listeners = Array.from(this._settled);
|
|
2426
|
+
for (const cb of listeners)
|
|
2427
|
+
fireQuietly(cb);
|
|
2428
|
+
}
|
|
730
2429
|
// Fire the whole-container catch-up once, attach the per-container change
|
|
731
2430
|
// subscription, track the record, and return an idempotent unsubscribe.
|
|
732
2431
|
subscribe(cb, fireWhole, attach) {
|
|
733
|
-
|
|
2432
|
+
this._delivering = true;
|
|
2433
|
+
try {
|
|
2434
|
+
fireWhole(cb);
|
|
2435
|
+
}
|
|
2436
|
+
finally {
|
|
2437
|
+
this._delivering = false;
|
|
2438
|
+
}
|
|
734
2439
|
const rec = { cb, detach: attach(cb) };
|
|
735
2440
|
this._subs.add(rec);
|
|
736
2441
|
return () => {
|
|
@@ -741,30 +2446,70 @@ class SubscriptionRegistry {
|
|
|
741
2446
|
// Re-fire every active subscriber with the whole current container and
|
|
742
2447
|
// re-attach its change subscription to it (checkout / return-to-head).
|
|
743
2448
|
resync(fireWhole, attach) {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
2449
|
+
this._delivering = true;
|
|
2450
|
+
try {
|
|
2451
|
+
for (const rec of this._subs) {
|
|
2452
|
+
rec.detach();
|
|
2453
|
+
fireWhole(rec.cb);
|
|
2454
|
+
rec.detach = attach(rec.cb);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
finally {
|
|
2458
|
+
this._delivering = false;
|
|
748
2459
|
}
|
|
2460
|
+
// The container has been delivered whole again, which is the transition
|
|
2461
|
+
// `onSettled` exists to report.
|
|
2462
|
+
this._settle();
|
|
749
2463
|
}
|
|
750
2464
|
// Detach every active subscription and forget it (suspend / close).
|
|
751
2465
|
teardown() {
|
|
752
2466
|
for (const rec of this._subs)
|
|
753
2467
|
rec.detach();
|
|
754
2468
|
this._subs.clear();
|
|
2469
|
+
this._settled.clear();
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
function sameStateVersion(a, b) {
|
|
2473
|
+
if (a.length !== b.length)
|
|
2474
|
+
return false;
|
|
2475
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
2476
|
+
if (a[i].peer !== b[i].peer || a[i].counter !== b[i].counter) {
|
|
2477
|
+
return false;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return true;
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Hide a handle's own fields from enumeration.
|
|
2484
|
+
*
|
|
2485
|
+
* A handle is a VIEW onto a container, not the container, so `Object.keys` on
|
|
2486
|
+
* one is a natural thing to reach for and it type-checks. Enumerable, it
|
|
2487
|
+
* answers with the handle's own fields, which look enough like record keys to
|
|
2488
|
+
* pass and are the same handful whatever the document holds, so the mistake
|
|
2489
|
+
* reads as a small document rather than as a mistake. Hidden, the answer is
|
|
2490
|
+
* empty, and the reader goes looking for `keys()`.
|
|
2491
|
+
*
|
|
2492
|
+
* Every field is declared with an initialiser, so they all exist by the time
|
|
2493
|
+
* this runs and a later assignment keeps the descriptor this sets.
|
|
2494
|
+
*/
|
|
2495
|
+
function hideOwnFields(handle) {
|
|
2496
|
+
for (const key of Object.keys(handle)) {
|
|
2497
|
+
Object.defineProperty(handle, key, { enumerable: false });
|
|
755
2498
|
}
|
|
756
2499
|
}
|
|
757
2500
|
class MapHandleImpl {
|
|
758
2501
|
_anchor;
|
|
759
2502
|
_owner;
|
|
760
|
-
//
|
|
761
|
-
// never be a
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// `
|
|
767
|
-
|
|
2503
|
+
// How this root reaches its store's paging state. `null` for handles that
|
|
2504
|
+
// can never be a paged root (composed child handles, and doc-less roots),
|
|
2505
|
+
// which skip the paged paths entirely.
|
|
2506
|
+
_paging;
|
|
2507
|
+
// Writes this session's own dense pages and indexes the store's records.
|
|
2508
|
+
// Built on first use over a paged store and kept for the handle's life; see
|
|
2509
|
+
// `SessionPager`.
|
|
2510
|
+
_pager = null;
|
|
2511
|
+
// Memo for this handle's keyed reads over a paged store; see `PageMemo`.
|
|
2512
|
+
_pageMemo = new PageMemo();
|
|
768
2513
|
_childMaps = new Map();
|
|
769
2514
|
_childLists = new Map();
|
|
770
2515
|
// Active subscriptions, tracked so a version checkout (which swaps the
|
|
@@ -775,80 +2520,117 @@ class MapHandleImpl {
|
|
|
775
2520
|
// matching `_resync` to report the keys the source swap removed. Null
|
|
776
2521
|
// outside a rebind cycle, so a bare `_resync` reports no removals.
|
|
777
2522
|
_priorKeys = null;
|
|
778
|
-
|
|
2523
|
+
// The handle this one is composed off, or null for a root. A subscription
|
|
2524
|
+
// taken before the container exists watches up this chain for the write
|
|
2525
|
+
// that creates it (`_nearestLiveContainer`).
|
|
2526
|
+
_parent;
|
|
2527
|
+
constructor(anchor, owner, paging = null, parent = null) {
|
|
779
2528
|
this._anchor = anchor;
|
|
780
2529
|
this._owner = owner;
|
|
781
|
-
this.
|
|
782
|
-
this.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
2530
|
+
this._paging = paging;
|
|
2531
|
+
this._parent = parent;
|
|
2532
|
+
hideOwnFields(this);
|
|
2533
|
+
}
|
|
2534
|
+
// The container at the top of this handle's composition chain: the root it
|
|
2535
|
+
// is composed off, which the document holds for its whole life (`getMap` is
|
|
2536
|
+
// get-or-create, and a root anchor always resolves). A composed handle's
|
|
2537
|
+
// subscription watches it rather than its nearest live ancestor, which a
|
|
2538
|
+
// delete can take away. Internal: `ListHandleImpl` walks the same chain from
|
|
2539
|
+
// its parent.
|
|
2540
|
+
_chainRoot() {
|
|
2541
|
+
return this._parent ? this._parent._chainRoot() : this._anchor.resolve();
|
|
2542
|
+
}
|
|
2543
|
+
// Whether the map is a PAGED record store. Defined by the root's own `meta`
|
|
2544
|
+
// marker (set once when the root is paged, never changed by a field edit),
|
|
2545
|
+
// read through the hooks in O(1). MUST stay O(1): it is on the hot path of every
|
|
2546
|
+
// `read` / `mutate` (twice per ordered-map edit), so scanning the map to
|
|
2547
|
+
// peek tags would make a single field edit O(file).
|
|
789
2548
|
_isPaged() {
|
|
790
|
-
return this.
|
|
791
|
-
}
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
2549
|
+
return this._paging !== null && this._paging.isPaged();
|
|
2550
|
+
}
|
|
2551
|
+
// This handle's pager, over a store known to be paged. A page is filled in
|
|
2552
|
+
// creation order, so the index is what locates a record; it is refreshed
|
|
2553
|
+
// against the map here, and every entry point that resolves a record reads a
|
|
2554
|
+
// current one.
|
|
2555
|
+
_sessionPager(m) {
|
|
2556
|
+
this._pager ??= new SessionPager(this._paging);
|
|
2557
|
+
this._pager.ensureIndex(m);
|
|
2558
|
+
return this._pager;
|
|
2559
|
+
}
|
|
2560
|
+
// The plain object this handle's key holds, when it holds a value rather
|
|
2561
|
+
// than a container. A record body above the paging threshold is stored that
|
|
2562
|
+
// way, so a scoped handle onto one must read it rather than report the key
|
|
2563
|
+
// empty; the alternative is a handle that answers `{}` over data the parent
|
|
2564
|
+
// map returns in full.
|
|
2565
|
+
_plainBody() {
|
|
2566
|
+
const v = this._anchor.plain?.();
|
|
2567
|
+
return isPlainObject(v) ? v : undefined;
|
|
803
2568
|
}
|
|
804
2569
|
read() {
|
|
2570
|
+
this._owner.assertReadable();
|
|
805
2571
|
const m = this._anchor.resolve();
|
|
806
2572
|
if (!m)
|
|
807
|
-
return {};
|
|
2573
|
+
return (this._plainBody() ?? {});
|
|
808
2574
|
// A paged map flattens back to the flat lookup, so a read is identical
|
|
809
2575
|
// paged or not.
|
|
810
2576
|
if (this._isPaged())
|
|
811
|
-
return
|
|
2577
|
+
return this._readPaged(m);
|
|
812
2578
|
return m.toJSON();
|
|
813
2579
|
}
|
|
2580
|
+
// The whole-store read, which decodes every page. That walk answers the same
|
|
2581
|
+
// question a keyed read asks (which page holds which record), so the index
|
|
2582
|
+
// takes its answers from it and a `get` after a read costs one page rather
|
|
2583
|
+
// than the store.
|
|
2584
|
+
_readPaged(m) {
|
|
2585
|
+
this._pager ??= new SessionPager(this._paging);
|
|
2586
|
+
const sink = this._pager.beginHarvest();
|
|
2587
|
+
if (sink === null)
|
|
2588
|
+
return flattenPagedMap(m);
|
|
2589
|
+
const flat = flattenPagedMap(m, sink);
|
|
2590
|
+
this._pager.endHarvest();
|
|
2591
|
+
return flat;
|
|
2592
|
+
}
|
|
814
2593
|
get(entryKey) {
|
|
2594
|
+
this._owner.assertReadable();
|
|
815
2595
|
const m = this._anchor.resolve();
|
|
816
2596
|
if (!m)
|
|
817
|
-
return
|
|
2597
|
+
return this._plainBody()?.[entryKey];
|
|
818
2598
|
if (this._isPaged()) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (!this._pageHashMatchesStore()) {
|
|
822
|
-
return flattenPagedMap(m)[entryKey];
|
|
823
|
-
}
|
|
824
|
-
// Decode only the key's page, so a per-key `get` stays proportional to
|
|
825
|
-
// one page.
|
|
826
|
-
return recordFromPage(m, entryKey, this._pageCount());
|
|
2599
|
+
const pager = this._sessionPager(m);
|
|
2600
|
+
return recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, pager.patches(m));
|
|
827
2601
|
}
|
|
828
|
-
|
|
829
|
-
if (v === undefined)
|
|
830
|
-
return undefined;
|
|
831
|
-
if (v instanceof LoroMap)
|
|
832
|
-
return v.toJSON();
|
|
833
|
-
if (v instanceof LoroList)
|
|
834
|
-
return v.toJSON();
|
|
835
|
-
return v;
|
|
2602
|
+
return plainEntry(m, entryKey);
|
|
836
2603
|
}
|
|
837
2604
|
keys() {
|
|
2605
|
+
return [...this.liveKeySet()];
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* The keys this map holds, as a set, without materialising a record.
|
|
2609
|
+
*
|
|
2610
|
+
* A paged store answers from the page index, which is built from each page's
|
|
2611
|
+
* key list and never decodes a body, so asking which keys exist costs a
|
|
2612
|
+
* fraction of asking what they hold. Everything that needs membership rather
|
|
2613
|
+
* than content goes through here.
|
|
2614
|
+
*/
|
|
2615
|
+
liveKeySet() {
|
|
2616
|
+
this._owner.assertReadable();
|
|
838
2617
|
const m = this._anchor.resolve();
|
|
839
2618
|
if (!m)
|
|
840
|
-
return
|
|
2619
|
+
return new Set(Object.keys(this._plainBody() ?? {}));
|
|
841
2620
|
if (this._isPaged())
|
|
842
|
-
return
|
|
843
|
-
return m.keys();
|
|
2621
|
+
return this._sessionPager(m).ensureIndex(m).liveKeys();
|
|
2622
|
+
return new Set(m.keys());
|
|
844
2623
|
}
|
|
845
2624
|
has(entryKey) {
|
|
2625
|
+
this._owner.assertReadable();
|
|
846
2626
|
const m = this._anchor.resolve();
|
|
847
|
-
if (!m)
|
|
848
|
-
|
|
2627
|
+
if (!m) {
|
|
2628
|
+
const body = this._plainBody();
|
|
2629
|
+
return body !== undefined && Object.prototype.hasOwnProperty.call(body, entryKey);
|
|
2630
|
+
}
|
|
849
2631
|
if (this._isPaged())
|
|
850
2632
|
return this.get(entryKey) !== undefined;
|
|
851
|
-
return m
|
|
2633
|
+
return entryExists(m, entryKey);
|
|
852
2634
|
}
|
|
853
2635
|
mutate(fn) {
|
|
854
2636
|
this._owner.assertWritable();
|
|
@@ -865,46 +2647,338 @@ class MapHandleImpl {
|
|
|
865
2647
|
// edit land in ONE commit (one undo step).
|
|
866
2648
|
_runMutate(fn, batcher) {
|
|
867
2649
|
const m = this._anchor.resolveOrCreate();
|
|
2650
|
+
this._pageIfOutgrown(m);
|
|
868
2651
|
if (this._isPaged()) {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
// record into the wrong page. Refuse loudly rather than corrupt it; the
|
|
872
|
-
// read path stays best-effort (only a persisted edit can corrupt).
|
|
873
|
-
if (this._pagingScheme && !isPagingSchemeEditable(this._pagingScheme())) {
|
|
874
|
-
throw new BrassError('This document was saved in a newer storage format. Update this app to the latest @brass-build/client to edit it.', { status: 426 });
|
|
875
|
-
}
|
|
876
|
-
mutatePaged(m, this._pageCount(), fn);
|
|
2652
|
+
const pager = this._sessionPager(m);
|
|
2653
|
+
mutatePaged(m, fn, pager, batcher);
|
|
877
2654
|
return;
|
|
878
2655
|
}
|
|
879
|
-
const
|
|
2656
|
+
const divert = this._paging ? this._pagingDivert(m, batcher) : null;
|
|
2657
|
+
const proxy = wrapLoroMapProxy(m, batcher, divert?.hooks, this._paging !== null);
|
|
880
2658
|
fn(proxy);
|
|
2659
|
+
divert?.finish();
|
|
2660
|
+
}
|
|
2661
|
+
// Route this mutation's ADDITIONS into pages from the moment the map
|
|
2662
|
+
// outgrows the threshold, rather than only from the next mutation.
|
|
2663
|
+
//
|
|
2664
|
+
// One `mutate` is allowed to be as large as an app likes (it commits as it
|
|
2665
|
+
// goes past a size budget), so the mutation that fills a map is routinely
|
|
2666
|
+
// the same one that pushes it past the threshold, and a map checked only on
|
|
2667
|
+
// the way in would never page at all for an app that writes in one call.
|
|
2668
|
+
//
|
|
2669
|
+
// Only additions divert. A key the map already stores is an edit of a record
|
|
2670
|
+
// this writer does not own, and moving it would mean deleting a container a
|
|
2671
|
+
// peer may be writing to at that moment.
|
|
2672
|
+
_pagingDivert(m, batcher) {
|
|
2673
|
+
// Additions waiting to be sealed into a page, in creation order. A page
|
|
2674
|
+
// packs whatever value a record is, and `writeRecordContainer` writes an
|
|
2675
|
+
// object as fields and anything else as the value it is, so a buffer here
|
|
2676
|
+
// carries the app's value through whichever of the two it ends up taking.
|
|
2677
|
+
const held = new Map();
|
|
2678
|
+
// Additions taken before this mutation is known to be bulk. They are held
|
|
2679
|
+
// rather than written as a container each, because a container written now
|
|
2680
|
+
// is one this write can never take back: nothing already stored moves, so
|
|
2681
|
+
// the first `UNPAGED_MAX_RECORDS` records of a bulk write would stay
|
|
2682
|
+
// containers for the life of the document. Holding them costs their size
|
|
2683
|
+
// until the decision and lets the whole write be paged.
|
|
2684
|
+
//
|
|
2685
|
+
// Holding is not the fold that is unsafe. A record created inside a
|
|
2686
|
+
// mutation that is still running has never been visible to a peer, so
|
|
2687
|
+
// nothing is deleted and nothing is raced; the choice is only which shape
|
|
2688
|
+
// to write it in.
|
|
2689
|
+
const pending = new Map();
|
|
2690
|
+
// Additions that must take a container of their own instead: a key this
|
|
2691
|
+
// mutation deleted out of a sealed page keeps its tombstone (the page
|
|
2692
|
+
// cannot be rewritten to drop the old copy), and the container is what
|
|
2693
|
+
// outranks both. Paging it again would leave two pages claiming the key.
|
|
2694
|
+
const contained = new Map();
|
|
2695
|
+
// Keys the mutation deleted that a sealed page still holds. A page cannot
|
|
2696
|
+
// be rewritten to drop one, so each becomes a tombstone at the end.
|
|
2697
|
+
const removed = new Set();
|
|
2698
|
+
const memo = new PageMemo();
|
|
2699
|
+
let pager = null;
|
|
2700
|
+
// The record count this mutation will leave behind: what the map holds
|
|
2701
|
+
// plus what is held out of it. Counting only the map would never reach the
|
|
2702
|
+
// threshold once records are held rather than written.
|
|
2703
|
+
const totalRecords = () => m.size + pending.size + held.size;
|
|
2704
|
+
const startPaging = () => {
|
|
2705
|
+
if (pager)
|
|
2706
|
+
return true;
|
|
2707
|
+
if (totalRecords() <= UNPAGED_MAX_RECORDS)
|
|
2708
|
+
return false;
|
|
2709
|
+
this._paging.page();
|
|
2710
|
+
pager = this._sessionPager(m);
|
|
2711
|
+
// Built while the map is still all containers, so this is a walk of the
|
|
2712
|
+
// keys rather than a decode of every page.
|
|
2713
|
+
pager.ensureIndex(m);
|
|
2714
|
+
// Everything held back before the threshold was crossed goes into pages
|
|
2715
|
+
// with the rest, which is the whole point of having held it.
|
|
2716
|
+
for (const [key, record] of pending)
|
|
2717
|
+
held.set(key, record);
|
|
2718
|
+
pending.clear();
|
|
2719
|
+
return true;
|
|
2720
|
+
};
|
|
2721
|
+
// A record this mutation already sealed into a page. Once sealed it is no
|
|
2722
|
+
// longer in `held` and not in the live map either, so without this the
|
|
2723
|
+
// mutation's own later read of it comes back undefined and its own later
|
|
2724
|
+
// delete of it silently does nothing.
|
|
2725
|
+
const sealedRecord = (key) => {
|
|
2726
|
+
const index = pager?.liveIndex;
|
|
2727
|
+
const pageKey = index?.readsFromPage(key);
|
|
2728
|
+
if (pageKey === undefined)
|
|
2729
|
+
return undefined;
|
|
2730
|
+
return memo.resolve(pageKey, plainEntry(m, pageKey)).get(key);
|
|
2731
|
+
};
|
|
2732
|
+
// Seal whole pages out of what is held, leaving any remainder for the next
|
|
2733
|
+
// boundary. Runs at each commit the batcher takes, so a mutation of any
|
|
2734
|
+
// size ships its pages as it goes instead of as one op at the end.
|
|
2735
|
+
const sealPages = () => {
|
|
2736
|
+
if (!pager || held.size === 0)
|
|
2737
|
+
return;
|
|
2738
|
+
const records = [...held];
|
|
2739
|
+
const perPage = recordsPerPage(sampleBodyBytes(records, pagedBodyOf));
|
|
2740
|
+
const written = pager.writePages(m, records, perPage);
|
|
2741
|
+
const sealed = [];
|
|
2742
|
+
for (let i = 0; i < written; i += 1) {
|
|
2743
|
+
sealed.push(records[i][0]);
|
|
2744
|
+
held.delete(records[i][0]);
|
|
2745
|
+
}
|
|
2746
|
+
// Named to the commit this seal runs just ahead of, which is what lets
|
|
2747
|
+
// an ordered map append the order entries for these records alongside
|
|
2748
|
+
// them instead of appending the whole list at the first commit. The
|
|
2749
|
+
// batcher is not passed to `writePages` above, since charging a page
|
|
2750
|
+
// there would take a commit inside a commit.
|
|
2751
|
+
batcher?.noteKeys(sealed);
|
|
2752
|
+
};
|
|
2753
|
+
batcher?.onFlush(sealPages);
|
|
2754
|
+
const hooks = {
|
|
2755
|
+
take: (key, value) => {
|
|
2756
|
+
// A key this mutation is already buffering keeps its buffer, whatever
|
|
2757
|
+
// shape the new value is. Declining it would write to the map behind
|
|
2758
|
+
// the buffer, which `finish` would then overwrite with the value the
|
|
2759
|
+
// buffer still held.
|
|
2760
|
+
if (held.has(key)) {
|
|
2761
|
+
held.set(key, toPlainDeep(value));
|
|
2762
|
+
return true;
|
|
2763
|
+
}
|
|
2764
|
+
if (contained.has(key)) {
|
|
2765
|
+
contained.set(key, toPlainDeep(value));
|
|
2766
|
+
return true;
|
|
2767
|
+
}
|
|
2768
|
+
if (pending.has(key)) {
|
|
2769
|
+
pending.set(key, toPlainDeep(value));
|
|
2770
|
+
return true;
|
|
2771
|
+
}
|
|
2772
|
+
// An existing key is an edit, and an edit keeps whatever shape the
|
|
2773
|
+
// record already has, so it is never held.
|
|
2774
|
+
if (m.get(key) !== undefined)
|
|
2775
|
+
return false;
|
|
2776
|
+
if (!startPaging()) {
|
|
2777
|
+
// Only a RECORD is held. A map entry that is a scalar, a string, an
|
|
2778
|
+
// array, or a container is not what paging packs, and holding one
|
|
2779
|
+
// would hand `finish` a value it can only write as a record: a
|
|
2780
|
+
// boolean comes back as an empty container. Those keep the write
|
|
2781
|
+
// they have always had.
|
|
2782
|
+
if (!isPlainObject(value))
|
|
2783
|
+
return false;
|
|
2784
|
+
// Undecided: hold the addition and re-ask, since this one may be
|
|
2785
|
+
// what crosses the threshold.
|
|
2786
|
+
pending.set(key, toPlainDeep(value));
|
|
2787
|
+
startPaging();
|
|
2788
|
+
return true;
|
|
2789
|
+
}
|
|
2790
|
+
// A key this mutation already sealed into a page, or deleted out of
|
|
2791
|
+
// one, takes a container of its own. The page keeps its copy either
|
|
2792
|
+
// way, so declining the write would leave the record stored twice: the
|
|
2793
|
+
// map's own key beside the page's stale copy, which a keyed read and a
|
|
2794
|
+
// streaming read then answer differently.
|
|
2795
|
+
if (pager.packedInPage(key) || removed.has(key)) {
|
|
2796
|
+
contained.set(key, toPlainDeep(value));
|
|
2797
|
+
return true;
|
|
2798
|
+
}
|
|
2799
|
+
// A container already answers for this key (a record written before
|
|
2800
|
+
// the crossing), so the write goes into it as an ordinary edit.
|
|
2801
|
+
if (pager.holds(key))
|
|
2802
|
+
return false;
|
|
2803
|
+
held.set(key, toPlainDeep(value));
|
|
2804
|
+
return true;
|
|
2805
|
+
},
|
|
2806
|
+
holds: (key) => {
|
|
2807
|
+
if (held.has(key) || contained.has(key) || pending.has(key))
|
|
2808
|
+
return true;
|
|
2809
|
+
if (removed.has(key))
|
|
2810
|
+
return false;
|
|
2811
|
+
return sealedRecord(key) !== undefined;
|
|
2812
|
+
},
|
|
2813
|
+
peek: (key) => {
|
|
2814
|
+
if (pending.has(key))
|
|
2815
|
+
return pending.get(key);
|
|
2816
|
+
if (held.has(key))
|
|
2817
|
+
return held.get(key);
|
|
2818
|
+
if (contained.has(key))
|
|
2819
|
+
return contained.get(key);
|
|
2820
|
+
return removed.has(key) ? undefined : sealedRecord(key);
|
|
2821
|
+
},
|
|
2822
|
+
drop: (key) => {
|
|
2823
|
+
held.delete(key);
|
|
2824
|
+
pending.delete(key);
|
|
2825
|
+
contained.delete(key);
|
|
2826
|
+
if (sealedRecord(key) !== undefined)
|
|
2827
|
+
removed.add(key);
|
|
2828
|
+
},
|
|
2829
|
+
keys: () => {
|
|
2830
|
+
const keys = new Set([
|
|
2831
|
+
...held.keys(),
|
|
2832
|
+
...pending.keys(),
|
|
2833
|
+
...contained.keys(),
|
|
2834
|
+
]);
|
|
2835
|
+
for (const key of removed) {
|
|
2836
|
+
if (!contained.has(key))
|
|
2837
|
+
keys.delete(key);
|
|
2838
|
+
}
|
|
2839
|
+
return [...keys];
|
|
2840
|
+
},
|
|
2841
|
+
};
|
|
2842
|
+
return {
|
|
2843
|
+
hooks,
|
|
2844
|
+
finish: () => {
|
|
2845
|
+
// The mutation ended below the threshold, so the store stays unpaged
|
|
2846
|
+
// and these take exactly the shape the undiverted write gives them: a
|
|
2847
|
+
// container under their own key, filled by `assignPlain`, which is
|
|
2848
|
+
// what makes a nested object a container a scoped handle can anchor
|
|
2849
|
+
// on. `writeRecordContainer` writes fields as plain values instead,
|
|
2850
|
+
// so a record written through it has no `props` container for
|
|
2851
|
+
// `handle.map('props')` to reach.
|
|
2852
|
+
//
|
|
2853
|
+
// Charged HERE rather than where the draft took them, because holding
|
|
2854
|
+
// is what decides that a record's bytes land in this commit and not an
|
|
2855
|
+
// earlier one: a write charged on the way in commits its neighbours
|
|
2856
|
+
// while its own value is still held, so a mutation that holds
|
|
2857
|
+
// everything would spend its boundaries on empty commits and then put
|
|
2858
|
+
// every record in the one op at the end.
|
|
2859
|
+
for (const [key, record] of pending) {
|
|
2860
|
+
// Only an OBJECT takes a container filled field by field. A buffered
|
|
2861
|
+
// key is re-taken whatever shape the new value is, so a rewrite can
|
|
2862
|
+
// leave a string, a number, or an array here, and `assignPlain`
|
|
2863
|
+
// walks a value's entries into fields: it would store `'hello'` as
|
|
2864
|
+
// one field per character and `5` as an empty container, losing what
|
|
2865
|
+
// the app assigned. `assignEntry` is the same write the map makes
|
|
2866
|
+
// for a key the divert declined, so the two shapes agree.
|
|
2867
|
+
if (isPlainObject(record)) {
|
|
2868
|
+
const existing = m.get(key);
|
|
2869
|
+
const sub = existing instanceof LoroMap
|
|
2870
|
+
? existing
|
|
2871
|
+
: m.setContainer(key, new LoroMap());
|
|
2872
|
+
assignPlain(sub, record);
|
|
2873
|
+
}
|
|
2874
|
+
else {
|
|
2875
|
+
assignEntry(m, key, record);
|
|
2876
|
+
}
|
|
2877
|
+
batcher?.noteKeys([key]);
|
|
2878
|
+
batcher?.record(record);
|
|
2879
|
+
}
|
|
2880
|
+
pending.clear();
|
|
2881
|
+
sealPages();
|
|
2882
|
+
// Every write below is charged the same way the draft's own
|
|
2883
|
+
// assignments are, because the charge the draft took paid for BUFFERING
|
|
2884
|
+
// the value, not for writing it. These loops run after the last
|
|
2885
|
+
// boundary the mutation took, so a route that only names its keys puts
|
|
2886
|
+
// its whole write in the final commit however much it holds:
|
|
2887
|
+
// `contained` grows with the records a mutation rewrites after sealing
|
|
2888
|
+
// them, and at 50,000 that op reaches 12 MiB, past what the document
|
|
2889
|
+
// can store at all.
|
|
2890
|
+
//
|
|
2891
|
+
// The remainder is taken out of `held` first, so a commit these charges
|
|
2892
|
+
// trigger runs `sealPages` over an empty buffer rather than over the
|
|
2893
|
+
// map this loop is walking.
|
|
2894
|
+
const remainder = [...held];
|
|
2895
|
+
held.clear();
|
|
2896
|
+
// A remainder shorter than a page takes a container each, which is
|
|
2897
|
+
// what it would have had anyway.
|
|
2898
|
+
for (const [key, record] of remainder) {
|
|
2899
|
+
batcher?.record(record);
|
|
2900
|
+
writeRecordContainer(m, key, record);
|
|
2901
|
+
pager.liveIndex?.noteContained(key);
|
|
2902
|
+
batcher?.noteKeys([key]);
|
|
2903
|
+
}
|
|
2904
|
+
for (const key of removed) {
|
|
2905
|
+
if (contained.has(key))
|
|
2906
|
+
continue;
|
|
2907
|
+
batcher?.recordDelete(key);
|
|
2908
|
+
markDeleted(m, key);
|
|
2909
|
+
pager.liveIndex?.noteRemoved(key);
|
|
2910
|
+
batcher?.noteKeys([key]);
|
|
2911
|
+
}
|
|
2912
|
+
removed.clear();
|
|
2913
|
+
for (const [key, record] of contained) {
|
|
2914
|
+
batcher?.record(record);
|
|
2915
|
+
writeRecordContainer(m, key, record);
|
|
2916
|
+
pager.liveIndex?.noteContained(key);
|
|
2917
|
+
batcher?.noteKeys([key]);
|
|
2918
|
+
}
|
|
2919
|
+
contained.clear();
|
|
2920
|
+
},
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
// Page this root once it has outgrown the one-container-per-record shape.
|
|
2924
|
+
//
|
|
2925
|
+
// The cost of reading a map back rises faster than its entry count (measured
|
|
2926
|
+
// at ~12us an entry at 12,500 entries and ~145us at 200,000), so a map an app
|
|
2927
|
+
// keeps filling eventually costs minutes to read on the reload after it is
|
|
2928
|
+
// written. Paging is what holds the entry count down, and an app should not
|
|
2929
|
+
// have to ask for it, so the threshold is checked here: on the write that
|
|
2930
|
+
// crosses it, the root is stamped paged and everything added from then on is
|
|
2931
|
+
// written into pages.
|
|
2932
|
+
//
|
|
2933
|
+
// Nothing already stored moves. A record in its own container reads as a
|
|
2934
|
+
// stray, which already outranks a page, so leaving them costs one entry each
|
|
2935
|
+
// and no correctness. Moving them would mean deleting containers a peer may
|
|
2936
|
+
// be editing at that moment, and a delete concurrent with an edit discards
|
|
2937
|
+
// the edit.
|
|
2938
|
+
_pageIfOutgrown(m) {
|
|
2939
|
+
if (!this._paging || this._isPaged())
|
|
2940
|
+
return;
|
|
2941
|
+
if (m.size <= UNPAGED_MAX_RECORDS)
|
|
2942
|
+
return;
|
|
2943
|
+
this._paging.page();
|
|
881
2944
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
return
|
|
888
|
-
}
|
|
889
|
-
// A predicate over the map
|
|
890
|
-
// caller checking many keys doesn't re-scan per key. The
|
|
891
|
-
// primitive uses it to decide, per set, whether a key is new
|
|
892
|
-
// be appended to the order list) without paying O(map) per
|
|
2945
|
+
/** @internal How many times this handle's page index has been rebuilt, for a
|
|
2946
|
+
* test to pin that an ordinary edit does not force one. A rebuild decodes
|
|
2947
|
+
* every page, so the count is the difference between an edit costing a
|
|
2948
|
+
* record and one costing the store. */
|
|
2949
|
+
_pageIndexRebuilds() {
|
|
2950
|
+
return this._pager?.indexRebuilds ?? 0;
|
|
2951
|
+
}
|
|
2952
|
+
// A predicate over the key set the map held when this was taken, resolved
|
|
2953
|
+
// ONCE so a caller checking many keys doesn't re-scan per key. The
|
|
2954
|
+
// ordered-map primitive uses it to decide, per set, whether a key is new
|
|
2955
|
+
// (and so must be appended to the order list) without paying O(map) per
|
|
2956
|
+
// added key.
|
|
2957
|
+
//
|
|
2958
|
+
// "When this was taken" is the whole contract, and it is why the unpaged
|
|
2959
|
+
// branch copies the key set rather than asking the live map per call. A
|
|
2960
|
+
// mutation's own writes reach that map as they are made, so a live predicate
|
|
2961
|
+
// reports a key by what this batch has done to it so far: a key deleted and
|
|
2962
|
+
// re-added reads absent and is appended a second time, and a key added
|
|
2963
|
+
// through the paging divert (which holds additions back) reads absent even
|
|
2964
|
+
// after the batch has written it. The caller tracks the batch's own
|
|
2965
|
+
// additions and removals on top of a start-of-batch answer, which is what
|
|
2966
|
+
// the paged branch already gives it.
|
|
893
2967
|
_existsChecker() {
|
|
894
2968
|
const m = this._anchor.resolve();
|
|
895
2969
|
if (!m)
|
|
896
2970
|
return () => false;
|
|
897
2971
|
if (this._isPaged()) {
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
904
|
-
const pageCount = this._pageCount();
|
|
905
|
-
return (key) => recordFromPage(m, key, pageCount) !== undefined;
|
|
2972
|
+
// The index answers from one walk of the pages' key lists instead of a
|
|
2973
|
+
// page decode per key, which is what keeps a bulk insert through the
|
|
2974
|
+
// ordered map off an O(keys x page) path.
|
|
2975
|
+
const index = this._sessionPager(m).ensureIndex(m);
|
|
2976
|
+
return (key) => index.has(key);
|
|
906
2977
|
}
|
|
907
|
-
|
|
2978
|
+
// One walk of an unpaged map, whose size the paging threshold bounds, and
|
|
2979
|
+
// no wasm handle minted per key the way a per-key read would.
|
|
2980
|
+
const keys = new Set(m.keys());
|
|
2981
|
+
return (key) => keys.has(key);
|
|
908
2982
|
}
|
|
909
2983
|
// Fire a subscriber once with the whole map as `change.set`, so a
|
|
910
2984
|
// subscriber is ONE loop with no initial-vs-update branch and no
|
|
@@ -918,46 +2992,108 @@ class MapHandleImpl {
|
|
|
918
2992
|
// source swap but are gone after it: a checkout to a version with fewer
|
|
919
2993
|
// records must tell a delta-reconcile subscriber to drop those keys, or they
|
|
920
2994
|
// linger as phantom records in the preview. `_resync` computes it.
|
|
921
|
-
|
|
2995
|
+
// `snapshot` is the map as a caller has ALREADY read it, so a tracker that
|
|
2996
|
+
// read the entry to decide whether to fire does not read it a second time to
|
|
2997
|
+
// fire it.
|
|
2998
|
+
_fireWhole(cb, deleted = [], snapshot) {
|
|
922
2999
|
try {
|
|
923
|
-
cb({
|
|
3000
|
+
cb({
|
|
3001
|
+
set: new Map(Object.entries(snapshot ?? this.read())),
|
|
3002
|
+
deleted,
|
|
3003
|
+
}, undefined, true);
|
|
924
3004
|
}
|
|
925
|
-
catch {
|
|
926
|
-
|
|
3005
|
+
catch (err) {
|
|
3006
|
+
reportSubscriberThrow(err);
|
|
927
3007
|
}
|
|
928
3008
|
}
|
|
929
|
-
// Attach the
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
//
|
|
3009
|
+
// Attach the change subscription for `cb`, returning its detach.
|
|
3010
|
+
//
|
|
3011
|
+
// A ROOT handle binds straight to its container: that container is the
|
|
3012
|
+
// document's for its whole life, so nothing can move out from under the
|
|
3013
|
+
// subscription except a source swap, which `_resync` re-runs this for.
|
|
3014
|
+
//
|
|
3015
|
+
// A COMPOSED handle's entry is not that stable. Which container answers for
|
|
3016
|
+
// it changes as the entry is deleted and written again, and over a paged
|
|
3017
|
+
// record store it can be nothing at all while an edit sits beside the pages,
|
|
3018
|
+
// so its subscription tracks the entry instead of binding once (see
|
|
3019
|
+
// `trackEntryContainer`).
|
|
933
3020
|
_attachChange(cb) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
|
|
940
|
-
|
|
3021
|
+
if (this._parent === null) {
|
|
3022
|
+
const m = this._anchor.resolve();
|
|
3023
|
+
return m === null ? () => { } : this._subscribeContainer(m, cb);
|
|
3024
|
+
}
|
|
3025
|
+
// The keys the subscriber has been told this entry holds, folded from
|
|
3026
|
+
// every change it has been handed. A re-bind names the ones the container
|
|
3027
|
+
// now answering does not carry: a subscriber folds `set` over `deleted`,
|
|
3028
|
+
// so a key it is never told is gone stays in its model and renders as a
|
|
3029
|
+
// field of a record that no longer has it.
|
|
3030
|
+
const known = new Set(Object.keys(this.read()));
|
|
3031
|
+
const fold = (change, origin, resync) => {
|
|
3032
|
+
for (const key of change.deleted)
|
|
3033
|
+
known.delete(key);
|
|
3034
|
+
for (const key of change.set.keys())
|
|
3035
|
+
known.add(key);
|
|
3036
|
+
cb(change, origin, resync);
|
|
3037
|
+
};
|
|
3038
|
+
return trackEntryContainer(this._chainRoot(), {
|
|
3039
|
+
resolve: () => this._anchor.resolve(),
|
|
3040
|
+
read: () => this.read(),
|
|
3041
|
+
print: (value) => JSON.stringify(value),
|
|
3042
|
+
catchUp: (value) => {
|
|
3043
|
+
const deleted = [...known].filter((key) => !(key in value));
|
|
3044
|
+
this._fireWhole(fold, deleted, value);
|
|
3045
|
+
},
|
|
3046
|
+
attach: (m) => this._subscribeContainer(m, fold),
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
// Subscribe `cb` to one container's own changes, returning its detach. The
|
|
3050
|
+
// instance is captured here (Loro's subscribe is per-container), so
|
|
3051
|
+
// whatever re-binds calls this again against the container it re-binds to.
|
|
3052
|
+
_subscribeContainer(m, cb) {
|
|
941
3053
|
// Paged delta needs prior page state to spot deleted records, so index
|
|
942
3054
|
// the pages once now; the index advances on every fire or its next delete
|
|
943
3055
|
// diff is wrong. Unpaged maps read their delta off the Loro diff.
|
|
944
|
-
|
|
945
|
-
const
|
|
3056
|
+
let index = this._isPaged() ? buildPagedDeltaIndex(m) : null;
|
|
3057
|
+
const depth = containerDepth(m);
|
|
946
3058
|
const sub = m.subscribe((event) => {
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
3059
|
+
// Which reading the delta takes is decided per BATCH, not once at
|
|
3060
|
+
// attach: a store crosses the paging threshold while its subscriptions
|
|
3061
|
+
// are live, and one attached before the crossing would otherwise keep
|
|
3062
|
+
// reporting the map's raw keys, naming pages and overlay slots as if
|
|
3063
|
+
// they were records and never naming a record added after it.
|
|
3064
|
+
//
|
|
3065
|
+
// The index holds the pages this subscriber has already been told
|
|
3066
|
+
// about, so a subscription that has been reading an unpaged store
|
|
3067
|
+
// starts from none: while the store was unpaged there were no pages,
|
|
3068
|
+
// and the batch that pages it names every page it creates. What the
|
|
3069
|
+
// store answers for from outside its pages is read off the map here,
|
|
3070
|
+
// because a store that paged remotely can arrive already carrying edits
|
|
3071
|
+
// and deletes this subscription has never seen a key for.
|
|
3072
|
+
if (index === null && this._isPaged()) {
|
|
3073
|
+
index = {
|
|
3074
|
+
pages: new Map(),
|
|
3075
|
+
shadowed: shadowedRecords(m),
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
const change = (index
|
|
3079
|
+
? pagedMapDelta(m, event, index, (key) => this.get(key))
|
|
3080
|
+
: unpagedMapDelta(m, depth, event));
|
|
950
3081
|
const origin = { source: this._owner.originOf(event) };
|
|
951
3082
|
try {
|
|
952
3083
|
cb(change, origin, false);
|
|
953
3084
|
}
|
|
954
|
-
catch {
|
|
955
|
-
|
|
3085
|
+
catch (err) {
|
|
3086
|
+
reportSubscriberThrow(err);
|
|
956
3087
|
}
|
|
957
3088
|
});
|
|
958
3089
|
return () => sub();
|
|
959
3090
|
}
|
|
3091
|
+
onSettled(cb) {
|
|
3092
|
+
this._owner.assertReadable();
|
|
3093
|
+
return this._registry.onSettled(cb);
|
|
3094
|
+
}
|
|
960
3095
|
subscribe(cb) {
|
|
3096
|
+
this._owner.assertReadable();
|
|
961
3097
|
// App subscribers see `(change, origin?)`; the internal re-sync flag is
|
|
962
3098
|
// dropped here so it never leaks into the public surface.
|
|
963
3099
|
return this._subscribeInternal((change, origin) => cb(change, origin));
|
|
@@ -981,11 +3117,21 @@ class MapHandleImpl {
|
|
|
981
3117
|
this._anchor.rebind(next);
|
|
982
3118
|
}
|
|
983
3119
|
// Capture the current key set of this handle and its composed child maps,
|
|
984
|
-
// read against the
|
|
985
|
-
// against the post-rebind source to find removed keys.
|
|
986
|
-
//
|
|
987
|
-
//
|
|
3120
|
+
// read against the source the handle currently describes, so `_resync` can
|
|
3121
|
+
// diff it against the post-rebind source to find removed keys. Child maps
|
|
3122
|
+
// resolve through this anchor, so they must be read now too.
|
|
3123
|
+
//
|
|
3124
|
+
// Reading keys depends on whether the store is paged, and a handle answers
|
|
3125
|
+
// that from the Document's ACTIVE source rather than from its own anchor, so
|
|
3126
|
+
// a correct snapshot needs both to still name the old source. The two move
|
|
3127
|
+
// separately across a checkout (the Document repoints the active source, then
|
|
3128
|
+
// rebinds the anchors), which is why `_snapshotRootHandleKeys` takes the
|
|
3129
|
+
// snapshot before EITHER moves and this is idempotent: the `_rebindAnchor`
|
|
3130
|
+
// call is the backstop for a rebind that arrives without that phase, and must
|
|
3131
|
+
// not overwrite a snapshot already taken against the right source.
|
|
988
3132
|
_snapshotPriorKeys() {
|
|
3133
|
+
if (this._priorKeys !== null)
|
|
3134
|
+
return;
|
|
989
3135
|
this._priorKeys = new Set(Object.keys(this.read()));
|
|
990
3136
|
for (const child of this._childMaps.values())
|
|
991
3137
|
child._snapshotPriorKeys();
|
|
@@ -1002,6 +3148,9 @@ class MapHandleImpl {
|
|
|
1002
3148
|
// container, and after a source swap that cache points at the old source.
|
|
1003
3149
|
// Prior keys were snapshotted pre-rebind, so this cannot lose them.
|
|
1004
3150
|
this._anchor.invalidate();
|
|
3151
|
+
// The index describes the container set of ONE source, and a checkout /
|
|
3152
|
+
// return-to-head swaps it.
|
|
3153
|
+
this._pager = null;
|
|
1005
3154
|
const prior = this._priorKeys;
|
|
1006
3155
|
this._priorKeys = null;
|
|
1007
3156
|
const currentKeys = new Set(Object.keys(this.read()));
|
|
@@ -1029,8 +3178,7 @@ class MapHandleImpl {
|
|
|
1029
3178
|
map(entryKey) {
|
|
1030
3179
|
let h = this._childMaps.get(entryKey);
|
|
1031
3180
|
if (!h) {
|
|
1032
|
-
|
|
1033
|
-
h = new MapHandleImpl(anchor, this._owner);
|
|
3181
|
+
h = new MapHandleImpl(this._entryAnchor(entryKey, LoroMap), this._owner, null, this);
|
|
1034
3182
|
this._childMaps.set(entryKey, h);
|
|
1035
3183
|
}
|
|
1036
3184
|
return h;
|
|
@@ -1038,70 +3186,298 @@ class MapHandleImpl {
|
|
|
1038
3186
|
list(entryKey) {
|
|
1039
3187
|
let h = this._childLists.get(entryKey);
|
|
1040
3188
|
if (!h) {
|
|
1041
|
-
|
|
1042
|
-
h = new ListHandleImpl(anchor, this._owner);
|
|
3189
|
+
h = new ListHandleImpl(this._entryAnchor(entryKey, LoroMovableList), this._owner, this);
|
|
1043
3190
|
this._childLists.set(entryKey, h);
|
|
1044
3191
|
}
|
|
1045
3192
|
return h;
|
|
1046
3193
|
}
|
|
3194
|
+
/**
|
|
3195
|
+
* The anchor a scoped handle onto one ENTRY of this map reads and writes
|
|
3196
|
+
* through.
|
|
3197
|
+
*
|
|
3198
|
+
* On a root that can be a record store the entry is a RECORD, and where a
|
|
3199
|
+
* record is stored is a property of the store's size: above the paging
|
|
3200
|
+
* threshold it is packed into a page rather than held under its own key. An
|
|
3201
|
+
* anchor that resolves `parent[entryKey]` therefore finds nothing on a large
|
|
3202
|
+
* store, and creating a container there mints an EMPTY record that outranks
|
|
3203
|
+
* the page's copy, so `records.map(id)` destroys the record it was asked
|
|
3204
|
+
* for. The paged anchor below resolves through the same presentation every
|
|
3205
|
+
* other reader uses, and writes by taking the record over into a container
|
|
3206
|
+
* of its own beside the pages.
|
|
3207
|
+
*
|
|
3208
|
+
* A composed child (a record's `params`) holds fields rather than records
|
|
3209
|
+
* and takes the plain anchor: `_paging` is null there, so nothing below a
|
|
3210
|
+
* root ever reaches the paged path.
|
|
3211
|
+
*/
|
|
3212
|
+
_entryAnchor(entryKey, ctor) {
|
|
3213
|
+
// Where a record written before the store paged still sits, and where a
|
|
3214
|
+
// record taken over by an edit goes. Both cache their resolved container,
|
|
3215
|
+
// so a per-container subscription keeps a stable instance across reads.
|
|
3216
|
+
const direct = childAnchor(this._anchor, entryKey, ctor);
|
|
3217
|
+
if (this._paging === null)
|
|
3218
|
+
return direct;
|
|
3219
|
+
assertUsableRecordKey(entryKey);
|
|
3220
|
+
const overlay = childAnchor(this._anchor, overlayKeyFor(entryKey), ctor);
|
|
3221
|
+
// Whether an edit written beside the pages is waiting to be folded over
|
|
3222
|
+
// this record. Asked only once a container has been found, since a record
|
|
3223
|
+
// with no container of its own is answered from the flattened value
|
|
3224
|
+
// either way. The patch index is a walk of the map's key list, so this
|
|
3225
|
+
// takes the pager WITHOUT the page index: learning which page holds each
|
|
3226
|
+
// record reads every page's key list, which no answer here needs.
|
|
3227
|
+
const patched = (m) => {
|
|
3228
|
+
this._pager ??= new SessionPager(this._paging);
|
|
3229
|
+
const keys = this._pager.patches(m).keysFor(entryKey);
|
|
3230
|
+
return keys !== undefined && keys.length > 0;
|
|
3231
|
+
};
|
|
3232
|
+
return {
|
|
3233
|
+
resolve: () => {
|
|
3234
|
+
if (!this._isPaged())
|
|
3235
|
+
return direct.resolve();
|
|
3236
|
+
const m = this._anchor.resolve();
|
|
3237
|
+
if (!m)
|
|
3238
|
+
return null;
|
|
3239
|
+
// A delete outranks every copy of the record, so a container a page
|
|
3240
|
+
// still shadows reads as absent here as it does everywhere else.
|
|
3241
|
+
if (entryExists(m, tombstoneKeyFor(entryKey)))
|
|
3242
|
+
return null;
|
|
3243
|
+
// Same precedence as `recordBeforePatches`: the edit beside the pages,
|
|
3244
|
+
// then the record under its own key.
|
|
3245
|
+
const own = overlay.resolve() ?? direct.resolve();
|
|
3246
|
+
if (own === null)
|
|
3247
|
+
return null;
|
|
3248
|
+
// A patch is folded over the record's own copy on every read, so while
|
|
3249
|
+
// one exists the container alone is not the record. Answering from it
|
|
3250
|
+
// would report a record the parent map does not.
|
|
3251
|
+
return patched(m) ? null : own;
|
|
3252
|
+
},
|
|
3253
|
+
resolveOrCreate: () => {
|
|
3254
|
+
if (!this._isPaged())
|
|
3255
|
+
return direct.resolveOrCreate();
|
|
3256
|
+
const m = this._anchor.resolveOrCreate();
|
|
3257
|
+
const pager = this._sessionPager(m);
|
|
3258
|
+
const patchIndex = pager.patches(m);
|
|
3259
|
+
const patchKeys = patchIndex.keysFor(entryKey);
|
|
3260
|
+
const hadPatches = patchKeys !== undefined && patchKeys.length > 0;
|
|
3261
|
+
// The record as every other reader resolves it, taken before this
|
|
3262
|
+
// handle takes it over.
|
|
3263
|
+
const record = recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, patchIndex);
|
|
3264
|
+
// From here the container IS the record, so the same bookkeeping a
|
|
3265
|
+
// whole-record write settles is settled here: a patch left behind
|
|
3266
|
+
// applies over the container on the next read and would undo whatever
|
|
3267
|
+
// this handle writes, and a delete marker left behind would hide it.
|
|
3268
|
+
dropPatches(m, patchIndex, entryKey);
|
|
3269
|
+
clearTombstone(m, entryKey);
|
|
3270
|
+
const existing = overlay.resolve() ?? direct.resolve();
|
|
3271
|
+
// A container that already holds the whole record is edited in place.
|
|
3272
|
+
// Seeding it again would rewrite every field, which costs the snapshot
|
|
3273
|
+
// an edit per field and hands a peer's concurrent field edit a
|
|
3274
|
+
// conflict this write has no reason to raise.
|
|
3275
|
+
if (existing !== null && !hadPatches) {
|
|
3276
|
+
pager.liveIndex?.noteContained(entryKey);
|
|
3277
|
+
return existing;
|
|
3278
|
+
}
|
|
3279
|
+
const target = existing ?? overlay.resolveOrCreate();
|
|
3280
|
+
if (target instanceof LoroMap) {
|
|
3281
|
+
if (isPlainObject(record))
|
|
3282
|
+
assignPlain(target, record);
|
|
3283
|
+
}
|
|
3284
|
+
else if (Array.isArray(record)) {
|
|
3285
|
+
assignList(target, record);
|
|
3286
|
+
}
|
|
3287
|
+
pager.liveIndex?.noteContained(entryKey);
|
|
3288
|
+
return target;
|
|
3289
|
+
},
|
|
3290
|
+
// The record as the parent map reports it, for the reads that run when
|
|
3291
|
+
// no container answers: packed in a page, or held as one plain value.
|
|
3292
|
+
plain: () => {
|
|
3293
|
+
if (!this._isPaged())
|
|
3294
|
+
return direct.plain();
|
|
3295
|
+
const m = this._anchor.resolve();
|
|
3296
|
+
if (!m)
|
|
3297
|
+
return undefined;
|
|
3298
|
+
const pager = this._sessionPager(m);
|
|
3299
|
+
return recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, pager.patches(m));
|
|
3300
|
+
},
|
|
3301
|
+
invalidate: () => {
|
|
3302
|
+
direct.invalidate();
|
|
3303
|
+
overlay.invalidate();
|
|
3304
|
+
},
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Loro's list diff as absolute splices.
|
|
3310
|
+
*
|
|
3311
|
+
* The diff is a retain / insert / delete stream over a moving cursor, which is
|
|
3312
|
+
* the same information in a shape a caller has to walk statefully. Splices
|
|
3313
|
+
* carry the position each edit lands at, so an app applies them in order and
|
|
3314
|
+
* is done; nothing about the cursor reaches it.
|
|
3315
|
+
*/
|
|
3316
|
+
function splicesFromListDiff(diff) {
|
|
3317
|
+
const out = [];
|
|
3318
|
+
let at = 0;
|
|
3319
|
+
for (const part of diff) {
|
|
3320
|
+
if (part.retain !== undefined) {
|
|
3321
|
+
at += part.retain;
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
if (part.delete !== undefined) {
|
|
3325
|
+
out.push({ at, removed: part.delete, inserted: [] });
|
|
3326
|
+
continue;
|
|
3327
|
+
}
|
|
3328
|
+
if (part.insert !== undefined) {
|
|
3329
|
+
const inserted = part.insert.map(plainDiffItem);
|
|
3330
|
+
out.push({ at, removed: 0, inserted });
|
|
3331
|
+
at += inserted.length;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
return out;
|
|
3335
|
+
}
|
|
3336
|
+
/**
|
|
3337
|
+
* One inserted element as plain data.
|
|
3338
|
+
*
|
|
3339
|
+
* An object or array element is stored as a child container, and the diff
|
|
3340
|
+
* names it as the raw engine handle. A subscriber applies its splices into
|
|
3341
|
+
* whatever it is rendering from, so handing it that handle both takes the app
|
|
3342
|
+
* onto the CRDT engine's own surface (a field read off it answers `undefined`,
|
|
3343
|
+
* while `JSON.stringify` still prints the record, so the failure is silent)
|
|
3344
|
+
* and hands it a pointer that outlives nothing: it is freed with the document.
|
|
3345
|
+
* The whole-container fire converts through `read()`; this is the same
|
|
3346
|
+
* conversion for the delta.
|
|
3347
|
+
*/
|
|
3348
|
+
function plainDiffItem(item) {
|
|
3349
|
+
return isContainer(item) ? item.toJSON() : item;
|
|
1047
3350
|
}
|
|
1048
3351
|
class ListHandleImpl {
|
|
3352
|
+
// How many items each subscriber currently believes it holds, so a whole
|
|
3353
|
+
// fire can say what it replaces. Tracked per subscriber rather than read off
|
|
3354
|
+
// the container, because a resync swaps the source and the container's own
|
|
3355
|
+
// length says nothing about what this subscriber was handed before it.
|
|
3356
|
+
_held = new Map();
|
|
1049
3357
|
_anchor;
|
|
1050
3358
|
_owner;
|
|
1051
3359
|
// Active subscriptions, tracked so a version checkout can re-fire and
|
|
1052
3360
|
// re-attach each one against the new container.
|
|
1053
3361
|
_registry = new SubscriptionRegistry();
|
|
1054
|
-
|
|
3362
|
+
// See `MapHandleImpl._parent`.
|
|
3363
|
+
_parent;
|
|
3364
|
+
constructor(anchor, owner, parent = null) {
|
|
1055
3365
|
this._anchor = anchor;
|
|
1056
3366
|
this._owner = owner;
|
|
3367
|
+
this._parent = parent;
|
|
3368
|
+
hideOwnFields(this);
|
|
3369
|
+
}
|
|
3370
|
+
// The plain array this handle's key holds, when it holds a value rather
|
|
3371
|
+
// than a container. See `MapHandleImpl._plainBody`.
|
|
3372
|
+
_plainBody() {
|
|
3373
|
+
const v = this._anchor.plain?.();
|
|
3374
|
+
return Array.isArray(v) ? v : undefined;
|
|
1057
3375
|
}
|
|
1058
3376
|
read() {
|
|
3377
|
+
this._owner.assertReadable();
|
|
1059
3378
|
const l = this._anchor.resolve();
|
|
1060
3379
|
if (!l)
|
|
1061
|
-
return [];
|
|
3380
|
+
return (this._plainBody() ?? []);
|
|
1062
3381
|
return l.toJSON();
|
|
1063
3382
|
}
|
|
1064
3383
|
length() {
|
|
3384
|
+
this._owner.assertReadable();
|
|
1065
3385
|
const l = this._anchor.resolve();
|
|
1066
|
-
return l ? l.length : 0;
|
|
3386
|
+
return l ? l.length : (this._plainBody()?.length ?? 0);
|
|
1067
3387
|
}
|
|
1068
3388
|
mutate(fn) {
|
|
1069
3389
|
this._owner.assertWritable();
|
|
1070
3390
|
this._owner.ensureLocalUpdatesSubscribed();
|
|
1071
|
-
|
|
3391
|
+
// Commits as it goes past a size budget; see `mutate-batching.ts`.
|
|
3392
|
+
this._runMutate(fn, new MutateBatcher(() => {
|
|
3393
|
+
this._owner.commitMutate();
|
|
3394
|
+
}));
|
|
1072
3395
|
this._owner.commitMutate();
|
|
1073
3396
|
}
|
|
1074
3397
|
// Run a list mutate without the commit pipeline; see
|
|
1075
3398
|
// `MapHandleImpl._runMutate`. The ordered-map primitive drives this to
|
|
1076
|
-
// reconcile the order list in the same commit as the map edit
|
|
1077
|
-
|
|
3399
|
+
// reconcile the order list in the same commit as the map edit, and passes
|
|
3400
|
+
// no batcher: that call already runs at one of the map's batch boundaries
|
|
3401
|
+
// and reconciles only the keys that boundary carries, so its own size is
|
|
3402
|
+
// bounded by the budget the map is spending.
|
|
3403
|
+
_runMutate(fn, batcher) {
|
|
1078
3404
|
const l = this._anchor.resolveOrCreate();
|
|
1079
|
-
const proxy = wrapLoroListProxy(l);
|
|
3405
|
+
const proxy = wrapLoroListProxy(l, batcher);
|
|
1080
3406
|
fn(proxy);
|
|
1081
3407
|
}
|
|
1082
|
-
// Fire
|
|
1083
|
-
//
|
|
3408
|
+
// Fire the whole list as ONE splice replacing everything the subscriber was
|
|
3409
|
+
// last told. Used for registration and for a checkout / return resync, which
|
|
3410
|
+
// describe no authored transition and so carry no origin.
|
|
3411
|
+
//
|
|
3412
|
+
// `removed` is what this subscriber currently believes it holds, which the
|
|
3413
|
+
// registry tracks per subscriber rather than reading off the container: a
|
|
3414
|
+
// resync swaps the source, so the container's own length says nothing about
|
|
3415
|
+
// what the subscriber was handed before it. That is what lets an app apply
|
|
3416
|
+
// every fire the same way instead of recognising a catch-up.
|
|
1084
3417
|
_fireWhole(cb) {
|
|
3418
|
+
const inserted = this.read();
|
|
3419
|
+
const held = this._held.get(cb) ?? 0;
|
|
3420
|
+
this._held.set(cb, inserted.length);
|
|
1085
3421
|
try {
|
|
1086
|
-
cb();
|
|
3422
|
+
cb({ splices: [{ at: 0, removed: held, inserted }] });
|
|
1087
3423
|
}
|
|
1088
|
-
catch {
|
|
1089
|
-
|
|
3424
|
+
catch (err) {
|
|
3425
|
+
reportSubscriberThrow(err);
|
|
1090
3426
|
}
|
|
1091
3427
|
}
|
|
1092
|
-
// Attach the
|
|
1093
|
-
//
|
|
3428
|
+
// Attach the change subscription for `cb`, returning its detach. A root
|
|
3429
|
+
// list binds straight to its container; a composed one tracks the entry as
|
|
3430
|
+
// it is deleted and written again (see `MapHandleImpl._attachChange`). A
|
|
3431
|
+
// list fire carries no delta, so the tracker needs no key bookkeeping: a
|
|
3432
|
+
// re-bind is a re-read signal like any other fire.
|
|
1094
3433
|
_attachChange(cb) {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
3434
|
+
if (this._parent === null) {
|
|
3435
|
+
const l = this._anchor.resolve();
|
|
3436
|
+
return l === null ? () => { } : this._subscribeContainer(l, cb);
|
|
3437
|
+
}
|
|
3438
|
+
return trackEntryContainer(this._parent._chainRoot(), {
|
|
3439
|
+
resolve: () => this._anchor.resolve(),
|
|
3440
|
+
read: () => this.read(),
|
|
3441
|
+
print: (value) => JSON.stringify(value),
|
|
3442
|
+
catchUp: () => this._fireWhole(cb),
|
|
3443
|
+
attach: (l) => this._subscribeContainer(l, cb),
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
// Subscribe `cb` to one container's own changes; see
|
|
3447
|
+
// `MapHandleImpl._subscribeContainer`.
|
|
3448
|
+
_subscribeContainer(l, cb) {
|
|
3449
|
+
const sub = l.subscribe((event) => {
|
|
3450
|
+
const diff = event.events[0]?.diff.type === 'list'
|
|
3451
|
+
? event.events[0].diff.diff
|
|
3452
|
+
: [];
|
|
3453
|
+
const splices = splicesFromListDiff(diff);
|
|
3454
|
+
let held = this._held.get(cb) ?? 0;
|
|
3455
|
+
for (const splice of splices)
|
|
3456
|
+
held += splice.inserted.length - splice.removed;
|
|
3457
|
+
this._held.set(cb, held);
|
|
3458
|
+
try {
|
|
3459
|
+
cb({ splices }, { source: this._owner.originOf(event) });
|
|
3460
|
+
}
|
|
3461
|
+
catch (err) {
|
|
3462
|
+
reportSubscriberThrow(err);
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
1102
3465
|
return () => sub();
|
|
1103
3466
|
}
|
|
3467
|
+
onSettled(cb) {
|
|
3468
|
+
this._owner.assertReadable();
|
|
3469
|
+
return this._registry.onSettled(cb);
|
|
3470
|
+
}
|
|
1104
3471
|
subscribe(cb) {
|
|
3472
|
+
this._owner.assertReadable();
|
|
3473
|
+
// App subscribers see the change; the internal origin is dropped here so
|
|
3474
|
+
// it never leaks into the public surface.
|
|
3475
|
+
return this._subscribeInternal((change) => cb(change));
|
|
3476
|
+
}
|
|
3477
|
+
// Internal: like `subscribe`, but the callback also receives what produced
|
|
3478
|
+
// the change. Only the ordered-map wrapper uses it, to stamp an `order`
|
|
3479
|
+
// frame the order list's own move produced.
|
|
3480
|
+
_subscribeInternal(cb) {
|
|
1105
3481
|
return this._registry.subscribe(cb, (c) => this._fireWhole(c), (c) => this._attachChange(c));
|
|
1106
3482
|
}
|
|
1107
3483
|
// Re-point this ROOT list handle to `next` without firing (see
|
|
@@ -1128,6 +3504,69 @@ class ListHandleImpl {
|
|
|
1128
3504
|
// lockstep. It reuses the concrete impls (not the interfaces) to drive their
|
|
1129
3505
|
// no-commit `_runMutate` seams and the map's `_existsChecker`, so both halves
|
|
1130
3506
|
// of a structural edit commit once. Built over the SAME handles
|
|
3507
|
+
/**
|
|
3508
|
+
* Records the map holds that the order list does not name, in the map's own
|
|
3509
|
+
* order, which both peers of a merged document read identically.
|
|
3510
|
+
*
|
|
3511
|
+
* The record map and the order list are separate containers and resolve a
|
|
3512
|
+
* conflict independently, so they can disagree in either direction. One peer
|
|
3513
|
+
* deleting a record while another edits it merges to the edit surviving in the
|
|
3514
|
+
* map and the entry gone from the list, which leaves the record present and
|
|
3515
|
+
* unnamed. A reader that walked the order alone would not report it, and the
|
|
3516
|
+
* walk that re-emits a file would drop it, so an ordered read ends with
|
|
3517
|
+
* whatever the order missed rather than losing it.
|
|
3518
|
+
*/
|
|
3519
|
+
function unorderedKeys(live, named) {
|
|
3520
|
+
const out = [];
|
|
3521
|
+
for (const key of live) {
|
|
3522
|
+
if (!named.has(key))
|
|
3523
|
+
out.push(key);
|
|
3524
|
+
}
|
|
3525
|
+
return out;
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* The keys an ordered map presents, from the map's records and the order
|
|
3529
|
+
* list's raw contents.
|
|
3530
|
+
*
|
|
3531
|
+
* Filter to keys still in the map: the primitive keeps the two in step, but a
|
|
3532
|
+
* hand-written or stale order list must not surface a key the map no longer
|
|
3533
|
+
* has. The caller passes `records` and `raw` in rather than re-reading them,
|
|
3534
|
+
* because a subscriber needs the two reads it was derived from to tell one
|
|
3535
|
+
* commit's fires apart, and reading the map is the expensive half (on a paged
|
|
3536
|
+
* store it decodes every page).
|
|
3537
|
+
*/
|
|
3538
|
+
/**
|
|
3539
|
+
* The sequence an ordered map presents, from the order list and the map's LIVE
|
|
3540
|
+
* KEYS.
|
|
3541
|
+
*
|
|
3542
|
+
* Keys rather than records, because that is all the answer depends on: an
|
|
3543
|
+
* entry in the order list appears when the map still holds it, and a record
|
|
3544
|
+
* the list never names is appended. Deriving it from the records instead
|
|
3545
|
+
* charges a whole-store read for a question about membership, which on a
|
|
3546
|
+
* paged store decodes every page, and it runs on every add and every delete.
|
|
3547
|
+
*/
|
|
3548
|
+
function sequenceOf(live, raw) {
|
|
3549
|
+
const seen = new Set();
|
|
3550
|
+
const out = [];
|
|
3551
|
+
for (const key of raw) {
|
|
3552
|
+
if (!live.has(key))
|
|
3553
|
+
continue;
|
|
3554
|
+
// Each key once, at its first position. The order list is a list CRDT and
|
|
3555
|
+
// keeps every insertion, so two peers concurrently adding the SAME key
|
|
3556
|
+
// resolve to one record and two entries naming it; a reader that surfaced
|
|
3557
|
+
// both would report a record this map holds once as present twice.
|
|
3558
|
+
if (seen.has(key))
|
|
3559
|
+
continue;
|
|
3560
|
+
seen.add(key);
|
|
3561
|
+
out.push(key);
|
|
3562
|
+
}
|
|
3563
|
+
for (const key of unorderedKeys(live, seen))
|
|
3564
|
+
out.push(key);
|
|
3565
|
+
return out;
|
|
3566
|
+
}
|
|
3567
|
+
function sameSequence(a, b) {
|
|
3568
|
+
return a.length === b.length && a.every((key, i) => key === b[i]);
|
|
3569
|
+
}
|
|
1131
3570
|
// `doc.map(mapKey)` / `doc.list(orderKey)` return, so subscriptions stay
|
|
1132
3571
|
// consistent.
|
|
1133
3572
|
class OrderedMapHandleImpl {
|
|
@@ -1138,6 +3577,7 @@ class OrderedMapHandleImpl {
|
|
|
1138
3577
|
this._map = _map;
|
|
1139
3578
|
this._order = _order;
|
|
1140
3579
|
this._owner = _owner;
|
|
3580
|
+
hideOwnFields(this);
|
|
1141
3581
|
}
|
|
1142
3582
|
read() {
|
|
1143
3583
|
return this._map.read();
|
|
@@ -1149,30 +3589,140 @@ class OrderedMapHandleImpl {
|
|
|
1149
3589
|
return this._map.has(entryKey);
|
|
1150
3590
|
}
|
|
1151
3591
|
keys() {
|
|
1152
|
-
|
|
1153
|
-
// but a hand-written or stale order list must not surface a key the map
|
|
1154
|
-
// no longer has. Read the map ONCE (its bulk snapshot) and test
|
|
1155
|
-
// membership against it, the same shape `ordered()` uses. A per-key
|
|
1156
|
-
// `_existsChecker` predicate would re-decode a paged store's page once
|
|
1157
|
-
// per record it holds (one gunzip per key, ~page-size times per page),
|
|
1158
|
-
// and `subscribe` calls this on every structural move, so a single edit
|
|
1159
|
-
// on a large paged doc would cost O(records) page decodes instead of
|
|
1160
|
-
// O(pages).
|
|
1161
|
-
const records = this._map.read();
|
|
1162
|
-
return this._order
|
|
1163
|
-
.read()
|
|
1164
|
-
.filter((k) => Object.prototype.hasOwnProperty.call(records, k));
|
|
3592
|
+
return sequenceOf(this._map.liveKeySet(), this._order.read());
|
|
1165
3593
|
}
|
|
1166
3594
|
ordered() {
|
|
1167
3595
|
const records = this._map.read();
|
|
3596
|
+
const seen = new Set();
|
|
1168
3597
|
const out = [];
|
|
1169
3598
|
for (const key of this._order.read()) {
|
|
1170
3599
|
if (Object.prototype.hasOwnProperty.call(records, key)) {
|
|
3600
|
+
if (seen.has(key))
|
|
3601
|
+
continue;
|
|
3602
|
+
seen.add(key);
|
|
1171
3603
|
out.push({ key, value: records[key] });
|
|
1172
3604
|
}
|
|
1173
3605
|
}
|
|
3606
|
+
for (const key of unorderedKeys(Object.keys(records), seen)) {
|
|
3607
|
+
out.push({ key, value: records[key] });
|
|
3608
|
+
}
|
|
1174
3609
|
return out;
|
|
1175
3610
|
}
|
|
3611
|
+
insert(entryKey, value, at) {
|
|
3612
|
+
this._owner.assertWritable();
|
|
3613
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
3614
|
+
// Resolved before the map write, so the destination is read off the
|
|
3615
|
+
// collection the caller was looking at rather than one this call has
|
|
3616
|
+
// already added a key to.
|
|
3617
|
+
const raw = this._order.read();
|
|
3618
|
+
const target = this._insertIndex(raw, entryKey, at);
|
|
3619
|
+
this._map._runMutate((inner) => {
|
|
3620
|
+
inner[entryKey] = value;
|
|
3621
|
+
});
|
|
3622
|
+
this._placeKey(raw, entryKey, target);
|
|
3623
|
+
this._owner.commitMutate();
|
|
3624
|
+
}
|
|
3625
|
+
move(entryKey, to) {
|
|
3626
|
+
this._owner.assertWritable();
|
|
3627
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
3628
|
+
// A key the map does not hold gets no order entry: the list names what
|
|
3629
|
+
// the map holds, and a peer's delete of this key is the case that puts a
|
|
3630
|
+
// reader here, so writing the entry would resurrect a name for a record
|
|
3631
|
+
// that is gone.
|
|
3632
|
+
if (!this._map._existsChecker()(entryKey))
|
|
3633
|
+
return;
|
|
3634
|
+
const raw = this._order.read();
|
|
3635
|
+
if (!this._placeKey(raw, entryKey, this._insertIndex(raw, entryKey, to)))
|
|
3636
|
+
return;
|
|
3637
|
+
this._owner.commitMutate();
|
|
3638
|
+
}
|
|
3639
|
+
/**
|
|
3640
|
+
* The order-list index a position resolves to.
|
|
3641
|
+
*
|
|
3642
|
+
* Resolved against the RAW list rather than the sequence a reader sees, so
|
|
3643
|
+
* the key lands beside the anchor's own entry. The two disagree only where
|
|
3644
|
+
* the list names a key the map no longer holds, and a dead neighbour still
|
|
3645
|
+
* names the right neighbourhood.
|
|
3646
|
+
*/
|
|
3647
|
+
_insertIndex(raw, entryKey, at) {
|
|
3648
|
+
if ('index' in at) {
|
|
3649
|
+
const want = Math.trunc(Number(at.index));
|
|
3650
|
+
if (!Number.isFinite(want))
|
|
3651
|
+
return raw.length;
|
|
3652
|
+
if (want <= 0)
|
|
3653
|
+
return 0;
|
|
3654
|
+
// An index counts the positions a reader is shown, and the raw list
|
|
3655
|
+
// holds entries no reader sees (a key the map lost, a duplicate the
|
|
3656
|
+
// sequence reports once), so the index names whichever key stands
|
|
3657
|
+
// there and the answer is that key's own entry.
|
|
3658
|
+
//
|
|
3659
|
+
// Counted with this key taken out, so an index means the position the
|
|
3660
|
+
// key ENDS at either way. Left in, a key moving down the collection
|
|
3661
|
+
// lands one short of the index named, because lifting it out shifts
|
|
3662
|
+
// everything above it.
|
|
3663
|
+
const seq = sequenceOf(this._map.liveKeySet(), raw).filter((key) => key !== entryKey);
|
|
3664
|
+
if (want >= seq.length)
|
|
3665
|
+
return raw.length;
|
|
3666
|
+
const found = raw.indexOf(seq[want]);
|
|
3667
|
+
return found === -1 ? raw.length : found;
|
|
3668
|
+
}
|
|
3669
|
+
const anchor = 'before' in at ? at.before : at.after;
|
|
3670
|
+
const found = raw.indexOf(anchor);
|
|
3671
|
+
if (found === -1)
|
|
3672
|
+
return raw.length;
|
|
3673
|
+
return 'before' in at ? found : found + 1;
|
|
3674
|
+
}
|
|
3675
|
+
/**
|
|
3676
|
+
* Put `entryKey` at `target` in the order list, reporting whether that
|
|
3677
|
+
* wrote anything.
|
|
3678
|
+
*
|
|
3679
|
+
* A key the list already names is REPOSITIONED (`move`), never re-inserted:
|
|
3680
|
+
* the list stores as a `LoroMovableList`, so a move names the entry it
|
|
3681
|
+
* carries and two peers repositioning the same key converge on a list
|
|
3682
|
+
* naming it once, where a delete plus an insert commutes into a list naming
|
|
3683
|
+
* it twice.
|
|
3684
|
+
*
|
|
3685
|
+
* The plan is computed on the snapshot the caller already read, so the
|
|
3686
|
+
* draft takes the writes and no scan.
|
|
3687
|
+
*/
|
|
3688
|
+
_placeKey(raw, entryKey, target) {
|
|
3689
|
+
const from = raw.indexOf(entryKey);
|
|
3690
|
+
if (from === -1) {
|
|
3691
|
+
this._order._runMutate((arr) => {
|
|
3692
|
+
arr.splice(target, 0, entryKey);
|
|
3693
|
+
});
|
|
3694
|
+
return true;
|
|
3695
|
+
}
|
|
3696
|
+
// Entries beyond the first naming this key. Two peers adding one key
|
|
3697
|
+
// leave the list naming it twice and a reader takes the first, so a
|
|
3698
|
+
// reposition that left the rest standing would put the key where the
|
|
3699
|
+
// reader is not looking. Dropping them names the key once and loses
|
|
3700
|
+
// nothing: the entry this call keeps carries it.
|
|
3701
|
+
const extras = [];
|
|
3702
|
+
for (let i = from + 1; i < raw.length; i += 1) {
|
|
3703
|
+
if (raw[i] === entryKey)
|
|
3704
|
+
extras.push(i);
|
|
3705
|
+
}
|
|
3706
|
+
// Positions after the drops: `from` is below every extra, and an
|
|
3707
|
+
// insertion index loses the extras below it. The move destination then
|
|
3708
|
+
// counts positions with the entry lifted out, which is one less than the
|
|
3709
|
+
// insertion index whenever the entry stands before it.
|
|
3710
|
+
let want = target;
|
|
3711
|
+
for (const extra of extras)
|
|
3712
|
+
if (extra < target)
|
|
3713
|
+
want -= 1;
|
|
3714
|
+
const dest = want > from ? want - 1 : want;
|
|
3715
|
+
if (dest === from && extras.length === 0)
|
|
3716
|
+
return false;
|
|
3717
|
+
this._order._runMutate((arr) => {
|
|
3718
|
+
for (let i = extras.length - 1; i >= 0; i -= 1) {
|
|
3719
|
+
arr.splice(extras[i], 1);
|
|
3720
|
+
}
|
|
3721
|
+
if (dest !== from)
|
|
3722
|
+
arr.move(from, dest);
|
|
3723
|
+
});
|
|
3724
|
+
return true;
|
|
3725
|
+
}
|
|
1176
3726
|
mutate(fn) {
|
|
1177
3727
|
this._owner.assertWritable();
|
|
1178
3728
|
this._owner.ensureLocalUpdatesSubscribed();
|
|
@@ -1187,39 +3737,63 @@ class OrderedMapHandleImpl {
|
|
|
1187
3737
|
// the order entries for those same records. Left to the end instead,
|
|
1188
3738
|
// the final commit carries every key at once: measured at 229 KB for a
|
|
1189
3739
|
// 20,000-record write, an oversized op of its own.
|
|
1190
|
-
|
|
1191
|
-
|
|
3740
|
+
//
|
|
3741
|
+
// `carried` names the records whose writes the commit about to be taken
|
|
3742
|
+
// covers, and the boundary reconciles those alone. A paged store assembles
|
|
3743
|
+
// its whole overlay before it writes any of it, so by the first boundary
|
|
3744
|
+
// every key of the mutation is already in `added`, and appending all of
|
|
3745
|
+
// them there would put the whole order list in that first op and name
|
|
3746
|
+
// records the store does not hold yet. The final call passes nothing and
|
|
3747
|
+
// lands whatever is left.
|
|
3748
|
+
const reconcileOrder = (carried) => {
|
|
3749
|
+
const covers = carried === undefined ? null : new Set(carried);
|
|
3750
|
+
const appends = [...added].filter((k) => covers === null || covers.has(k));
|
|
3751
|
+
const drops = [...removed].filter((k) => covers === null || covers.has(k));
|
|
3752
|
+
if (appends.length === 0 && drops.length === 0)
|
|
1192
3753
|
return;
|
|
3754
|
+
const dropped = new Set(drops);
|
|
1193
3755
|
this._order._runMutate((arr) => {
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
3756
|
+
if (dropped.size > 0) {
|
|
3757
|
+
// Remove exactly the entries that went, in place. Rebuilding the
|
|
3758
|
+
// list instead (truncate, then re-push what survived) makes the
|
|
3759
|
+
// whole list one write, and two peers each removing a different
|
|
3760
|
+
// record then merge to a list holding both re-insertions plus the
|
|
3761
|
+
// entries they each meant to drop. Walking down keeps the indices
|
|
3762
|
+
// below the cursor valid, and a run of adjacent removals goes in one
|
|
3763
|
+
// splice so a bulk delete costs runs rather than entries.
|
|
3764
|
+
const current = [...arr];
|
|
3765
|
+
let runEnd = -1;
|
|
3766
|
+
for (let i = current.length - 1; i >= -1; i -= 1) {
|
|
3767
|
+
const drop = i >= 0 && dropped.has(current[i]);
|
|
3768
|
+
if (drop && runEnd === -1)
|
|
3769
|
+
runEnd = i;
|
|
3770
|
+
if (!drop && runEnd !== -1) {
|
|
3771
|
+
arr.splice(i + 1, runEnd - i);
|
|
3772
|
+
runEnd = -1;
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
1199
3775
|
}
|
|
1200
|
-
for (const k of
|
|
3776
|
+
for (const k of appends)
|
|
1201
3777
|
arr.push(k);
|
|
1202
3778
|
});
|
|
1203
|
-
|
|
1204
|
-
|
|
3779
|
+
for (const k of appends)
|
|
3780
|
+
added.delete(k);
|
|
3781
|
+
for (const k of drops)
|
|
3782
|
+
removed.delete(k);
|
|
1205
3783
|
};
|
|
1206
|
-
const batcher =
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
})
|
|
1211
|
-
: null;
|
|
3784
|
+
const batcher = new MutateBatcher((carried) => {
|
|
3785
|
+
reconcileOrder(carried);
|
|
3786
|
+
this._owner.commitMutate();
|
|
3787
|
+
});
|
|
1212
3788
|
this._map._runMutate((inner) => {
|
|
1213
3789
|
// "is this key new" (and so must be appended to the order list) is O(1)
|
|
1214
3790
|
// per set; a repeat set of the same new key is guarded by `added`.
|
|
1215
|
-
// `exists`
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
// re-add of a pre-existing key from being mis-seen as a fresh add and
|
|
1222
|
-
// appended to the order list a second time.
|
|
3791
|
+
// `exists` answers over the key set the map held at the START of this
|
|
3792
|
+
// batch, so it is the whole test for "present before the batch" and this
|
|
3793
|
+
// batch's own writes cannot move it. `added` and `removed` carry what
|
|
3794
|
+
// the batch has done on top of it, which is what makes a delete followed
|
|
3795
|
+
// by any number of re-sets append to the order list zero times rather
|
|
3796
|
+
// than once per set.
|
|
1223
3797
|
const exists = this._map._existsChecker();
|
|
1224
3798
|
const tracking = new Proxy({}, {
|
|
1225
3799
|
get: (_t, key) => typeof key === 'symbol'
|
|
@@ -1229,9 +3803,8 @@ class OrderedMapHandleImpl {
|
|
|
1229
3803
|
if (typeof key === 'symbol')
|
|
1230
3804
|
return false;
|
|
1231
3805
|
const k = key;
|
|
1232
|
-
if (!added.has(k) && !exists(k)
|
|
3806
|
+
if (!added.has(k) && !exists(k))
|
|
1233
3807
|
added.add(k);
|
|
1234
|
-
}
|
|
1235
3808
|
// A re-add after a delete in the same batch cancels the delete.
|
|
1236
3809
|
removed.delete(k);
|
|
1237
3810
|
inner[k] = value;
|
|
@@ -1247,17 +3820,11 @@ class OrderedMapHandleImpl {
|
|
|
1247
3820
|
added.delete(k);
|
|
1248
3821
|
}
|
|
1249
3822
|
else if (exists(k)) {
|
|
1250
|
-
// Only
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
// must NOT enter `removed`: a later re-`set` of that key in the
|
|
1256
|
-
// same batch consults `removed.has(k)` to decide "re-add of a
|
|
1257
|
-
// pre-existing key" (skip the order append) vs "fresh add"
|
|
1258
|
-
// (append). A spurious `removed` entry makes the fresh add look
|
|
1259
|
-
// like a re-add, so the key lands in the record map but never in
|
|
1260
|
-
// `instance_order`, a silent divergence between the two.
|
|
3823
|
+
// Only a key the order list already named can be dropped from
|
|
3824
|
+
// it, and `exists` is exactly that set, so `removed` stays a
|
|
3825
|
+
// subset of it. Deleting a key that was already absent records
|
|
3826
|
+
// nothing, which leaves a later re-`set` of it in the same batch
|
|
3827
|
+
// reading as the fresh add it is.
|
|
1261
3828
|
removed.add(k);
|
|
1262
3829
|
}
|
|
1263
3830
|
delete inner[k];
|
|
@@ -1270,53 +3837,92 @@ class OrderedMapHandleImpl {
|
|
|
1270
3837
|
getOwnPropertyDescriptor: (_t, key) => Object.getOwnPropertyDescriptor(inner, key),
|
|
1271
3838
|
});
|
|
1272
3839
|
fn(tracking);
|
|
1273
|
-
}, batcher
|
|
3840
|
+
}, batcher);
|
|
1274
3841
|
reconcileOrder();
|
|
1275
3842
|
this._owner.commitMutate();
|
|
1276
3843
|
}
|
|
3844
|
+
/** Settles with its map. The order list is delivered in the same breath, so
|
|
3845
|
+
* one signal covers both halves and an app is never told the records are in
|
|
3846
|
+
* while their sequence is still arriving. */
|
|
3847
|
+
onSettled(cb) {
|
|
3848
|
+
return this._map.onSettled(cb);
|
|
3849
|
+
}
|
|
1277
3850
|
subscribe(cb) {
|
|
1278
|
-
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
3851
|
+
this._owner.assertReadable();
|
|
3852
|
+
// Content (`set` / `deleted`) rides the map subscription. `order` rides
|
|
3853
|
+
// whichever of the two containers moved it, and reaches the subscriber at
|
|
3854
|
+
// most once per commit: `lastOrder` is the sequence it was last handed, so
|
|
3855
|
+
// the second fire of a commit that changed both containers has nothing to
|
|
3856
|
+
// add.
|
|
3857
|
+
//
|
|
3858
|
+
// The map's fire tests the key set against `known`, which is O(changed)
|
|
3859
|
+
// and catches the ordinary add / delete without walking the order; a field
|
|
3860
|
+
// edit must not pay for a re-sequence. `known` starts empty so the initial
|
|
3861
|
+
// fire reads as an all-new move and carries the starting `order`.
|
|
1284
3862
|
//
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
1293
|
-
//
|
|
3863
|
+
// What that test cannot see reaches the ORDER LIST's own fire: a raw edit
|
|
3864
|
+
// of the order list moves the sequence while the map stands still, and a
|
|
3865
|
+
// merge can resequence keys whose membership never changed (two peers
|
|
3866
|
+
// adding the same key leave one record and two entries naming it). Both
|
|
3867
|
+
// are silent on the map, so without that subscription the app's rendered
|
|
3868
|
+
// sequence disagrees with `keys()` and no later content change corrects
|
|
3869
|
+
// it.
|
|
3870
|
+
//
|
|
3871
|
+
// A whole-container re-sync (registration, or a checkout / return /
|
|
3872
|
+
// hydrate swapping the source) can present the SAME key set in a different
|
|
3873
|
+
// order, which the membership test cannot see either. The fire says which
|
|
3874
|
+
// it is (`resync`), so those read the sequence directly.
|
|
1294
3875
|
const known = new Set();
|
|
1295
3876
|
let lastOrder = [];
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
3877
|
+
// The order list's raw contents the last delivered sequence was derived
|
|
3878
|
+
// from. The order fire compares against it FIRST, so the fire that follows
|
|
3879
|
+
// the map's own for one commit costs a list read rather than a re-read of
|
|
3880
|
+
// the map, which on a paged store decodes every page.
|
|
3881
|
+
let lastRaw = [];
|
|
3882
|
+
// The sequence as it stands, or null when the subscriber already has it.
|
|
3883
|
+
//
|
|
3884
|
+
// Derived from `known`, which the map fire has already brought up to date
|
|
3885
|
+
// and which IS this map's live key set: every fire adds what it delivered
|
|
3886
|
+
// and drops what it removed, a resync included. The alternative, asking
|
|
3887
|
+
// the map, answers the same question by materialising every record, which
|
|
3888
|
+
// on a paged store decodes every page on every add and every delete.
|
|
3889
|
+
const moved = () => {
|
|
3890
|
+
lastRaw = this._order.read();
|
|
3891
|
+
const order = sequenceOf(known, lastRaw);
|
|
3892
|
+
if (sameSequence(order, lastOrder))
|
|
3893
|
+
return null;
|
|
3894
|
+
lastOrder = order;
|
|
3895
|
+
return order;
|
|
3896
|
+
};
|
|
3897
|
+
const detachMap = this._map._subscribeInternal((change, origin, resync) => {
|
|
3898
|
+
let structural = change.deleted.length > 0;
|
|
3899
|
+
for (const key of change.set.keys()) {
|
|
1300
3900
|
if (!known.has(key))
|
|
1301
|
-
|
|
1302
|
-
let orderKeys = null;
|
|
1303
|
-
if (!moved && resync) {
|
|
1304
|
-
orderKeys = this.keys();
|
|
1305
|
-
if (!sameOrder(orderKeys, lastOrder))
|
|
1306
|
-
moved = true;
|
|
1307
|
-
}
|
|
1308
|
-
if (!moved) {
|
|
1309
|
-
cb(change, origin);
|
|
1310
|
-
return;
|
|
3901
|
+
structural = true;
|
|
1311
3902
|
}
|
|
1312
3903
|
for (const key of change.deleted)
|
|
1313
3904
|
known.delete(key);
|
|
1314
3905
|
for (const key of change.set.keys())
|
|
1315
3906
|
known.add(key);
|
|
1316
|
-
const order =
|
|
1317
|
-
|
|
3907
|
+
const order = structural || resync ? moved() : null;
|
|
3908
|
+
if (order === null) {
|
|
3909
|
+
cb(change, origin);
|
|
3910
|
+
return;
|
|
3911
|
+
}
|
|
1318
3912
|
cb({ set: change.set, deleted: change.deleted, order }, origin);
|
|
1319
3913
|
});
|
|
3914
|
+
const detachOrder = this._order._subscribeInternal((_change, origin) => {
|
|
3915
|
+
if (sameSequence(this._order.read(), lastRaw))
|
|
3916
|
+
return;
|
|
3917
|
+
const order = moved();
|
|
3918
|
+
if (order === null)
|
|
3919
|
+
return;
|
|
3920
|
+
cb({ set: new Map(), deleted: [], order }, origin);
|
|
3921
|
+
});
|
|
3922
|
+
return () => {
|
|
3923
|
+
detachMap();
|
|
3924
|
+
detachOrder();
|
|
3925
|
+
};
|
|
1320
3926
|
}
|
|
1321
3927
|
}
|
|
1322
3928
|
// Factory functions called by the Document. App code reaches handles via
|
|
@@ -1325,8 +3931,8 @@ class OrderedMapHandleImpl {
|
|
|
1325
3931
|
// transparently as a flat record map. The Document supplies it for every root
|
|
1326
3932
|
// map; a non-paged map never consults it, so passing it to non-record roots
|
|
1327
3933
|
// is harmless. Omitted means the handle only serves the plain unpaged shape.
|
|
1328
|
-
export function createRootMapHandle(root, owner,
|
|
1329
|
-
return new MapHandleImpl(rootAnchor(root), owner,
|
|
3934
|
+
export function createRootMapHandle(root, owner, paging) {
|
|
3935
|
+
return new MapHandleImpl(rootAnchor(root), owner, paging ?? null);
|
|
1330
3936
|
}
|
|
1331
3937
|
export function createRootListHandle(root, owner) {
|
|
1332
3938
|
return new ListHandleImpl(rootAnchor(root), owner);
|
|
@@ -1351,6 +3957,21 @@ export function rebindRootMapAnchor(handle, next) {
|
|
|
1351
3957
|
export function rebindRootListAnchor(handle, next) {
|
|
1352
3958
|
handle._rebindAnchor(next);
|
|
1353
3959
|
}
|
|
3960
|
+
// The first step of the swap: capture a map handle's key set while the handle still
|
|
3961
|
+
// resolves the outgoing source in BOTH respects, its container and the paging
|
|
3962
|
+
// state it reads that container under. The Document runs this over every root
|
|
3963
|
+
// map handle before it moves its active-source pointer, because the paging
|
|
3964
|
+
// state follows that pointer while the container follows the anchor: between
|
|
3965
|
+
// the two moves a handle reads the outgoing container under the incoming
|
|
3966
|
+
// source's paging state, which yields the store's `brass:` bookkeeping keys
|
|
3967
|
+
// rather than its records, and the resync then reports those as the keys the
|
|
3968
|
+
// swap removed while every record it actually removed goes unreported.
|
|
3969
|
+
//
|
|
3970
|
+
// Lists need no counterpart: they carry no per-key delta, so a list rebind has
|
|
3971
|
+
// nothing to diff.
|
|
3972
|
+
export function snapshotRootMapKeys(handle) {
|
|
3973
|
+
handle._snapshotPriorKeys();
|
|
3974
|
+
}
|
|
1354
3975
|
// Re-fire + re-attach a root handle's subscribers against its current
|
|
1355
3976
|
// container. Both concrete impls expose `_resync`, so one generic reaches
|
|
1356
3977
|
// either kind; the cast is safe because this module is the only producer of
|