@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/dist/handles.js
ADDED
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
// Typed container handles. A handle is a scoped reference to a container in
|
|
2
|
+
// the CRDT doc; its `read` / `mutate` / `subscribe` only see (and only pay
|
|
3
|
+
// for) that container.
|
|
4
|
+
//
|
|
5
|
+
// Engine-neutral: Loro events are translated to a minimal `Change` shape at
|
|
6
|
+
// the handle boundary, so handle-only app code does not pin the SDK to Loro.
|
|
7
|
+
import { LoroList, LoroMap, isContainer, } from 'loro-crdt';
|
|
8
|
+
import { PAGE_TAG, decodePageBody, decodeRecordValue, encodePageBody, flattenPagedLookup, isPagingSchemeEditable, pageKeyFor, peekRecordTag, } from './record-paging.js';
|
|
9
|
+
import { BrassError } from './errors.js';
|
|
10
|
+
import { MutateBatcher } from './mutate-batching.js';
|
|
11
|
+
// ---- Plain-JS-proxy machinery, scoped per-container --------------------
|
|
12
|
+
function isPlainObject(v) {
|
|
13
|
+
return (v !== null &&
|
|
14
|
+
typeof v === 'object' &&
|
|
15
|
+
!Array.isArray(v) &&
|
|
16
|
+
v.constructor === Object);
|
|
17
|
+
}
|
|
18
|
+
// A value being assigned may be a live Loro container handle (a nested list
|
|
19
|
+
// read back through `m[key].listField`, or a nested-map proxy). Re-inserting
|
|
20
|
+
// an already-attached handle as a value corrupts it: it lands as an opaque
|
|
21
|
+
// reference and is lost on the next snapshot. Materialize it to a deep plain
|
|
22
|
+
// value first, so the assign path rebuilds it as a fresh container.
|
|
23
|
+
function materializeContainers(v) {
|
|
24
|
+
return isContainer(v) ? v.toJSON() : v;
|
|
25
|
+
}
|
|
26
|
+
// Deep-plain conversion for structures stored as plain Loro VALUES: any
|
|
27
|
+
// nested container handle or wrapped proxy materialises to pure JSON, and
|
|
28
|
+
// `undefined` entries drop (the documented JSON-serialise semantics of
|
|
29
|
+
// `mutate`).
|
|
30
|
+
function toPlainDeep(v) {
|
|
31
|
+
if (isContainer(v))
|
|
32
|
+
return v.toJSON();
|
|
33
|
+
if (Array.isArray(v))
|
|
34
|
+
return v.map(toPlainDeep);
|
|
35
|
+
if (v !== null && typeof v === 'object') {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [k, x] of Object.entries(v)) {
|
|
38
|
+
if (x !== undefined)
|
|
39
|
+
out[k] = toPlainDeep(x);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
function assignPlain(target, value) {
|
|
46
|
+
// Recursively materialize nested objects / arrays as nested LoroMap /
|
|
47
|
+
// LoroList containers so the CRDT mirrors the JS shape. Clear keys not in
|
|
48
|
+
// `value` so assignment is a replace, not a merge (property-level patching
|
|
49
|
+
// uses `d.entities[id].x = ...`).
|
|
50
|
+
for (const k of target.keys()) {
|
|
51
|
+
if (!(k in value))
|
|
52
|
+
target.delete(k);
|
|
53
|
+
}
|
|
54
|
+
for (const [k, rawV] of Object.entries(value)) {
|
|
55
|
+
const v = materializeContainers(rawV);
|
|
56
|
+
// Plain-VALUE preservation (as in the proxy set trap): a key holding a
|
|
57
|
+
// plain object/array value (a record body) stays a value, never promoted.
|
|
58
|
+
const existingV = target.get(k);
|
|
59
|
+
if (existingV !== undefined &&
|
|
60
|
+
!isContainer(existingV) &&
|
|
61
|
+
typeof existingV === 'object' &&
|
|
62
|
+
existingV !== null &&
|
|
63
|
+
typeof v === 'object' &&
|
|
64
|
+
v !== null) {
|
|
65
|
+
target.set(k, toPlainDeep(v));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isPlainObject(v)) {
|
|
69
|
+
// Reuse an existing LoroMap at this key: a fresh `setContainer` mints a
|
|
70
|
+
// new container ID, which under Loro's UndoManager is a structural
|
|
71
|
+
// destroy+create that does NOT merge within the merge window. In-place
|
|
72
|
+
// field overwrites DO merge, which whole-entity-rewrites-at-drag-cadence
|
|
73
|
+
// expects.
|
|
74
|
+
const existing = target.get(k);
|
|
75
|
+
const sub = existing instanceof LoroMap
|
|
76
|
+
? existing
|
|
77
|
+
: target.setContainer(k, new LoroMap());
|
|
78
|
+
assignPlain(sub, v);
|
|
79
|
+
}
|
|
80
|
+
else if (Array.isArray(v)) {
|
|
81
|
+
const existingList = target.get(k);
|
|
82
|
+
const sub = existingList instanceof LoroList
|
|
83
|
+
? existingList
|
|
84
|
+
: target.setContainer(k, new LoroList());
|
|
85
|
+
assignList(sub, v);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
target.set(k, v);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function assignList(target, value) {
|
|
93
|
+
// Same rationale as the LoroMap reuse above: replace contents in place
|
|
94
|
+
// rather than minting a new LoroList, so adjacent commits collapse into one
|
|
95
|
+
// undo step.
|
|
96
|
+
const oldLen = target.length;
|
|
97
|
+
for (let i = 0; i < value.length; i++) {
|
|
98
|
+
const item = materializeContainers(value[i]);
|
|
99
|
+
if (isPlainObject(item)) {
|
|
100
|
+
if (i < oldLen)
|
|
101
|
+
target.delete(i, 1);
|
|
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
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (value.length < oldLen) {
|
|
124
|
+
target.delete(value.length, oldLen - value.length);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Append one value at the tail (same plain-vs-container handling as the
|
|
128
|
+
// indexed-set trap). The proxy's `push` fast-path uses this so a tail append
|
|
129
|
+
// stays O(1) instead of the full-list replay.
|
|
130
|
+
function insertListTail(target, rawItem) {
|
|
131
|
+
const idx = target.length;
|
|
132
|
+
const item = materializeContainers(rawItem);
|
|
133
|
+
if (isPlainObject(item)) {
|
|
134
|
+
const child = target.insertContainer(idx, new LoroMap());
|
|
135
|
+
assignPlain(child, item);
|
|
136
|
+
}
|
|
137
|
+
else if (Array.isArray(item)) {
|
|
138
|
+
const child = target.insertContainer(idx, new LoroList());
|
|
139
|
+
assignList(child, item);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
target.insert(idx, item);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Exported because `Document.mutate` reuses the same proxy for its
|
|
146
|
+
// `entities` root. Load-bearing: `getOwnPropertyDescriptor` does a single
|
|
147
|
+
// `loroMap.get(key)` (O(1) per key, so `Object.keys()` is O(N)). Iterating
|
|
148
|
+
// `loroMap.keys()` in the descriptor trap makes `Object.keys()` O(N^2) at
|
|
149
|
+
// 50k entities (measured at >5 min of CPU before giving up).
|
|
150
|
+
export function wrapLoroMapProxy(loroMap,
|
|
151
|
+
// Threaded into nested record proxies too, so a field edit counts. A
|
|
152
|
+
// mutation can hold thousands of them (`m[id].params = ...` in a loop),
|
|
153
|
+
// which reaches the same size a bulk write does while touching no
|
|
154
|
+
// top-level key.
|
|
155
|
+
batcher) {
|
|
156
|
+
const handler = {
|
|
157
|
+
get(_t, key) {
|
|
158
|
+
if (typeof key === 'symbol')
|
|
159
|
+
return undefined;
|
|
160
|
+
const v = loroMap.get(key);
|
|
161
|
+
if (isContainer(v)) {
|
|
162
|
+
// Present an engine-neutral view, never the raw container, so app
|
|
163
|
+
// code can't take a hard dependency on the CRDT engine. A LoroMap
|
|
164
|
+
// recurses into another proxy; a LoroList reads back via `toJSON`
|
|
165
|
+
// (not `toArray`, which is shallow and hands back live element
|
|
166
|
+
// handles that corrupt on reassignment; list editing goes through
|
|
167
|
+
// `parent.list(key).mutate(...)`). Other container types are outside
|
|
168
|
+
// the v1 portable surface, so project them via toJSON().
|
|
169
|
+
if (v instanceof LoroMap)
|
|
170
|
+
return wrapLoroMapProxy(v, batcher);
|
|
171
|
+
if (v instanceof LoroList)
|
|
172
|
+
return v.toJSON();
|
|
173
|
+
return v.toJSON();
|
|
174
|
+
}
|
|
175
|
+
return v;
|
|
176
|
+
},
|
|
177
|
+
set(_t, key, value) {
|
|
178
|
+
if (typeof key === 'symbol')
|
|
179
|
+
return false;
|
|
180
|
+
// Before the write, so a commit it triggers seals the preceding
|
|
181
|
+
// batch rather than splitting this record out of it.
|
|
182
|
+
batcher?.record(value);
|
|
183
|
+
const existing = loroMap.get(key);
|
|
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));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (isPlainObject(value)) {
|
|
200
|
+
// Reuse the existing LoroMap (see `assignPlain`): a fresh
|
|
201
|
+
// `setContainer` is a destroy+create Loro won't merge across, so
|
|
202
|
+
// `entities[id] = {...}` at drag cadence accumulates one undo entry
|
|
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
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
deleteProperty(_t, key) {
|
|
221
|
+
if (typeof key === 'symbol')
|
|
222
|
+
return false;
|
|
223
|
+
batcher?.recordDelete();
|
|
224
|
+
loroMap.delete(key);
|
|
225
|
+
return true;
|
|
226
|
+
},
|
|
227
|
+
has(_t, key) {
|
|
228
|
+
if (typeof key === 'symbol')
|
|
229
|
+
return false;
|
|
230
|
+
return loroMap.get(key) !== undefined;
|
|
231
|
+
},
|
|
232
|
+
ownKeys() {
|
|
233
|
+
return loroMap.keys();
|
|
234
|
+
},
|
|
235
|
+
getOwnPropertyDescriptor(_t, key) {
|
|
236
|
+
if (typeof key === 'symbol')
|
|
237
|
+
return undefined;
|
|
238
|
+
// Single get per key. Do NOT iterate `loroMap.keys()` here: it makes
|
|
239
|
+
// Object.keys() O(N^2) at 50k entities.
|
|
240
|
+
const v = loroMap.get(key);
|
|
241
|
+
if (v === undefined)
|
|
242
|
+
return undefined;
|
|
243
|
+
return {
|
|
244
|
+
enumerable: true,
|
|
245
|
+
configurable: true,
|
|
246
|
+
writable: true,
|
|
247
|
+
value: v,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
return new Proxy({}, handler);
|
|
252
|
+
}
|
|
253
|
+
// Array.prototype methods that mutate the array in place. Called on a list
|
|
254
|
+
// mutate-proxy, their result is replayed into the LoroList; every other
|
|
255
|
+
// method is a pure read that leaves the container untouched, so a `find` /
|
|
256
|
+
// `findIndex` inside a mutate reads fields without rewriting the whole list.
|
|
257
|
+
// `push` is fast-pathed natively above the generic fallback and never reaches
|
|
258
|
+
// this set.
|
|
259
|
+
const MUTATING_ARRAY_METHODS = new Set([
|
|
260
|
+
'pop',
|
|
261
|
+
'shift',
|
|
262
|
+
'unshift',
|
|
263
|
+
'splice',
|
|
264
|
+
'sort',
|
|
265
|
+
'reverse',
|
|
266
|
+
'fill',
|
|
267
|
+
'copyWithin',
|
|
268
|
+
]);
|
|
269
|
+
// An array-proxy for list mutators: index assignments, `.push` / `.splice`,
|
|
270
|
+
// and length-set route into the underlying LoroList so the user writes
|
|
271
|
+
// idiomatic JS. Reads delegate to a fresh snapshot per access so a sequence
|
|
272
|
+
// of reads inside the mutator sees each write.
|
|
273
|
+
function wrapLoroListProxy(loroList) {
|
|
274
|
+
const handler = {
|
|
275
|
+
get(_t, key) {
|
|
276
|
+
if (key === 'length')
|
|
277
|
+
return loroList.length;
|
|
278
|
+
if (typeof key === 'symbol') {
|
|
279
|
+
// `toJSON` (deep-plain), not `toArray` (shallow), so iterating the
|
|
280
|
+
// draft (`for (const c of l)`, `[...l]`) yields plain objects an app
|
|
281
|
+
// can read fields off, matching the array-method snapshot below.
|
|
282
|
+
return loroList.toJSON()[key];
|
|
283
|
+
}
|
|
284
|
+
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
|
285
|
+
const idx = Number(key);
|
|
286
|
+
const v = loroList.get(idx);
|
|
287
|
+
if (isContainer(v)) {
|
|
288
|
+
// Engine-neutral view only (see the map proxy's get trap).
|
|
289
|
+
if (v instanceof LoroMap)
|
|
290
|
+
return wrapLoroMapProxy(v);
|
|
291
|
+
if (v instanceof LoroList)
|
|
292
|
+
return v.toJSON();
|
|
293
|
+
return v.toJSON();
|
|
294
|
+
}
|
|
295
|
+
return v;
|
|
296
|
+
}
|
|
297
|
+
// `push` is the hot path (the ordered-map primitive appends every new
|
|
298
|
+
// key through it; apps bulk-build lists with it), so append natively.
|
|
299
|
+
// The toArray + replay below is O(list) PER call, turning N appends
|
|
300
|
+
// into O(N^2) and faulting Loro at a few thousand keys; a tail insert
|
|
301
|
+
// is O(1).
|
|
302
|
+
if (key === 'push') {
|
|
303
|
+
return (...items) => {
|
|
304
|
+
for (const item of items)
|
|
305
|
+
insertListTail(loroList, item);
|
|
306
|
+
return loroList.length;
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Remaining array methods: bind to a deep-plain `toJSON` snapshot so a
|
|
310
|
+
// callback reading an element's fields (`findIndex((c) => c.id === x)`,
|
|
311
|
+
// `find`, `filter`, `map`) sees plain objects, not the raw child
|
|
312
|
+
// containers `toArray` hands back (whose fields read undefined). A
|
|
313
|
+
// mutating method replays its result into the LoroList; a pure read
|
|
314
|
+
// does not, or a `find` inside a mutate would rewrite the whole list.
|
|
315
|
+
// Best-effort for v1; efficient mutation uses indexed assignment /
|
|
316
|
+
// length-set / `push`.
|
|
317
|
+
const arr = loroList.toJSON();
|
|
318
|
+
const val = arr[key];
|
|
319
|
+
if (typeof val === 'function') {
|
|
320
|
+
return (...args) => {
|
|
321
|
+
const fn = val;
|
|
322
|
+
const result = fn.apply(arr, args);
|
|
323
|
+
if (MUTATING_ARRAY_METHODS.has(key))
|
|
324
|
+
assignList(loroList, arr);
|
|
325
|
+
return result;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return val;
|
|
329
|
+
},
|
|
330
|
+
set(_t, key, value) {
|
|
331
|
+
if (key === 'length') {
|
|
332
|
+
const newLen = Number(value);
|
|
333
|
+
const oldLen = loroList.length;
|
|
334
|
+
if (newLen < oldLen)
|
|
335
|
+
loroList.delete(newLen, oldLen - newLen);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
|
339
|
+
const idx = Number(key);
|
|
340
|
+
const oldLen = loroList.length;
|
|
341
|
+
if (idx < oldLen) {
|
|
342
|
+
loroList.delete(idx, 1);
|
|
343
|
+
}
|
|
344
|
+
if (isPlainObject(value)) {
|
|
345
|
+
const child = loroList.insertContainer(idx, new LoroMap());
|
|
346
|
+
assignPlain(child, value);
|
|
347
|
+
}
|
|
348
|
+
else if (Array.isArray(value)) {
|
|
349
|
+
const child = loroList.insertContainer(idx, new LoroList());
|
|
350
|
+
assignList(child, value);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
loroList.insert(idx, value);
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
return new Proxy([], handler);
|
|
361
|
+
}
|
|
362
|
+
const TAG_FIELD = 'type';
|
|
363
|
+
const BODY_FIELD = 'params';
|
|
364
|
+
// A page entry on the live LoroMap is a CONTAINER, not a plain
|
|
365
|
+
// `{ type, params }`, so it must be materialised before the page codec
|
|
366
|
+
// (which expects plain values) reads its tag / body.
|
|
367
|
+
function pageValue(m, key) {
|
|
368
|
+
const v = m.get(key);
|
|
369
|
+
if (v instanceof LoroMap)
|
|
370
|
+
return v.toJSON();
|
|
371
|
+
return v;
|
|
372
|
+
}
|
|
373
|
+
// Decode a live paged `instances` map to the flat `key -> { type, params }`
|
|
374
|
+
// lookup. `toJSON` materialises the page containers to the plain shape the
|
|
375
|
+
// shared `flattenPagedLookup` consumes, so page flattening lives in one place.
|
|
376
|
+
// Only ever called on the handle's paged path (guarded by `_isPaged()` /
|
|
377
|
+
// `_pageCount`), so the store is known paged. Pass `paged: true` rather than
|
|
378
|
+
// 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: hash the key
|
|
383
|
+
// to its page, decode only that page, find the triple. Keeps a per-key `get`
|
|
384
|
+
// proportional to one page, not the whole file.
|
|
385
|
+
function recordFromPage(m, recordKey, pageCount) {
|
|
386
|
+
const page = pageValue(m, pageKeyFor(recordKey, pageCount));
|
|
387
|
+
if (peekRecordTag(page) !== PAGE_TAG)
|
|
388
|
+
return undefined;
|
|
389
|
+
for (const packed of decodePageBody(decodeRecordValue(page)?.body)) {
|
|
390
|
+
if (Array.isArray(packed) && packed[0] === recordKey) {
|
|
391
|
+
return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
// ---- Change deltas (`{ delta: true }` map subscribers) ----------------
|
|
397
|
+
//
|
|
398
|
+
// A delta subscriber reports which keys changed since the previous fire so a
|
|
399
|
+
// render loop patches O(changed) rather than re-reading the container.
|
|
400
|
+
//
|
|
401
|
+
// Unpaged: read it off Loro's diff. A value reassign / add / delete shows on
|
|
402
|
+
// the map's own diff (`target === m.id`); a field edit fires on the record's
|
|
403
|
+
// nested container, so the record's key is the first path segment under root.
|
|
404
|
+
//
|
|
405
|
+
// Paged: a record edit rewrites the whole page container, so the diff names
|
|
406
|
+
// the changed PAGE. Recover record granularity with a per-subscription
|
|
407
|
+
// page -> keys index, diffing a changed page's current keys against it; a
|
|
408
|
+
// deleted record was in the prior key set and absent from the rewritten page.
|
|
409
|
+
// The root key of a root container (`cid:root-<key>:Map`), else null. A field
|
|
410
|
+
// edit is keyed by its first path segment under root, which only a root
|
|
411
|
+
// record map can resolve; nested handles fall back to direct keys only.
|
|
412
|
+
function rootKeyOf(m) {
|
|
413
|
+
const match = /^cid:root-(.+):Map$/.exec(m.id);
|
|
414
|
+
return match?.[1] ?? null;
|
|
415
|
+
}
|
|
416
|
+
// The record keys currently packed into one page, or null for a missing /
|
|
417
|
+
// non-page entry. Seeds and advances the page index for delete detection.
|
|
418
|
+
function pageRecordKeys(m, pageKey) {
|
|
419
|
+
const records = pageRecords(m, pageKey);
|
|
420
|
+
return records ? new Set(records.keys()) : null;
|
|
421
|
+
}
|
|
422
|
+
// Decode one page to its `key -> { type, params }` records in a single pass,
|
|
423
|
+
// so a paged delta gets the changed records' VALUES from the same decode it
|
|
424
|
+
// uses to find the changed keys (a second per-key decode would make a large
|
|
425
|
+
// initial build O(records x page-size)).
|
|
426
|
+
function pageRecords(m, pageKey) {
|
|
427
|
+
const page = pageValue(m, pageKey);
|
|
428
|
+
if (peekRecordTag(page) !== PAGE_TAG)
|
|
429
|
+
return null;
|
|
430
|
+
const records = new Map();
|
|
431
|
+
for (const packed of decodePageBody(decodeRecordValue(page)?.body)) {
|
|
432
|
+
if (Array.isArray(packed) && typeof packed[0] === 'string') {
|
|
433
|
+
records.set(packed[0], { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return records;
|
|
437
|
+
}
|
|
438
|
+
// Materialise one unpaged map entry to a plain value (containers via
|
|
439
|
+
// `toJSON`, scalars passthrough), so a delta carries the changed value
|
|
440
|
+
// without a re-read.
|
|
441
|
+
function entryValue(m, key) {
|
|
442
|
+
const v = m.get(key);
|
|
443
|
+
if (v instanceof LoroMap || v instanceof LoroList)
|
|
444
|
+
return v.toJSON();
|
|
445
|
+
return v;
|
|
446
|
+
}
|
|
447
|
+
function buildPageIndex(m) {
|
|
448
|
+
const index = new Map();
|
|
449
|
+
for (const pageKey of m.keys()) {
|
|
450
|
+
const keys = pageRecordKeys(m, pageKey);
|
|
451
|
+
if (keys)
|
|
452
|
+
index.set(pageKey, keys);
|
|
453
|
+
}
|
|
454
|
+
return index;
|
|
455
|
+
}
|
|
456
|
+
function unpagedMapDelta(m, rootKey, batch) {
|
|
457
|
+
const setKeys = new Set();
|
|
458
|
+
const deleted = new Set();
|
|
459
|
+
for (const ev of batch.events) {
|
|
460
|
+
if (ev.target === m.id) {
|
|
461
|
+
if (ev.diff.type !== 'map')
|
|
462
|
+
continue;
|
|
463
|
+
for (const [key, value] of Object.entries(ev.diff.updated)) {
|
|
464
|
+
if (value === undefined)
|
|
465
|
+
deleted.add(key);
|
|
466
|
+
else
|
|
467
|
+
setKeys.add(key);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (rootKey !== null) {
|
|
471
|
+
// A descendant event (a field edit on a record's nested container).
|
|
472
|
+
// Every batch event is within the root map's subtree, so the record's
|
|
473
|
+
// key is the path segment directly under root.
|
|
474
|
+
const key = ev.path[1];
|
|
475
|
+
if (typeof key === 'string')
|
|
476
|
+
setKeys.add(key);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const key of deleted)
|
|
480
|
+
setKeys.delete(key);
|
|
481
|
+
const set = new Map();
|
|
482
|
+
for (const key of setKeys)
|
|
483
|
+
set.set(key, entryValue(m, key));
|
|
484
|
+
return { set, deleted: [...deleted] };
|
|
485
|
+
}
|
|
486
|
+
function pagedMapDelta(m, batch, pageIndex) {
|
|
487
|
+
const set = new Map();
|
|
488
|
+
const deleted = new Set();
|
|
489
|
+
for (const ev of batch.events) {
|
|
490
|
+
if (ev.target !== m.id || ev.diff.type !== 'map')
|
|
491
|
+
continue;
|
|
492
|
+
for (const pageKey of Object.keys(ev.diff.updated)) {
|
|
493
|
+
const before = pageIndex.get(pageKey);
|
|
494
|
+
const after = pageRecords(m, pageKey);
|
|
495
|
+
if (after) {
|
|
496
|
+
for (const [key, value] of after)
|
|
497
|
+
set.set(key, value);
|
|
498
|
+
pageIndex.set(pageKey, new Set(after.keys()));
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
pageIndex.delete(pageKey);
|
|
502
|
+
}
|
|
503
|
+
if (before) {
|
|
504
|
+
for (const key of before) {
|
|
505
|
+
if (!after || !after.has(key))
|
|
506
|
+
deleted.add(key);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const key of deleted)
|
|
512
|
+
set.delete(key);
|
|
513
|
+
return { set, deleted: [...deleted] };
|
|
514
|
+
}
|
|
515
|
+
// Run a flat-draft `mutate` against a PAGED store. The draft proxies an
|
|
516
|
+
// OVERLAY of in-batch changes (key -> record, or a tombstone for a delete);
|
|
517
|
+
// reads consult the overlay first then the stored page, so an edit is
|
|
518
|
+
// visible to later reads in the same `fn` as on an unpaged live proxy.
|
|
519
|
+
// Afterwards only the touched pages are decoded, rewritten, and re-encoded,
|
|
520
|
+
// so a single field edit rewrites a single page. A `pruneRefs` sweep that
|
|
521
|
+
// iterates the whole draft is the only path that reads every page, the rare
|
|
522
|
+
// structural-delete case already treated as O(file).
|
|
523
|
+
function mutatePaged(m, pageCount, fn) {
|
|
524
|
+
// A paged store always carries a positive `meta.page_count`, so a
|
|
525
|
+
// non-positive count means the store is corrupt: the page layout can't be
|
|
526
|
+
// resolved and guessing would scramble which record lives where. Refuse
|
|
527
|
+
// rather than corrupt.
|
|
528
|
+
if (pageCount <= 0) {
|
|
529
|
+
throw new Error('Cannot edit a paged record store with no page_count.');
|
|
530
|
+
}
|
|
531
|
+
const TOMBSTONE = Symbol('deleted');
|
|
532
|
+
// In-batch changes: key -> new record, or TOMBSTONE when deleted. Absent
|
|
533
|
+
// means "unchanged, read from the page".
|
|
534
|
+
const overlay = new Map();
|
|
535
|
+
// The record a draft read should see: the overlay value if touched this
|
|
536
|
+
// batch, else the stored record (one page decode).
|
|
537
|
+
const currentRecord = (key) => {
|
|
538
|
+
if (overlay.has(key)) {
|
|
539
|
+
const v = overlay.get(key);
|
|
540
|
+
return v === TOMBSTONE ? undefined : v;
|
|
541
|
+
}
|
|
542
|
+
return recordFromPage(m, key, pageCount);
|
|
543
|
+
};
|
|
544
|
+
// Per-record sub-proxy: a field assignment writes through to the overlay
|
|
545
|
+
// record so a later read sees it and the page rewrite picks it up. Field
|
|
546
|
+
// reads return the deep-plain stored value, so spreading `{ ...rec.params }`
|
|
547
|
+
// never leaks a live handle.
|
|
548
|
+
const recordProxy = (key) => {
|
|
549
|
+
return new Proxy({}, {
|
|
550
|
+
get(_t, field) {
|
|
551
|
+
if (typeof field === 'symbol')
|
|
552
|
+
return undefined;
|
|
553
|
+
const rec = currentRecord(key);
|
|
554
|
+
return rec ? rec[field] : undefined;
|
|
555
|
+
},
|
|
556
|
+
set(_t, field, value) {
|
|
557
|
+
if (typeof field === 'symbol')
|
|
558
|
+
return false;
|
|
559
|
+
const base = { ...currentRecord(key) };
|
|
560
|
+
base[field] = toPlainDeep(value);
|
|
561
|
+
overlay.set(key, base);
|
|
562
|
+
return true;
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
};
|
|
566
|
+
const draft = new Proxy({}, {
|
|
567
|
+
get(_t, key) {
|
|
568
|
+
if (typeof key === 'symbol')
|
|
569
|
+
return undefined;
|
|
570
|
+
if (currentRecord(key) === undefined)
|
|
571
|
+
return undefined;
|
|
572
|
+
return recordProxy(key);
|
|
573
|
+
},
|
|
574
|
+
set(_t, key, value) {
|
|
575
|
+
if (typeof key === 'symbol')
|
|
576
|
+
return false;
|
|
577
|
+
overlay.set(key, toPlainDeep(value));
|
|
578
|
+
return true;
|
|
579
|
+
},
|
|
580
|
+
deleteProperty(_t, key) {
|
|
581
|
+
if (typeof key === 'symbol')
|
|
582
|
+
return false;
|
|
583
|
+
overlay.set(key, TOMBSTONE);
|
|
584
|
+
return true;
|
|
585
|
+
},
|
|
586
|
+
has(_t, key) {
|
|
587
|
+
if (typeof key === 'symbol')
|
|
588
|
+
return false;
|
|
589
|
+
return currentRecord(key) !== undefined;
|
|
590
|
+
},
|
|
591
|
+
ownKeys() {
|
|
592
|
+
// Stored keys plus added, minus deleted.
|
|
593
|
+
const keys = new Set(Object.keys(flattenPagedMap(m)));
|
|
594
|
+
for (const [k, v] of overlay) {
|
|
595
|
+
if (v === TOMBSTONE)
|
|
596
|
+
keys.delete(k);
|
|
597
|
+
else
|
|
598
|
+
keys.add(k);
|
|
599
|
+
}
|
|
600
|
+
return [...keys];
|
|
601
|
+
},
|
|
602
|
+
getOwnPropertyDescriptor(_t, key) {
|
|
603
|
+
if (typeof key === 'symbol')
|
|
604
|
+
return undefined;
|
|
605
|
+
if (currentRecord(key) === undefined)
|
|
606
|
+
return undefined;
|
|
607
|
+
return { enumerable: true, configurable: true, writable: true };
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
fn(draft);
|
|
611
|
+
if (overlay.size === 0)
|
|
612
|
+
return;
|
|
613
|
+
applyOverlay(m, pageCount, overlay, TOMBSTONE);
|
|
614
|
+
}
|
|
615
|
+
// Rewrite the pages the overlay touched: each is decoded once, untouched
|
|
616
|
+
// records carried over, changed records re-stated (or dropped on a
|
|
617
|
+
// tombstone), then re-encoded (or deleted if it ends empty).
|
|
618
|
+
function applyOverlay(m, pageCount, overlay, tombstone) {
|
|
619
|
+
const touchedPages = new Set();
|
|
620
|
+
for (const key of overlay.keys()) {
|
|
621
|
+
touchedPages.add(pageKeyFor(key, pageCount));
|
|
622
|
+
}
|
|
623
|
+
for (const pageKey of touchedPages) {
|
|
624
|
+
const existing = pageValue(m, pageKey);
|
|
625
|
+
const triples = [];
|
|
626
|
+
// Carry over the page's untouched records.
|
|
627
|
+
if (peekRecordTag(existing) === PAGE_TAG) {
|
|
628
|
+
for (const packed of decodePageBody(decodeRecordValue(existing)?.body)) {
|
|
629
|
+
if (Array.isArray(packed) &&
|
|
630
|
+
typeof packed[0] === 'string' &&
|
|
631
|
+
!overlay.has(packed[0])) {
|
|
632
|
+
triples.push(packed);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Add the overlay's survivors that hash to this page.
|
|
637
|
+
for (const [key, rec] of overlay) {
|
|
638
|
+
if (rec === tombstone)
|
|
639
|
+
continue;
|
|
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;
|
|
649
|
+
}
|
|
650
|
+
const page = m.setContainer(pageKey, new LoroMap());
|
|
651
|
+
page.set(TAG_FIELD, PAGE_TAG);
|
|
652
|
+
page.set(BODY_FIELD, encodePageBody(triples));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// A root anchor holds one container that a version checkout swaps in place
|
|
656
|
+
// (`rebind`). Between rebinds `resolve()` returns the same instance, so the
|
|
657
|
+
// per-container Loro subscription attaches to a stable identity. `LoroMap` and
|
|
658
|
+
// `LoroList` roots share this; the kind is the type parameter.
|
|
659
|
+
function rootAnchor(root) {
|
|
660
|
+
let current = root;
|
|
661
|
+
return {
|
|
662
|
+
resolve: () => current,
|
|
663
|
+
resolveOrCreate: () => current,
|
|
664
|
+
invalidate: () => { },
|
|
665
|
+
rebind: (next) => {
|
|
666
|
+
current = next;
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
// A child anchor resolves `parent[key]` lazily, then caches the container so
|
|
671
|
+
// per-container subscribers attach to a stable instance. `ctor` selects the
|
|
672
|
+
// container kind: a value of the wrong kind at the key reads as absent, and
|
|
673
|
+
// `resolveOrCreate` mints one only inside a mutate context.
|
|
674
|
+
function childAnchor(parent, key, ctor) {
|
|
675
|
+
// `invalidate` clears the cache on a source swap so the next resolve
|
|
676
|
+
// re-derives through the (rebound) parent.
|
|
677
|
+
let cached = null;
|
|
678
|
+
// A cached container is stale once it has been detached from the document
|
|
679
|
+
// (deleted from `parent[key]`), which happens on a delete-then-recreate of
|
|
680
|
+
// the key: the recreate mints a NEW container, so a cache holding the old
|
|
681
|
+
// one would read and subscribe against a dead container. `isDeleted()` is
|
|
682
|
+
// the cheap per-resolve check that keeps the cache without going stale;
|
|
683
|
+
// `invalidate()` still handles the source swap (checkout / resync).
|
|
684
|
+
const live = (c) => c !== null && !c.isDeleted();
|
|
685
|
+
return {
|
|
686
|
+
resolve: () => {
|
|
687
|
+
if (live(cached))
|
|
688
|
+
return cached;
|
|
689
|
+
cached = null;
|
|
690
|
+
const p = parent.resolve();
|
|
691
|
+
if (!p)
|
|
692
|
+
return null;
|
|
693
|
+
const v = p.get(key);
|
|
694
|
+
if (v instanceof ctor) {
|
|
695
|
+
cached = v;
|
|
696
|
+
return cached;
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
},
|
|
700
|
+
resolveOrCreate: () => {
|
|
701
|
+
if (live(cached))
|
|
702
|
+
return cached;
|
|
703
|
+
cached = null;
|
|
704
|
+
const p = parent.resolveOrCreate();
|
|
705
|
+
const v = p.get(key);
|
|
706
|
+
if (v instanceof ctor) {
|
|
707
|
+
cached = v;
|
|
708
|
+
return cached;
|
|
709
|
+
}
|
|
710
|
+
const created = p.setContainer(key, new ctor());
|
|
711
|
+
cached = created;
|
|
712
|
+
return created;
|
|
713
|
+
},
|
|
714
|
+
invalidate: () => {
|
|
715
|
+
cached = null;
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
// Shared per-container subscription bookkeeping for both handle kinds. Holds
|
|
720
|
+
// the active { callback, detach } records so a version checkout can re-fire
|
|
721
|
+
// and re-attach each against the swapped source, and a suspend / close can
|
|
722
|
+
// detach them all. Each kind passes in its own whole-container fire and its
|
|
723
|
+
// per-container change-attach; this owns only the record set and its
|
|
724
|
+
// lifecycle. (The `createEmitter` primitive is a value-fanout with retain /
|
|
725
|
+
// dedup, a different shape: it models no per-listener external resource, and
|
|
726
|
+
// the Loro subscription each record must re-create on rebind is exactly
|
|
727
|
+
// that.)
|
|
728
|
+
class SubscriptionRegistry {
|
|
729
|
+
_subs = new Set();
|
|
730
|
+
// Fire the whole-container catch-up once, attach the per-container change
|
|
731
|
+
// subscription, track the record, and return an idempotent unsubscribe.
|
|
732
|
+
subscribe(cb, fireWhole, attach) {
|
|
733
|
+
fireWhole(cb);
|
|
734
|
+
const rec = { cb, detach: attach(cb) };
|
|
735
|
+
this._subs.add(rec);
|
|
736
|
+
return () => {
|
|
737
|
+
rec.detach();
|
|
738
|
+
this._subs.delete(rec);
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
// Re-fire every active subscriber with the whole current container and
|
|
742
|
+
// re-attach its change subscription to it (checkout / return-to-head).
|
|
743
|
+
resync(fireWhole, attach) {
|
|
744
|
+
for (const rec of this._subs) {
|
|
745
|
+
rec.detach();
|
|
746
|
+
fireWhole(rec.cb);
|
|
747
|
+
rec.detach = attach(rec.cb);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// Detach every active subscription and forget it (suspend / close).
|
|
751
|
+
teardown() {
|
|
752
|
+
for (const rec of this._subs)
|
|
753
|
+
rec.detach();
|
|
754
|
+
this._subs.clear();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
class MapHandleImpl {
|
|
758
|
+
_anchor;
|
|
759
|
+
_owner;
|
|
760
|
+
// The store's page count (0 when unpaged). `null` for handles that can
|
|
761
|
+
// never be a record store's `instances` root (composed child handles, and
|
|
762
|
+
// doc-less roots), which skip the paged paths entirely.
|
|
763
|
+
_pageCount;
|
|
764
|
+
// The paging scheme of the record store this root maps, read the same way
|
|
765
|
+
// and at the same time as `_pageCount` (both off the active source's `meta`).
|
|
766
|
+
// `null` for any handle that is never a record store's `instances` root.
|
|
767
|
+
_pagingScheme;
|
|
768
|
+
_childMaps = new Map();
|
|
769
|
+
_childLists = new Map();
|
|
770
|
+
// Active subscriptions, tracked so a version checkout (which swaps the
|
|
771
|
+
// active source doc) can re-fire and re-attach each one against the new
|
|
772
|
+
// container.
|
|
773
|
+
_registry = new SubscriptionRegistry();
|
|
774
|
+
// Keys captured just before a rebind (`_rebindAnchor`), consumed by the
|
|
775
|
+
// matching `_resync` to report the keys the source swap removed. Null
|
|
776
|
+
// outside a rebind cycle, so a bare `_resync` reports no removals.
|
|
777
|
+
_priorKeys = null;
|
|
778
|
+
constructor(anchor, owner, pageCount = null, pagingScheme = null) {
|
|
779
|
+
this._anchor = anchor;
|
|
780
|
+
this._owner = owner;
|
|
781
|
+
this._pageCount = pageCount;
|
|
782
|
+
this._pagingScheme = pagingScheme;
|
|
783
|
+
}
|
|
784
|
+
// Whether the map is a PAGED record store. Defined by `meta.page_count`
|
|
785
|
+
// (set whenever the writer pages, never changed by a field edit), threaded
|
|
786
|
+
// in as `_pageCount` and read in O(1). MUST stay O(1): it is on the hot
|
|
787
|
+
// path of every `read` / `mutate` (twice per ordered-map edit), so scanning
|
|
788
|
+
// the map to peek tags would make a single field edit O(file).
|
|
789
|
+
_isPaged() {
|
|
790
|
+
return this._pageCount !== null && this._pageCount() > 0;
|
|
791
|
+
}
|
|
792
|
+
// Whether this build's page-key hash matches the scheme the store was
|
|
793
|
+
// written under. A per-key `get` hashes the key to its page (`pageKeyFor`),
|
|
794
|
+
// so a store written under a NEWER scheme (one that changed the hash) would
|
|
795
|
+
// be looked up in the wrong page and read back as absent. When this is
|
|
796
|
+
// false, the per-key read paths fall back to the hash-independent whole-store
|
|
797
|
+
// flatten (O(file), only on this rare newer-scheme read) so `get` / `has`
|
|
798
|
+
// agree with `read()` instead of silently reporting present records missing.
|
|
799
|
+
// The edit path refuses such a store loudly (`_runMutate`); reads stay
|
|
800
|
+
// best-effort. Absent / current-or-older scheme is a match.
|
|
801
|
+
_pageHashMatchesStore() {
|
|
802
|
+
return !this._pagingScheme || isPagingSchemeEditable(this._pagingScheme());
|
|
803
|
+
}
|
|
804
|
+
read() {
|
|
805
|
+
const m = this._anchor.resolve();
|
|
806
|
+
if (!m)
|
|
807
|
+
return {};
|
|
808
|
+
// A paged map flattens back to the flat lookup, so a read is identical
|
|
809
|
+
// paged or not.
|
|
810
|
+
if (this._isPaged())
|
|
811
|
+
return flattenPagedMap(m);
|
|
812
|
+
return m.toJSON();
|
|
813
|
+
}
|
|
814
|
+
get(entryKey) {
|
|
815
|
+
const m = this._anchor.resolve();
|
|
816
|
+
if (!m)
|
|
817
|
+
return undefined;
|
|
818
|
+
if (this._isPaged()) {
|
|
819
|
+
// A newer-scheme store hashes keys differently, so a per-page lookup
|
|
820
|
+
// would miss present records; flatten instead (see `_pageHashMatchesStore`).
|
|
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());
|
|
827
|
+
}
|
|
828
|
+
const v = m.get(entryKey);
|
|
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;
|
|
836
|
+
}
|
|
837
|
+
keys() {
|
|
838
|
+
const m = this._anchor.resolve();
|
|
839
|
+
if (!m)
|
|
840
|
+
return [];
|
|
841
|
+
if (this._isPaged())
|
|
842
|
+
return Object.keys(flattenPagedMap(m));
|
|
843
|
+
return m.keys();
|
|
844
|
+
}
|
|
845
|
+
has(entryKey) {
|
|
846
|
+
const m = this._anchor.resolve();
|
|
847
|
+
if (!m)
|
|
848
|
+
return false;
|
|
849
|
+
if (this._isPaged())
|
|
850
|
+
return this.get(entryKey) !== undefined;
|
|
851
|
+
return m.get(entryKey) !== undefined;
|
|
852
|
+
}
|
|
853
|
+
mutate(fn) {
|
|
854
|
+
this._owner.assertWritable();
|
|
855
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
856
|
+
// Commits as it goes past a size budget; see `mutate-batching.ts`.
|
|
857
|
+
this._runMutate(fn, new MutateBatcher(() => {
|
|
858
|
+
this._owner.commitMutate();
|
|
859
|
+
}));
|
|
860
|
+
this._owner.commitMutate();
|
|
861
|
+
}
|
|
862
|
+
// Run a mutate WITHOUT the commit pipeline (the caller owns
|
|
863
|
+
// assertWritable / ensureLocalUpdatesSubscribed / commit). The seam the
|
|
864
|
+
// ordered-map primitive drives so a map edit and its sibling order-list
|
|
865
|
+
// edit land in ONE commit (one undo step).
|
|
866
|
+
_runMutate(fn, batcher) {
|
|
867
|
+
const m = this._anchor.resolveOrCreate();
|
|
868
|
+
if (this._isPaged()) {
|
|
869
|
+
// A store written under a paging scheme this build does not implement
|
|
870
|
+
// would be mis-hashed by `pageKeyFor`, so an in-place edit would write a
|
|
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);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const proxy = wrapLoroMapProxy(m, batcher);
|
|
880
|
+
fn(proxy);
|
|
881
|
+
}
|
|
882
|
+
// Paged writes defer to `applyOverlay` at the end of the mutation, so an
|
|
883
|
+
// intermediate commit there would seal no records (and, on an ordered
|
|
884
|
+
// map, order entries for records not yet written). Their ops are bounded
|
|
885
|
+
// by page size anyway.
|
|
886
|
+
_canBatchMutate() {
|
|
887
|
+
return !this._isPaged();
|
|
888
|
+
}
|
|
889
|
+
// A predicate over the map's CURRENT stored key set, resolved ONCE so a
|
|
890
|
+
// caller checking many keys doesn't re-scan per key. The ordered-map
|
|
891
|
+
// primitive uses it to decide, per set, whether a key is new (and so must
|
|
892
|
+
// be appended to the order list) without paying O(map) per added key.
|
|
893
|
+
_existsChecker() {
|
|
894
|
+
const m = this._anchor.resolve();
|
|
895
|
+
if (!m)
|
|
896
|
+
return () => false;
|
|
897
|
+
if (this._isPaged()) {
|
|
898
|
+
// Newer-scheme store: the per-page hash would miss records, so resolve
|
|
899
|
+
// the whole flattened key set once (see `_pageHashMatchesStore`).
|
|
900
|
+
if (!this._pageHashMatchesStore()) {
|
|
901
|
+
const flat = flattenPagedMap(m);
|
|
902
|
+
return (key) => Object.prototype.hasOwnProperty.call(flat, key);
|
|
903
|
+
}
|
|
904
|
+
const pageCount = this._pageCount();
|
|
905
|
+
return (key) => recordFromPage(m, key, pageCount) !== undefined;
|
|
906
|
+
}
|
|
907
|
+
return (key) => m.get(key) !== undefined;
|
|
908
|
+
}
|
|
909
|
+
// Fire a subscriber once with the whole map as `change.set`, so a
|
|
910
|
+
// subscriber is ONE loop with no initial-vs-update branch and no
|
|
911
|
+
// `if (value)` guard (the footgun that left a view stale after an undo /
|
|
912
|
+
// remote delete emptied the container). Flagged `resync: true` and carries
|
|
913
|
+
// no `origin`: this is a whole-container catch-up (registration, or a
|
|
914
|
+
// checkout / return swapping the source), not an authored transition.
|
|
915
|
+
//
|
|
916
|
+
// `deleted` is empty for a registration fire (the subscriber holds no prior
|
|
917
|
+
// state). On a resync it carries the keys that were present before the
|
|
918
|
+
// source swap but are gone after it: a checkout to a version with fewer
|
|
919
|
+
// records must tell a delta-reconcile subscriber to drop those keys, or they
|
|
920
|
+
// linger as phantom records in the preview. `_resync` computes it.
|
|
921
|
+
_fireWhole(cb, deleted = []) {
|
|
922
|
+
try {
|
|
923
|
+
cb({ set: new Map(Object.entries(this.read())), deleted }, undefined, true);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
// A throwing subscriber must not break registration / resync.
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// Attach the per-container change subscription for `cb` to the CURRENT
|
|
930
|
+
// resolved container, returning its detach. The container instance is
|
|
931
|
+
// captured here (Loro's subscribe is per-container), so a source swap
|
|
932
|
+
// re-runs this against the new container.
|
|
933
|
+
_attachChange(cb) {
|
|
934
|
+
const m = this._anchor.resolve();
|
|
935
|
+
// If the container doesn't exist yet (a composed handle for a missing
|
|
936
|
+
// entry), only the whole-fire happens; the change subscription attaches
|
|
937
|
+
// once a later `doc.map(key)` / `mutate` materializes it. Root handles
|
|
938
|
+
// are always materialized (`getMap` is get-or-create).
|
|
939
|
+
if (!m)
|
|
940
|
+
return () => { };
|
|
941
|
+
// Paged delta needs prior page state to spot deleted records, so index
|
|
942
|
+
// the pages once now; the index advances on every fire or its next delete
|
|
943
|
+
// diff is wrong. Unpaged maps read their delta off the Loro diff.
|
|
944
|
+
const pageIndex = this._isPaged() ? buildPageIndex(m) : null;
|
|
945
|
+
const rootKey = rootKeyOf(m);
|
|
946
|
+
const sub = m.subscribe((event) => {
|
|
947
|
+
const change = (pageIndex
|
|
948
|
+
? pagedMapDelta(m, event, pageIndex)
|
|
949
|
+
: unpagedMapDelta(m, rootKey, event));
|
|
950
|
+
const origin = { source: this._owner.originOf(event) };
|
|
951
|
+
try {
|
|
952
|
+
cb(change, origin, false);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// A throwing subscriber must not affect the others.
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
return () => sub();
|
|
959
|
+
}
|
|
960
|
+
subscribe(cb) {
|
|
961
|
+
// App subscribers see `(change, origin?)`; the internal re-sync flag is
|
|
962
|
+
// dropped here so it never leaks into the public surface.
|
|
963
|
+
return this._subscribeInternal((change, origin) => cb(change, origin));
|
|
964
|
+
}
|
|
965
|
+
// Internal: like `subscribe`, but the callback also receives whether the
|
|
966
|
+
// fire is a whole-container re-sync. Only the ordered-map wrapper uses it,
|
|
967
|
+
// to re-check `instance_order` on a source swap without inspecting `origin`.
|
|
968
|
+
_subscribeInternal(cb) {
|
|
969
|
+
return this._registry.subscribe(cb, (c) => this._fireWhole(c), (c) => this._attachChange(c));
|
|
970
|
+
}
|
|
971
|
+
// Re-point this ROOT handle to `next` (a container in the newly-active
|
|
972
|
+
// source doc) WITHOUT firing yet. Called by the Document on a version
|
|
973
|
+
// checkout / return-to-head. Snapshots the subtree's current keys first,
|
|
974
|
+
// before the anchor moves, so the subsequent `_resync` can report which keys
|
|
975
|
+
// the swap removed. Rebind and resync are two phases (see
|
|
976
|
+
// `_rebindRootHandles`) because the ordered map reads its sibling order list
|
|
977
|
+
// while firing its map: both anchors must already point at the new source
|
|
978
|
+
// when either fires, or the map redraw reads a half-swapped order list.
|
|
979
|
+
_rebindAnchor(next) {
|
|
980
|
+
this._snapshotPriorKeys();
|
|
981
|
+
this._anchor.rebind(next);
|
|
982
|
+
}
|
|
983
|
+
// Capture the current key set of this handle and its composed child maps,
|
|
984
|
+
// read against the CURRENT (pre-rebind) source, so `_resync` can diff it
|
|
985
|
+
// against the post-rebind source to find removed keys. Runs before the
|
|
986
|
+
// anchor moves; child maps resolve through this anchor, so they must be read
|
|
987
|
+
// now too.
|
|
988
|
+
_snapshotPriorKeys() {
|
|
989
|
+
this._priorKeys = new Set(Object.keys(this.read()));
|
|
990
|
+
for (const child of this._childMaps.values())
|
|
991
|
+
child._snapshotPriorKeys();
|
|
992
|
+
}
|
|
993
|
+
// Re-fire every active subscriber with the whole current container (plus the
|
|
994
|
+
// keys the source swap removed, from the pre-rebind snapshot) and re-attach
|
|
995
|
+
// its change subscription to it, then recurse into composed child handles
|
|
996
|
+
// (their anchors resolve through this one, so they already point at the new
|
|
997
|
+
// source; their subscriptions still need re-attaching). Idempotent and safe
|
|
998
|
+
// when there are no subscribers.
|
|
999
|
+
_resync() {
|
|
1000
|
+
// Re-derive this handle's container from the (already-rebound) parent
|
|
1001
|
+
// before reading or re-attaching: a child anchor caches its resolved
|
|
1002
|
+
// container, and after a source swap that cache points at the old source.
|
|
1003
|
+
// Prior keys were snapshotted pre-rebind, so this cannot lose them.
|
|
1004
|
+
this._anchor.invalidate();
|
|
1005
|
+
const prior = this._priorKeys;
|
|
1006
|
+
this._priorKeys = null;
|
|
1007
|
+
const currentKeys = new Set(Object.keys(this.read()));
|
|
1008
|
+
const removed = prior
|
|
1009
|
+
? [...prior].filter((k) => !currentKeys.has(k))
|
|
1010
|
+
: [];
|
|
1011
|
+
this._registry.resync((c) => this._fireWhole(c, removed), (c) => this._attachChange(c));
|
|
1012
|
+
for (const child of this._childMaps.values())
|
|
1013
|
+
child._resync();
|
|
1014
|
+
for (const child of this._childLists.values())
|
|
1015
|
+
child._resync();
|
|
1016
|
+
}
|
|
1017
|
+
// Detach every active per-container subscription (this handle's and its
|
|
1018
|
+
// composed children's) and forget them. The Document calls this when it
|
|
1019
|
+
// suspends (parks) or closes: a parked doc keeps applying remote ops to its
|
|
1020
|
+
// live containers, so a Loro subscription an app registered before `close()`
|
|
1021
|
+
// would otherwise keep firing the app's callback on a doc it has closed.
|
|
1022
|
+
_teardownSubs() {
|
|
1023
|
+
this._registry.teardown();
|
|
1024
|
+
for (const child of this._childMaps.values())
|
|
1025
|
+
child._teardownSubs();
|
|
1026
|
+
for (const child of this._childLists.values())
|
|
1027
|
+
child._teardownSubs();
|
|
1028
|
+
}
|
|
1029
|
+
map(entryKey) {
|
|
1030
|
+
let h = this._childMaps.get(entryKey);
|
|
1031
|
+
if (!h) {
|
|
1032
|
+
const anchor = childAnchor(this._anchor, entryKey, LoroMap);
|
|
1033
|
+
h = new MapHandleImpl(anchor, this._owner);
|
|
1034
|
+
this._childMaps.set(entryKey, h);
|
|
1035
|
+
}
|
|
1036
|
+
return h;
|
|
1037
|
+
}
|
|
1038
|
+
list(entryKey) {
|
|
1039
|
+
let h = this._childLists.get(entryKey);
|
|
1040
|
+
if (!h) {
|
|
1041
|
+
const anchor = childAnchor(this._anchor, entryKey, LoroList);
|
|
1042
|
+
h = new ListHandleImpl(anchor, this._owner);
|
|
1043
|
+
this._childLists.set(entryKey, h);
|
|
1044
|
+
}
|
|
1045
|
+
return h;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
class ListHandleImpl {
|
|
1049
|
+
_anchor;
|
|
1050
|
+
_owner;
|
|
1051
|
+
// Active subscriptions, tracked so a version checkout can re-fire and
|
|
1052
|
+
// re-attach each one against the new container.
|
|
1053
|
+
_registry = new SubscriptionRegistry();
|
|
1054
|
+
constructor(anchor, owner) {
|
|
1055
|
+
this._anchor = anchor;
|
|
1056
|
+
this._owner = owner;
|
|
1057
|
+
}
|
|
1058
|
+
read() {
|
|
1059
|
+
const l = this._anchor.resolve();
|
|
1060
|
+
if (!l)
|
|
1061
|
+
return [];
|
|
1062
|
+
return l.toJSON();
|
|
1063
|
+
}
|
|
1064
|
+
length() {
|
|
1065
|
+
const l = this._anchor.resolve();
|
|
1066
|
+
return l ? l.length : 0;
|
|
1067
|
+
}
|
|
1068
|
+
mutate(fn) {
|
|
1069
|
+
this._owner.assertWritable();
|
|
1070
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
1071
|
+
this._runMutate(fn);
|
|
1072
|
+
this._owner.commitMutate();
|
|
1073
|
+
}
|
|
1074
|
+
// Run a list mutate without the commit pipeline; see
|
|
1075
|
+
// `MapHandleImpl._runMutate`. The ordered-map primitive drives this to
|
|
1076
|
+
// reconcile the order list in the same commit as the map edit.
|
|
1077
|
+
_runMutate(fn) {
|
|
1078
|
+
const l = this._anchor.resolveOrCreate();
|
|
1079
|
+
const proxy = wrapLoroListProxy(l);
|
|
1080
|
+
fn(proxy);
|
|
1081
|
+
}
|
|
1082
|
+
// Fire once (positional lists have no per-key delta, so a fire is a signal
|
|
1083
|
+
// to re-read). Used for registration and for a checkout / return resync.
|
|
1084
|
+
_fireWhole(cb) {
|
|
1085
|
+
try {
|
|
1086
|
+
cb();
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
// A throwing subscriber must not break registration / resync.
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
// Attach the per-container change subscription for `cb` to the CURRENT
|
|
1093
|
+
// resolved container, returning its detach.
|
|
1094
|
+
_attachChange(cb) {
|
|
1095
|
+
// Same caveat as MapHandleImpl: the change subscription needs the
|
|
1096
|
+
// container to exist. Root lists are materialized by `doc.list(key)`;
|
|
1097
|
+
// nested lists need a prior mutate.
|
|
1098
|
+
const l = this._anchor.resolve();
|
|
1099
|
+
if (!l)
|
|
1100
|
+
return () => { };
|
|
1101
|
+
const sub = l.subscribe(() => this._fireWhole(cb));
|
|
1102
|
+
return () => sub();
|
|
1103
|
+
}
|
|
1104
|
+
subscribe(cb) {
|
|
1105
|
+
return this._registry.subscribe(cb, (c) => this._fireWhole(c), (c) => this._attachChange(c));
|
|
1106
|
+
}
|
|
1107
|
+
// Re-point this ROOT list handle to `next` without firing (see
|
|
1108
|
+
// MapHandleImpl._rebindAnchor). Lists carry no per-key delta, so there is
|
|
1109
|
+
// no prior-key snapshot to take; the resync fire is a re-read signal.
|
|
1110
|
+
_rebindAnchor(next) {
|
|
1111
|
+
this._anchor.rebind(next);
|
|
1112
|
+
}
|
|
1113
|
+
// Re-fire + re-attach every active subscriber against the current
|
|
1114
|
+
// container (see MapHandleImpl._resync). Lists have no composed children,
|
|
1115
|
+
// but a child list handle (`doc.map(k).list(j)`) has a cached anchor that a
|
|
1116
|
+
// source swap leaves pointing at the old source, so invalidate it first.
|
|
1117
|
+
_resync() {
|
|
1118
|
+
this._anchor.invalidate();
|
|
1119
|
+
this._registry.resync((c) => this._fireWhole(c), (c) => this._attachChange(c));
|
|
1120
|
+
}
|
|
1121
|
+
// Detach every active subscription and forget it (see
|
|
1122
|
+
// MapHandleImpl._teardownSubs). Lists have no composed children.
|
|
1123
|
+
_teardownSubs() {
|
|
1124
|
+
this._registry.teardown();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// The ordered map composes a map handle and a list handle and keeps them in
|
|
1128
|
+
// lockstep. It reuses the concrete impls (not the interfaces) to drive their
|
|
1129
|
+
// no-commit `_runMutate` seams and the map's `_existsChecker`, so both halves
|
|
1130
|
+
// of a structural edit commit once. Built over the SAME handles
|
|
1131
|
+
// `doc.map(mapKey)` / `doc.list(orderKey)` return, so subscriptions stay
|
|
1132
|
+
// consistent.
|
|
1133
|
+
class OrderedMapHandleImpl {
|
|
1134
|
+
_map;
|
|
1135
|
+
_order;
|
|
1136
|
+
_owner;
|
|
1137
|
+
constructor(_map, _order, _owner) {
|
|
1138
|
+
this._map = _map;
|
|
1139
|
+
this._order = _order;
|
|
1140
|
+
this._owner = _owner;
|
|
1141
|
+
}
|
|
1142
|
+
read() {
|
|
1143
|
+
return this._map.read();
|
|
1144
|
+
}
|
|
1145
|
+
get(entryKey) {
|
|
1146
|
+
return this._map.get(entryKey);
|
|
1147
|
+
}
|
|
1148
|
+
has(entryKey) {
|
|
1149
|
+
return this._map.has(entryKey);
|
|
1150
|
+
}
|
|
1151
|
+
keys() {
|
|
1152
|
+
// Filter to keys still in the map: the primitive keeps the two in step,
|
|
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));
|
|
1165
|
+
}
|
|
1166
|
+
ordered() {
|
|
1167
|
+
const records = this._map.read();
|
|
1168
|
+
const out = [];
|
|
1169
|
+
for (const key of this._order.read()) {
|
|
1170
|
+
if (Object.prototype.hasOwnProperty.call(records, key)) {
|
|
1171
|
+
out.push({ key, value: records[key] });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
mutate(fn) {
|
|
1177
|
+
this._owner.assertWritable();
|
|
1178
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
1179
|
+
// Net structural effect: keys added (`added` iterates in insertion
|
|
1180
|
+
// order, and a delete-then-re-set re-appends, matching the order list's
|
|
1181
|
+
// append semantics) and removed. A field edit routes through the inner
|
|
1182
|
+
// record sub-proxy and never trips the top-level set / delete traps, so
|
|
1183
|
+
// it leaves the order list alone.
|
|
1184
|
+
const added = new Set();
|
|
1185
|
+
const removed = new Set();
|
|
1186
|
+
// Run at each batch boundary, so a commit carries the map writes AND
|
|
1187
|
+
// the order entries for those same records. Left to the end instead,
|
|
1188
|
+
// the final commit carries every key at once: measured at 229 KB for a
|
|
1189
|
+
// 20,000-record write, an oversized op of its own.
|
|
1190
|
+
const reconcileOrder = () => {
|
|
1191
|
+
if (added.size === 0 && removed.size === 0)
|
|
1192
|
+
return;
|
|
1193
|
+
this._order._runMutate((arr) => {
|
|
1194
|
+
if (removed.size > 0) {
|
|
1195
|
+
const kept = arr.filter((k) => !removed.has(k));
|
|
1196
|
+
arr.length = 0;
|
|
1197
|
+
for (const k of kept)
|
|
1198
|
+
arr.push(k);
|
|
1199
|
+
}
|
|
1200
|
+
for (const k of added)
|
|
1201
|
+
arr.push(k);
|
|
1202
|
+
});
|
|
1203
|
+
added.clear();
|
|
1204
|
+
removed.clear();
|
|
1205
|
+
};
|
|
1206
|
+
const batcher = this._map._canBatchMutate()
|
|
1207
|
+
? new MutateBatcher(() => {
|
|
1208
|
+
reconcileOrder();
|
|
1209
|
+
this._owner.commitMutate();
|
|
1210
|
+
})
|
|
1211
|
+
: null;
|
|
1212
|
+
this._map._runMutate((inner) => {
|
|
1213
|
+
// "is this key new" (and so must be appended to the order list) is O(1)
|
|
1214
|
+
// per set; a repeat set of the same new key is guarded by `added`.
|
|
1215
|
+
// `exists` reflects the key set at the start of the batch for a paged
|
|
1216
|
+
// store (its writes are deferred to `applyOverlay`), but an UNPAGED
|
|
1217
|
+
// proxy writes through to the live map immediately, so a key deleted
|
|
1218
|
+
// earlier in this same batch reads as absent from `exists`. `removed`
|
|
1219
|
+
// records exactly those keys, so "present before the batch" is
|
|
1220
|
+
// `exists(k) || removed.has(k)`; consulting both keeps a delete-then-
|
|
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.
|
|
1223
|
+
const exists = this._map._existsChecker();
|
|
1224
|
+
const tracking = new Proxy({}, {
|
|
1225
|
+
get: (_t, key) => typeof key === 'symbol'
|
|
1226
|
+
? inner[key]
|
|
1227
|
+
: inner[key],
|
|
1228
|
+
set: (_t, key, value) => {
|
|
1229
|
+
if (typeof key === 'symbol')
|
|
1230
|
+
return false;
|
|
1231
|
+
const k = key;
|
|
1232
|
+
if (!added.has(k) && !exists(k) && !removed.has(k)) {
|
|
1233
|
+
added.add(k);
|
|
1234
|
+
}
|
|
1235
|
+
// A re-add after a delete in the same batch cancels the delete.
|
|
1236
|
+
removed.delete(k);
|
|
1237
|
+
inner[k] = value;
|
|
1238
|
+
return true;
|
|
1239
|
+
},
|
|
1240
|
+
deleteProperty: (_t, key) => {
|
|
1241
|
+
if (typeof key === 'symbol')
|
|
1242
|
+
return false;
|
|
1243
|
+
const k = key;
|
|
1244
|
+
if (added.has(k)) {
|
|
1245
|
+
// Added and removed in one batch: it never reached the order
|
|
1246
|
+
// list, so just forget the addition.
|
|
1247
|
+
added.delete(k);
|
|
1248
|
+
}
|
|
1249
|
+
else if (exists(k)) {
|
|
1250
|
+
// Only track a removal that actually drops a key present in the
|
|
1251
|
+
// order list before this batch. `exists(k)` reads the key set at
|
|
1252
|
+
// batch start (paged defers writes; an unpaged key untouched so
|
|
1253
|
+
// far this batch reads its start state), so it is true only for a
|
|
1254
|
+
// genuinely-present key. Deleting a key that was already absent
|
|
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.
|
|
1261
|
+
removed.add(k);
|
|
1262
|
+
}
|
|
1263
|
+
delete inner[k];
|
|
1264
|
+
return true;
|
|
1265
|
+
},
|
|
1266
|
+
has: (_t, key) => typeof key === 'symbol'
|
|
1267
|
+
? false
|
|
1268
|
+
: key in inner,
|
|
1269
|
+
ownKeys: () => Reflect.ownKeys(inner),
|
|
1270
|
+
getOwnPropertyDescriptor: (_t, key) => Object.getOwnPropertyDescriptor(inner, key),
|
|
1271
|
+
});
|
|
1272
|
+
fn(tracking);
|
|
1273
|
+
}, batcher ?? undefined);
|
|
1274
|
+
reconcileOrder();
|
|
1275
|
+
this._owner.commitMutate();
|
|
1276
|
+
}
|
|
1277
|
+
subscribe(cb) {
|
|
1278
|
+
// Content (`set` / `deleted`) rides the map subscription. `order` is
|
|
1279
|
+
// attached ONLY when the order moved; a field edit must not pay for a
|
|
1280
|
+
// re-sequence. A move is detected in O(changed) by testing changed keys
|
|
1281
|
+
// against a cached membership set; the ordered keys (O(order)) are read
|
|
1282
|
+
// only on a move. `known` starts empty so the initial fire reads as an
|
|
1283
|
+
// all-new move and carries the starting `order`.
|
|
1284
|
+
//
|
|
1285
|
+
// A `mutate` / remote delta only moves the order by changing the key set,
|
|
1286
|
+
// which the cheap membership test catches. The exception is a
|
|
1287
|
+
// whole-container re-sync (registration, or a checkout / return / hydrate
|
|
1288
|
+
// swapping the source): it can present the SAME key set in a different
|
|
1289
|
+
// `instance_order`, which the membership test cannot see. The fire tells
|
|
1290
|
+
// us which it is (`resync`) directly, so on a re-sync we read the actual
|
|
1291
|
+
// order (O(order), and these fires are rare) and treat only a genuine
|
|
1292
|
+
// reorder as a move rather than every re-sync (else an empty registration
|
|
1293
|
+
// and an unchanged-order checkout would both deliver a redundant `order`).
|
|
1294
|
+
const known = new Set();
|
|
1295
|
+
let lastOrder = [];
|
|
1296
|
+
const sameOrder = (a, b) => a.length === b.length && a.every((k, i) => k === b[i]);
|
|
1297
|
+
return this._map._subscribeInternal((change, origin, resync) => {
|
|
1298
|
+
let moved = change.deleted.length > 0;
|
|
1299
|
+
for (const key of change.set.keys())
|
|
1300
|
+
if (!known.has(key))
|
|
1301
|
+
moved = true;
|
|
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;
|
|
1311
|
+
}
|
|
1312
|
+
for (const key of change.deleted)
|
|
1313
|
+
known.delete(key);
|
|
1314
|
+
for (const key of change.set.keys())
|
|
1315
|
+
known.add(key);
|
|
1316
|
+
const order = orderKeys ?? this.keys();
|
|
1317
|
+
lastOrder = order;
|
|
1318
|
+
cb({ set: change.set, deleted: change.deleted, order }, origin);
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
// Factory functions called by the Document. App code reaches handles via
|
|
1323
|
+
// `doc.map(...)` / `doc.list(...)`.
|
|
1324
|
+
// `pageCount`, when supplied, lets the root map present a PAGED record store
|
|
1325
|
+
// transparently as a flat record map. The Document supplies it for every root
|
|
1326
|
+
// map; a non-paged map never consults it, so passing it to non-record roots
|
|
1327
|
+
// is harmless. Omitted means the handle only serves the plain unpaged shape.
|
|
1328
|
+
export function createRootMapHandle(root, owner, pageCount, pagingScheme) {
|
|
1329
|
+
return new MapHandleImpl(rootAnchor(root), owner, pageCount ?? null, pagingScheme ?? null);
|
|
1330
|
+
}
|
|
1331
|
+
export function createRootListHandle(root, owner) {
|
|
1332
|
+
return new ListHandleImpl(rootAnchor(root), owner);
|
|
1333
|
+
}
|
|
1334
|
+
// Re-point a ROOT handle to a container in a newly-active source doc and
|
|
1335
|
+
// resync its subscribers, in two phases. The Document calls these on a version
|
|
1336
|
+
// checkout / return-to-head so the handles an app already holds (its
|
|
1337
|
+
// `doc.map(key)` / `doc.list(key)` / `doc.orderedMap(...)` references) follow
|
|
1338
|
+
// the source swap and redraw, instead of being discarded and silently
|
|
1339
|
+
// detached. The cast is safe because this module is the only producer of these
|
|
1340
|
+
// interfaces.
|
|
1341
|
+
//
|
|
1342
|
+
// The two phases must be run across ALL root handles as two passes (rebind
|
|
1343
|
+
// every anchor, THEN resync every handle), not handle-by-handle, because a
|
|
1344
|
+
// composed handle can read a SIBLING root while firing: the ordered map reads
|
|
1345
|
+
// its order list during its map redraw. If the order list's anchor were still
|
|
1346
|
+
// on the old source when the map fired, the redraw would read a half-swapped
|
|
1347
|
+
// pair and drop the keys the order list had not caught up on.
|
|
1348
|
+
export function rebindRootMapAnchor(handle, next) {
|
|
1349
|
+
handle._rebindAnchor(next);
|
|
1350
|
+
}
|
|
1351
|
+
export function rebindRootListAnchor(handle, next) {
|
|
1352
|
+
handle._rebindAnchor(next);
|
|
1353
|
+
}
|
|
1354
|
+
// Re-fire + re-attach a root handle's subscribers against its current
|
|
1355
|
+
// container. Both concrete impls expose `_resync`, so one generic reaches
|
|
1356
|
+
// either kind; the cast is safe because this module is the only producer of
|
|
1357
|
+
// these handles.
|
|
1358
|
+
export function resyncRootHandle(handle) {
|
|
1359
|
+
handle._resync();
|
|
1360
|
+
}
|
|
1361
|
+
// Detach a handle's live subscriptions (its own and its children's). The
|
|
1362
|
+
// Document calls this when it suspends (parks) or closes, so a subscription
|
|
1363
|
+
// registered before `close()` stops firing against the still-live containers
|
|
1364
|
+
// a parked doc keeps applying remote ops to. As with `resyncRootHandle`, one
|
|
1365
|
+
// generic reaches either kind.
|
|
1366
|
+
export function teardownRootHandle(handle) {
|
|
1367
|
+
handle._teardownSubs();
|
|
1368
|
+
}
|
|
1369
|
+
// Bind a map handle and a list handle into an ordered map. The cast to the
|
|
1370
|
+
// concrete impls is safe because this module is the only producer of those
|
|
1371
|
+
// interfaces, and it lets the ordered map drive their no-commit seams.
|
|
1372
|
+
export function createOrderedMapHandle(map, order, owner) {
|
|
1373
|
+
return new OrderedMapHandleImpl(map, order, owner);
|
|
1374
|
+
}
|
|
1375
|
+
//# sourceMappingURL=handles.js.map
|