@brass-build/client 0.1.0 → 0.2.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 +86 -0
- package/README.md +317 -231
- 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 +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.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-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/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- 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 +253 -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 +1 -1
- package/dist/internal.d.ts.map +1 -1
- 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 +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- 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 +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -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 +226 -75
- 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 +140 -19
- 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/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -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 +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- 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 +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- 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 +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -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 +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -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 +249 -80
- 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 +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
package/dist/documents.js
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
// `Object.keys`; doing `loroMap.keys().includes(key)` there makes
|
|
10
10
|
// `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
|
|
11
11
|
import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from 'loro-crdt';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
12
|
+
import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
|
|
13
|
+
import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
|
|
14
|
+
import { flattenPagedLookup, isPagedMarker, META_KEY, pagedRootKey, } from './record-paging.js';
|
|
15
|
+
import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
|
|
16
16
|
import { GoneSignal, WorkStatusSignal, DriveStatusSignal, } from './document-signals.js';
|
|
17
17
|
import { ThumbnailCapture, } from './document-thumbnail-capture.js';
|
|
18
18
|
import { CheckpointScheduler, } from './document-checkpoints.js';
|
|
19
19
|
import { BranchController, } from './document-branches.js';
|
|
20
|
+
import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
|
|
20
21
|
import { CommandMenu, } from './document-commands.js';
|
|
21
22
|
import { DocumentStream } from './document-streams.js';
|
|
22
23
|
import { createEmitter } from './emitter.js';
|
|
@@ -42,8 +43,6 @@ function changeSourceOf(event) {
|
|
|
42
43
|
const by = event.by;
|
|
43
44
|
if (by === 'import')
|
|
44
45
|
return 'remote';
|
|
45
|
-
if (by === 'checkout')
|
|
46
|
-
return 'checkout';
|
|
47
46
|
// by === 'local': disambiguate via the commit-origin tag.
|
|
48
47
|
const origin = event.origin;
|
|
49
48
|
if (origin === UNDO_COMMIT_ORIGIN)
|
|
@@ -52,19 +51,35 @@ function changeSourceOf(event) {
|
|
|
52
51
|
return 'redo';
|
|
53
52
|
return 'local';
|
|
54
53
|
}
|
|
55
|
-
// Per-chunk cap on a
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
54
|
+
// Per-chunk cap on a blob this Document pushes as several ops: a durability
|
|
55
|
+
// resync's missing slice (`computeResyncPush`) and a local commit no write
|
|
56
|
+
// budget bounded (`splitLocalUpdate`).
|
|
57
|
+
//
|
|
58
|
+
// Each chunk goes out through the same local-op hook an ordinary commit
|
|
59
|
+
// uses, so the transport picks itself by size and a chunk this large takes
|
|
60
|
+
// the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
|
|
61
|
+
// makes a heal the most expensive write the SDK performs: the slice a bulk
|
|
62
|
+
// write leaves behind is the whole document, and at frame size that is
|
|
63
|
+
// thousands of ops, each one landing on the document's single storage
|
|
64
|
+
// partition at exactly the size that stays inline on its row. Measured, a
|
|
65
|
+
// 40 MB divergence healed in 16 KB chunks throttled the partition and left
|
|
66
|
+
// the join a tail it refused to serve. The same divergence at this size is
|
|
67
|
+
// tens of ops whose bytes go to the bucket instead.
|
|
68
|
+
const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
|
|
69
|
+
// Hard ceiling for a single irreducible op (one Loro op, unsplittable),
|
|
70
|
+
// beyond which the chunk is skipped with a warning rather than retried
|
|
66
71
|
// forever, while the doc's other chunks still heal.
|
|
67
|
-
|
|
72
|
+
//
|
|
73
|
+
// Skipping drops an edit the server never received, so this sits at exactly
|
|
74
|
+
// what the write path can carry rather than at what a frame holds. Base64
|
|
75
|
+
// turns B raw bytes into ceil(B/3)*4 characters, so the last raw size whose
|
|
76
|
+
// encoding still fits the durable cap is three quarters of it. Derived
|
|
77
|
+
// rather than written out, because the two have to move together and a
|
|
78
|
+
// literal here would let the ceiling drift above what the server accepts,
|
|
79
|
+
// which turns a heal into silent loss. A blob this large travels through
|
|
80
|
+
// the staged upload rather than a request body, a path the write already
|
|
81
|
+
// takes on its own size.
|
|
82
|
+
const RESYNC_PUSH_HARD_MAX_BYTES = Math.floor((MAX_OP_BLOB_CHARS * 3) / 4);
|
|
68
83
|
// Element-wise max of two Loro version vectors. `VersionVector` exposes
|
|
69
84
|
// no public join, so round-trip through its `Map<PeerID, number>` JSON
|
|
70
85
|
// form. Counters are exclusive ends, so the larger is more advanced.
|
|
@@ -79,57 +94,157 @@ function mergeVersionVectors(a, b) {
|
|
|
79
94
|
}
|
|
80
95
|
return VersionVector.parseJSON(merged);
|
|
81
96
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
function rangeOpCount(ranges) {
|
|
98
|
+
return ranges.reduce((n, r) => n + (r.to - r.from), 0);
|
|
99
|
+
}
|
|
100
|
+
// Split half-open per-peer counter ranges in two after `at` ops, splitting at
|
|
101
|
+
// most one range across the boundary. Order is preserved.
|
|
102
|
+
function splitRangesAt(ranges, at) {
|
|
88
103
|
const first = [];
|
|
89
104
|
const second = [];
|
|
90
105
|
let acc = 0;
|
|
91
106
|
for (const r of ranges) {
|
|
92
107
|
const len = r.to - r.from;
|
|
93
|
-
if (acc >=
|
|
108
|
+
if (acc >= at) {
|
|
94
109
|
second.push(r);
|
|
95
110
|
}
|
|
96
|
-
else if (acc + len <=
|
|
111
|
+
else if (acc + len <= at) {
|
|
97
112
|
first.push(r);
|
|
98
113
|
acc += len;
|
|
99
114
|
}
|
|
100
115
|
else {
|
|
101
|
-
const splitAt = r.from + (
|
|
116
|
+
const splitAt = r.from + (at - acc);
|
|
102
117
|
first.push({ peer: r.peer, from: r.from, to: splitAt });
|
|
103
118
|
second.push({ peer: r.peer, from: splitAt, to: r.to });
|
|
104
|
-
acc =
|
|
119
|
+
acc = at;
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
return [first, second];
|
|
108
123
|
}
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
124
|
+
// Split into two halves by op count. `total` must be >= 2 so both halves are
|
|
125
|
+
// non-empty (the caller handles total === 1 as the irreducible single-op case).
|
|
126
|
+
function splitRangesByLength(ranges, total) {
|
|
127
|
+
return splitRangesAt(ranges, Math.floor(total / 2));
|
|
128
|
+
}
|
|
129
|
+
// Split into `parts` pieces of roughly equal op count, in order.
|
|
130
|
+
//
|
|
131
|
+
// A caller that already knows the whole range's byte size can name the number
|
|
132
|
+
// of pieces it wants instead of discovering it by halving, which costs one
|
|
133
|
+
// export per level and re-exports the same ops at every level: an 8 MB commit
|
|
134
|
+
// takes 31 exports to reach sixteen chunks by halving and 16 by naming them.
|
|
135
|
+
// The pieces are still measured afterwards, so an uneven commit that leaves a
|
|
136
|
+
// piece over the cap is split further from there.
|
|
137
|
+
function splitRangesEvenly(ranges, parts) {
|
|
138
|
+
let rest = [...ranges];
|
|
139
|
+
let remaining = rangeOpCount(rest);
|
|
140
|
+
if (parts <= 1 || remaining <= parts)
|
|
141
|
+
return [rest];
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let left = parts; left > 1; left -= 1) {
|
|
144
|
+
const at = Math.floor(remaining / left);
|
|
145
|
+
if (at < 1)
|
|
146
|
+
break;
|
|
147
|
+
const [first, second] = splitRangesAt(rest, at);
|
|
148
|
+
out.push(first);
|
|
149
|
+
rest = second;
|
|
150
|
+
remaining -= at;
|
|
151
|
+
}
|
|
152
|
+
out.push(rest);
|
|
153
|
+
return out.filter((piece) => rangeOpCount(piece) > 0);
|
|
154
|
+
}
|
|
155
|
+
// The `meta` root a document's paged marker lives in, or `null` while the
|
|
156
|
+
// document has none.
|
|
157
|
+
//
|
|
158
|
+
// The marker is read on every root-map read, edit and keyed lookup, and a
|
|
159
|
+
// reader that answers from a copy taken earlier is what puts paged-shaped keys
|
|
160
|
+
// into a store whose marker says unpaged, or reads a root a peer paged as its
|
|
161
|
+
// raw page entries. Answering off the live container instead makes both
|
|
162
|
+
// impossible: a peer's write lands in that same container, so the next read
|
|
163
|
+
// sees it, with no invalidation to get right.
|
|
164
|
+
//
|
|
165
|
+
// What is remembered per document is the resolved CONTAINER, and only once the
|
|
166
|
+
// root exists. A root handle reads through to the document's current state, so
|
|
167
|
+
// holding one keeps the value live: a peer's import and a checkout both land
|
|
168
|
+
// on the same container the cell already points at.
|
|
169
|
+
//
|
|
170
|
+
// Holding it is also what keeps this off the leak. Every `getMap` mints a new
|
|
171
|
+
// wasm-bindgen handle that dropping does not reclaim (the same fact `pageValue`
|
|
172
|
+
// and `entryExists` free around), and this resolves on every root-map read,
|
|
173
|
+
// edit and keyed lookup, so minting per call retains ~145 bytes each for the
|
|
174
|
+
// length of the session. One handle per document is a bounded cost instead.
|
|
175
|
+
//
|
|
176
|
+
// Resolution goes through `getByPath` rather than `getMap`, which is
|
|
177
|
+
// get-or-create and would materialize an empty `meta` on a document that has
|
|
178
|
+
// none (in the wire shape of every unpaged document, and as a local write on a
|
|
179
|
+
// read-only open). Its cost tracks the document's container count, and it is
|
|
180
|
+
// paid once per document.
|
|
181
|
+
const metaRoots = new WeakMap();
|
|
182
|
+
function metaRoot(source) {
|
|
183
|
+
let cell = metaRoots.get(source);
|
|
184
|
+
if (cell === undefined) {
|
|
185
|
+
cell = { map: null };
|
|
186
|
+
metaRoots.set(source, cell);
|
|
187
|
+
}
|
|
188
|
+
if (cell.map === null) {
|
|
189
|
+
const found = source.getByPath(META_KEY);
|
|
190
|
+
if (!(found instanceof LoroMap))
|
|
191
|
+
return null;
|
|
192
|
+
cell.map = found;
|
|
193
|
+
}
|
|
194
|
+
return cell.map;
|
|
195
|
+
}
|
|
196
|
+
/** The `meta` root, creating it if `source` has none, and remembering it so
|
|
197
|
+
* later reads resolve without minting a second handle. */
|
|
198
|
+
function metaRootOrCreate(source) {
|
|
199
|
+
const existing = metaRoot(source);
|
|
200
|
+
if (existing !== null)
|
|
201
|
+
return existing;
|
|
202
|
+
const created = source.getMap(META_KEY);
|
|
203
|
+
metaRoots.set(source, { map: created });
|
|
204
|
+
return created;
|
|
205
|
+
}
|
|
206
|
+
/** Whether one root map is paged. Same predicate the record-store readers
|
|
207
|
+
* use, so the SDK handle path and those readers cannot disagree. */
|
|
208
|
+
function readRootPaged(source, rootKey) {
|
|
209
|
+
return isPagedMarker(metaRoot(source)?.get(pagedRootKey(rootKey)));
|
|
122
210
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// rather than hashing a key into the wrong page.
|
|
129
|
-
function readStorePagingScheme(source) {
|
|
130
|
-
const s = source.getByPath(`${META_KEY}/${PAGING_SCHEME_KEY}`);
|
|
131
|
-
return typeof s === 'number' && s > 0 ? s : CURRENT_PAGING_SCHEME;
|
|
211
|
+
/** Whether a root's JSON is the keyed lookup a record store flattens. A root
|
|
212
|
+
* that projects to a list or a scalar cannot hold pages whatever `meta`
|
|
213
|
+
* says, so it is handed back untouched. */
|
|
214
|
+
function isRootLookup(value) {
|
|
215
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
132
216
|
}
|
|
217
|
+
// Mark `rootKey` paged, under a `meta` key of its own. Written ONCE and never
|
|
218
|
+
// revised: pages are addressed by session and index, so nothing downstream
|
|
219
|
+
// needs to know how many there are or how much the store grows afterwards.
|
|
220
|
+
//
|
|
221
|
+
// A key per root is what lets two peers page two different roots at the same
|
|
222
|
+
// time. One entry naming every paged root would have to be read, extended and
|
|
223
|
+
// written back whole, and the losing side of that race takes its root's marker
|
|
224
|
+
// with it (see `PAGED_ROOT_PREFIX`); two keys merge.
|
|
225
|
+
//
|
|
226
|
+
// The marker commits on its own, under an origin the undo stack excludes, and
|
|
227
|
+
// `commitPending` lands whatever the running mutation has written so far
|
|
228
|
+
// first. Two properties ride on that. Peers and the server never see a page
|
|
229
|
+
// without the marker that says how to read it, because the marker's op
|
|
230
|
+
// precedes every op carrying a page. And nothing an app authored is taken out
|
|
231
|
+
// of the undo stack with it: a commit carrying both would exclude the records
|
|
232
|
+
// as well as the marker.
|
|
233
|
+
function pageRoot(source, rootKey, commitPending) {
|
|
234
|
+
if (readRootPaged(source, rootKey))
|
|
235
|
+
return;
|
|
236
|
+
commitPending();
|
|
237
|
+
metaRootOrCreate(source).set(pagedRootKey(rootKey), true);
|
|
238
|
+
source.setNextCommitOrigin(PAGING_COMMIT_ORIGIN);
|
|
239
|
+
source.commit();
|
|
240
|
+
}
|
|
241
|
+
const READ_ONLY_MESSAGES = {
|
|
242
|
+
link: 'Document is read-only: it was opened via a view-only share link.',
|
|
243
|
+
grant: 'Document is read-only: you have view-only access to it.',
|
|
244
|
+
app: 'Document is read-only: this app is not permitted to edit it.',
|
|
245
|
+
unsaved: 'Document is read-only: a change could not be saved, so this copy holds ' +
|
|
246
|
+
'work the platform does not. Brass is replacing it with the platform copy.',
|
|
247
|
+
};
|
|
133
248
|
// Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
|
|
134
249
|
// toJSON / close. Construction is cheap: the Loro subscriber attaches
|
|
135
250
|
// lazily (see `_ensureLoroSubscribed`).
|
|
@@ -164,13 +279,15 @@ export class Document {
|
|
|
164
279
|
// leaves this `false`.
|
|
165
280
|
_isFrozen;
|
|
166
281
|
// Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
|
|
167
|
-
// is a version-pinned open with no live channel)
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
282
|
+
// is a version-pinned open with no live channel): the Document keeps a
|
|
283
|
+
// live WS subscription and applies incoming remote ops, but local
|
|
284
|
+
// `mutate` is rejected, so the reader sees edits stream in while being
|
|
285
|
+
// unable to write. Set at open from the join's `can_edit` (a viewer's
|
|
286
|
+
// grant, a view-only link), and latched on afterwards by
|
|
287
|
+
// `_markReadOnly` when the server refuses a write on the same grounds.
|
|
171
288
|
_forcedReadOnly;
|
|
172
|
-
|
|
173
|
-
|
|
289
|
+
// Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
|
|
290
|
+
_readOnlyReason;
|
|
174
291
|
// The UndoManager is constructed eagerly so the first call to
|
|
175
292
|
// `doc.mutate(...)` lands in the undo stack. Lazy construction
|
|
176
293
|
// (deferred to first `doc.undo()`) would miss every commit made
|
|
@@ -184,7 +301,9 @@ export class Document {
|
|
|
184
301
|
// app reconciles by value and reads `origin.source` only when it has a
|
|
185
302
|
// non-render reason to know the cause. A throwing subscriber is swallowed
|
|
186
303
|
// so it cannot strand the others or the WS router.
|
|
187
|
-
_subs = createEmitter({
|
|
304
|
+
_subs = createEmitter({
|
|
305
|
+
onError: () => { },
|
|
306
|
+
});
|
|
188
307
|
// Subscribers registered via `subscribeUndoState`. Fired after
|
|
189
308
|
// every underlying Loro event when `canUndo()` or `canRedo()`
|
|
190
309
|
// changed value vs the last fire, so UI bound to undo/redo
|
|
@@ -314,6 +433,9 @@ export class Document {
|
|
|
314
433
|
// Live = neither closed nor parked. This is the public-surface gate;
|
|
315
434
|
// internal ingress (remote ops, status emits) deliberately checks
|
|
316
435
|
// `_closed` alone, because a parked doc keeps applying remote state.
|
|
436
|
+
// Readable from the SDK's own layers (the client's open-document set
|
|
437
|
+
// gates on it) rather than only from inside this class.
|
|
438
|
+
/** @internal */
|
|
317
439
|
get _isLive() {
|
|
318
440
|
return !this._closed && !this._suspended;
|
|
319
441
|
}
|
|
@@ -321,17 +443,32 @@ export class Document {
|
|
|
321
443
|
if (!this._isLive)
|
|
322
444
|
throw new Error('Document is closed');
|
|
323
445
|
}
|
|
446
|
+
// `close()` releases the CRDT state, so a read after it reaches released
|
|
447
|
+
// memory and fails with whatever the engine says about that. Reads are gated
|
|
448
|
+
// on this rather than on `_assertOpen` because a parked Document still holds
|
|
449
|
+
// its state and answers from it.
|
|
450
|
+
_assertNotClosed() {
|
|
451
|
+
if (this._closed)
|
|
452
|
+
throw new Error('Document is closed');
|
|
453
|
+
}
|
|
324
454
|
// Server-pushed lifecycle signals: `_gone` is fire-once-then-cache
|
|
325
455
|
// for `doc_gone` eviction; `_workStatus` is latest-retained,
|
|
326
456
|
// re-emit-per-phase for `doc_status`.
|
|
327
457
|
_gone = new GoneSignal();
|
|
328
458
|
_workStatus = new WorkStatusSignal();
|
|
459
|
+
// Inbound work ended without delivering content, so this doc holds
|
|
460
|
+
// nothing and stays locked. See `_emitStatus` for how it is derived.
|
|
461
|
+
_loadFailed = false;
|
|
329
462
|
// Drive sync state, latest-retained. `null` until the open factory
|
|
330
463
|
// seeds it (drive-linked docs only) and on docs with no drive link.
|
|
331
464
|
_driveStatus = new DriveStatusSignal();
|
|
332
465
|
// Document-scoped presence. `null` until the WS transport is wired
|
|
333
466
|
// on, and on the offline / test path where there is no transport.
|
|
334
467
|
presence = null;
|
|
468
|
+
// A Document owns the LoroDoc it wraps, so it is built through the factories
|
|
469
|
+
// below (or `documentFromEngine`, for in-repo tooling that brings its own).
|
|
470
|
+
// Owning construction is what lets this class declare an engine-neutral
|
|
471
|
+
// surface: app code holds a Document, never the doc underneath it.
|
|
335
472
|
constructor(loroDoc, options) {
|
|
336
473
|
this._doc = loroDoc;
|
|
337
474
|
this._rootKey = options?.rootKey ?? 'entities';
|
|
@@ -355,12 +492,15 @@ export class Document {
|
|
|
355
492
|
this._isFrozen = false;
|
|
356
493
|
this._checkout = null;
|
|
357
494
|
}
|
|
358
|
-
// `readOnly` on a live (non-version) open is a forced read-only:
|
|
359
|
-
//
|
|
360
|
-
//
|
|
495
|
+
// `readOnly` on a live (non-version) open is a forced read-only: a
|
|
496
|
+
// reader the server will not accept writes from, whether because their
|
|
497
|
+
// grant is view-only or because the link they came through is. A
|
|
498
|
+
// version open is already frozen, so it does not also need the flag.
|
|
361
499
|
this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
|
|
362
|
-
this.
|
|
363
|
-
|
|
500
|
+
this._readOnlyReason = options?.readOnlyReason ?? 'link';
|
|
501
|
+
// Materialize the root container, so a document nothing has written to
|
|
502
|
+
// still carries its root in the shape a reader opens it with.
|
|
503
|
+
loroDoc.getMap(this._rootKey);
|
|
364
504
|
this._undo = new UndoController(this._doc, options?.undo);
|
|
365
505
|
}
|
|
366
506
|
// ---- Typed container handles (the primary read/write/subscribe surface) --
|
|
@@ -378,6 +518,14 @@ export class Document {
|
|
|
378
518
|
// rebinds and each suspend / close tears down every root handle in a single
|
|
379
519
|
// traversal.
|
|
380
520
|
_rootHandles = new Map();
|
|
521
|
+
// Names the pages this Document's writes may occupy. A page is one value,
|
|
522
|
+
// so two writers appending to the same page container would resolve
|
|
523
|
+
// last-writer-wins and lose one writer's records; the token is what keeps
|
|
524
|
+
// their key spaces apart. Loro's peer id is already unique per open, which
|
|
525
|
+
// is exactly the scope wanted.
|
|
526
|
+
get _pagingSessionToken() {
|
|
527
|
+
return this._doc.peerIdStr;
|
|
528
|
+
}
|
|
381
529
|
// Get a handle for a top-level LoroMap at `key`. The container is
|
|
382
530
|
// materialized in the underlying LoroDoc immediately (Loro's
|
|
383
531
|
// `getMap` is "get or create"); mutations through the handle do
|
|
@@ -397,29 +545,30 @@ export class Document {
|
|
|
397
545
|
// `instances` map present its page containers transparently as a
|
|
398
546
|
// flat record map. It resolves the ACTIVE source at call time (not
|
|
399
547
|
// the one captured at handle creation): a checkout rebinds the
|
|
400
|
-
// handle's anchor to the preview doc, and the page
|
|
401
|
-
// from that same doc or
|
|
402
|
-
// closure pinned to a preview doc would read a freed
|
|
403
|
-
// returnToHead).
|
|
548
|
+
// handle's anchor to the preview doc, and the page state must come
|
|
549
|
+
// from that same doc or a read takes the wrong path for the state it is
|
|
550
|
+
// looking at (and a closure pinned to a preview doc would read a freed
|
|
551
|
+
// LoroDoc after returnToHead).
|
|
404
552
|
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
// `meta
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
:
|
|
553
|
+
// EVERY root map gets the providers, because a `meta` key of its own is
|
|
554
|
+
// what names a paged root: a root with no such key reads as unpaged, which
|
|
555
|
+
// is the answer `meta` and `header` want anyway. Naming one root here
|
|
556
|
+
// instead would make paging a property of a reserved key rather than of a
|
|
557
|
+
// collection, so a map an app owns could never be paged however large it
|
|
558
|
+
// grew.
|
|
559
|
+
const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
560
|
+
const handle = createRootMapHandle(loroMap, this._handleOwner, {
|
|
561
|
+
isPaged: () => readRootPaged(activeSource(), key),
|
|
562
|
+
page: () => {
|
|
563
|
+
pageRoot(this._doc, key, this._handleOwner.commitMutate);
|
|
564
|
+
},
|
|
565
|
+
sessionToken: () => this._pagingSessionToken,
|
|
566
|
+
stateVersion: () => activeSource().frontiers(),
|
|
567
|
+
});
|
|
419
568
|
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
420
569
|
return handle;
|
|
421
570
|
}
|
|
422
|
-
// Get a handle for a top-level
|
|
571
|
+
// Get a handle for a top-level LoroMovableList at `key`.
|
|
423
572
|
list(key) {
|
|
424
573
|
this._assertOpen();
|
|
425
574
|
const cacheKey = `list:${key}`;
|
|
@@ -427,7 +576,7 @@ export class Document {
|
|
|
427
576
|
if (existing)
|
|
428
577
|
return existing.handle;
|
|
429
578
|
const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
430
|
-
const loroList = source.
|
|
579
|
+
const loroList = source.getMovableList(key);
|
|
431
580
|
const handle = createRootListHandle(loroList, this._handleOwner);
|
|
432
581
|
this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
|
|
433
582
|
return handle;
|
|
@@ -461,14 +610,25 @@ export class Document {
|
|
|
461
610
|
if (this._handleOwnerCache)
|
|
462
611
|
return this._handleOwnerCache;
|
|
463
612
|
this._handleOwnerCache = {
|
|
613
|
+
assertReadable: () => {
|
|
614
|
+
this._assertNotClosed();
|
|
615
|
+
},
|
|
464
616
|
assertWritable: () => {
|
|
465
617
|
this._assertOpen();
|
|
466
618
|
if (this._checkout !== null) {
|
|
467
619
|
throw new Error('Document is read-only: it is showing a historical version. ' +
|
|
468
620
|
'Call `doc.returnToHead()` to edit.');
|
|
469
621
|
}
|
|
622
|
+
if (this._versionBrowsing) {
|
|
623
|
+
throw new Error('Document is read-only: the reader is browsing its versions. ' +
|
|
624
|
+
'Close the version bar to edit.');
|
|
625
|
+
}
|
|
470
626
|
if (this._forcedReadOnly) {
|
|
471
|
-
throw new Error(
|
|
627
|
+
throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
|
|
628
|
+
}
|
|
629
|
+
if (this._loadFailed) {
|
|
630
|
+
throw new Error('Document is read-only: its content could not be prepared ' +
|
|
631
|
+
'(see `doc.status`). Reopen it to try again.');
|
|
472
632
|
}
|
|
473
633
|
if (this._isLoadLocked()) {
|
|
474
634
|
throw new Error('Document is still loading its content; wait for the load to ' +
|
|
@@ -502,11 +662,14 @@ export class Document {
|
|
|
502
662
|
get branchId() {
|
|
503
663
|
return this._branchId;
|
|
504
664
|
}
|
|
505
|
-
// True when
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
665
|
+
// True when this reader cannot write to the document right now:
|
|
666
|
+
// `mutate` throws and edit affordances should be disabled. It covers a
|
|
667
|
+
// view-only grant, a view-only share link, a historical version showing
|
|
668
|
+
// (a checkout), and a document whose content is still loading or could
|
|
669
|
+
// not be loaded. Reactive in every one of those: `subscribe` fires when
|
|
670
|
+
// it changes, so an app binds it directly inside the subscribe handler
|
|
671
|
+
// and follows a grant the server withdraws mid-session as well as a
|
|
672
|
+
// checkout the reader entered.
|
|
510
673
|
get readOnly() {
|
|
511
674
|
// A doc that is still loading its first content (a drive-linked
|
|
512
675
|
// stub hydrating on first open) is read-only until the bytes
|
|
@@ -516,19 +679,48 @@ export class Document {
|
|
|
516
679
|
// `reloadAfterHydration`); until then mutation is rejected. This is
|
|
517
680
|
// also what stops an app's "seed an empty doc" path from clobbering
|
|
518
681
|
// a doc that only looks empty because it has not loaded yet.
|
|
519
|
-
|
|
682
|
+
// Browsing versions locks the document at the live head as well as at an
|
|
683
|
+
// earlier one. Stepping moves between the points history KEEPS, and an
|
|
684
|
+
// edit at the head makes no such point, so an edit made under the open
|
|
685
|
+
// bar puts the reader's own newest work somewhere the bar cannot travel
|
|
686
|
+
// to: Back then steps past it to the newest marker, and the way forward
|
|
687
|
+
// is a different number of presses from the way back. Reading history
|
|
688
|
+
// and writing it are separate modes, and the bar carries the Close that
|
|
689
|
+
// leaves this one.
|
|
690
|
+
return (this._forcedReadOnly ||
|
|
691
|
+
this._checkout !== null ||
|
|
692
|
+
this._versionBrowsing ||
|
|
693
|
+
this._isLoadLocked());
|
|
520
694
|
}
|
|
521
695
|
// True when content-replacing work (hydration / extraction) is pending
|
|
522
696
|
// and editing must be locked so incoming bytes do not clobber local
|
|
523
|
-
// ops
|
|
524
|
-
// nothing
|
|
697
|
+
// ops, and true again once that work ends without content
|
|
698
|
+
// (`_loadFailed`): a doc nothing could prepare is empty, and an edit
|
|
699
|
+
// there writes ops into a document the app is about to be told could not
|
|
700
|
+
// be opened. A pending PUSH is outbound (it writes the doc out to the
|
|
701
|
+
// drive, nothing replaces local content), so the server marks it
|
|
525
702
|
// `blocking: false` and the doc stays editable while the importer
|
|
526
703
|
// runs. An older server omits `blocking`; absence is treated as
|
|
527
704
|
// blocking so hydration stays locked.
|
|
528
705
|
_isLoadLocked() {
|
|
706
|
+
if (this._loadFailed)
|
|
707
|
+
return true;
|
|
529
708
|
const s = this._workStatus.status;
|
|
530
709
|
return s?.pending === true && s.blocking !== false;
|
|
531
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* Whether inbound work on this document ended without content. The SDK's
|
|
713
|
+
* session reads it to report the open as failed rather than binding an
|
|
714
|
+
* empty document as ready.
|
|
715
|
+
* @internal
|
|
716
|
+
*/
|
|
717
|
+
get _contentLoadFailed() {
|
|
718
|
+
return this._loadFailed;
|
|
719
|
+
}
|
|
720
|
+
/** @internal The `doc_gone` reason this Document has seen, or `null`. */
|
|
721
|
+
get _goneReason() {
|
|
722
|
+
return this._gone.reason;
|
|
723
|
+
}
|
|
532
724
|
/**
|
|
533
725
|
* The historical version this Document shows, or `null` for a live
|
|
534
726
|
* (head) open. Reactive alongside `readOnly`. The number is the
|
|
@@ -589,6 +781,15 @@ export class Document {
|
|
|
589
781
|
static create(options) {
|
|
590
782
|
return new Document(new LoroDoc(), options);
|
|
591
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Wrap a LoroDoc the caller already holds. `documentFromEngine` is the typed
|
|
786
|
+
* entry point; this seam stays `unknown` so `documents.ts` declares an
|
|
787
|
+
* engine-neutral surface.
|
|
788
|
+
* @internal
|
|
789
|
+
*/
|
|
790
|
+
static _fromEngine(engine, options) {
|
|
791
|
+
return new Document(engine, options);
|
|
792
|
+
}
|
|
592
793
|
// Apply a remote op blob produced by another peer (delivered via the
|
|
593
794
|
// WS `doc_op` server frame). Idempotent: Loro's import
|
|
594
795
|
// path dedupes by lamport, so re-applying a self-originated op is
|
|
@@ -615,8 +816,8 @@ export class Document {
|
|
|
615
816
|
}
|
|
616
817
|
// Apply a snapshot blob over the top of an existing Document.
|
|
617
818
|
// Internal: the SDK calls this when the server delivers a fresh
|
|
618
|
-
// snapshot mid-session
|
|
619
|
-
//
|
|
819
|
+
// snapshot mid-session. Persistence is server-authoritative: the server
|
|
820
|
+
// decides when a fresh snapshot supersedes the local history.
|
|
620
821
|
/** @internal */
|
|
621
822
|
_applySnapshot(snapshot) {
|
|
622
823
|
// See `applyRemoteOp`: refuse the import on a closed (freed) doc so a late
|
|
@@ -733,9 +934,9 @@ export class Document {
|
|
|
733
934
|
// intentional escape hatch: `Brass.openDocumentById` wires the hook;
|
|
734
935
|
// `Document.create()` and direct construction stay
|
|
735
936
|
// hook-free so the class is usable without taking the WS dependency.
|
|
736
|
-
// `E` defaults to `EntityProxy` (the untyped Record<string, unknown>)
|
|
737
|
-
//
|
|
738
|
-
// entity shape pass it as a type argument:
|
|
937
|
+
// `E` defaults to `EntityProxy` (the untyped Record<string, unknown>) so an
|
|
938
|
+
// app that has not described its entity shape still gets a working mutate.
|
|
939
|
+
// Apps with a typed entity shape pass it as a type argument:
|
|
739
940
|
//
|
|
740
941
|
// doc.mutate<MyEntity>((d) => {
|
|
741
942
|
// d.entities[id] = myTypedEntity;
|
|
@@ -745,39 +946,16 @@ export class Document {
|
|
|
745
946
|
// regardless of `E`; the generic exists purely to widen the type at
|
|
746
947
|
// the assignment site so closed interfaces don't need an
|
|
747
948
|
// `as Record<string, unknown>` cast.
|
|
949
|
+
//
|
|
950
|
+
// `d.entities` is the root map's handle under a plain-object surface, so
|
|
951
|
+
// the two reach one collection: a record a paged store holds reads and
|
|
952
|
+
// edits here exactly as it does through `doc.map(rootKey)`, and a write
|
|
953
|
+
// large enough to cross the paging threshold takes the paged shape from
|
|
954
|
+
// either.
|
|
748
955
|
mutate(fn) {
|
|
749
|
-
this.
|
|
750
|
-
|
|
751
|
-
throw new Error('Document is read-only: it is showing a historical version. ' +
|
|
752
|
-
'Call `doc.returnToHead()` to edit.');
|
|
753
|
-
}
|
|
754
|
-
// A view-only share-link (guest) open is a live head held read-only:
|
|
755
|
-
// remote ops still stream in, but a local edit must be refused. The
|
|
756
|
-
// `_checkout` guard above only covers the version-pinned case, so this
|
|
757
|
-
// is a separate branch.
|
|
758
|
-
if (this._forcedReadOnly) {
|
|
759
|
-
throw new Error('Document is read-only: it was opened via a view-only share link.');
|
|
760
|
-
}
|
|
761
|
-
if (this._isLoadLocked()) {
|
|
762
|
-
throw new Error('Document is still loading its content; wait for the load to ' +
|
|
763
|
-
'finish before editing (see `doc.onStatus`).');
|
|
764
|
-
}
|
|
765
|
-
this._ensureLocalUpdatesSubscribed();
|
|
766
|
-
// A fresh proxy for the call, because the batcher rides it: the
|
|
767
|
-
// long-lived `this._entities` must stay batcher-free for the reads
|
|
768
|
-
// that go through it outside a mutation.
|
|
769
|
-
const batcher = new MutateBatcher(() => {
|
|
770
|
-
this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
|
|
771
|
-
this._doc.commit();
|
|
772
|
-
});
|
|
773
|
-
fn({
|
|
774
|
-
entities: wrapLoroMapProxy(this._root, batcher),
|
|
956
|
+
this.map(this._rootKey).mutate((entities) => {
|
|
957
|
+
fn({ entities });
|
|
775
958
|
});
|
|
776
|
-
// Stamp the commit so `subscribe`'s default path can identify
|
|
777
|
-
// and suppress the local-mutate echo. `setNextCommitOrigin`
|
|
778
|
-
// applies to the very next commit, and `commit()` consumes it.
|
|
779
|
-
this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
|
|
780
|
-
this._doc.commit();
|
|
781
959
|
}
|
|
782
960
|
// Install / replace the local-op send hook; `null` detaches it.
|
|
783
961
|
//
|
|
@@ -821,6 +999,12 @@ export class Document {
|
|
|
821
999
|
_isCheckedOut() {
|
|
822
1000
|
return this._checkout !== null;
|
|
823
1001
|
}
|
|
1002
|
+
/** @internal Whether this Document is pinned to the version it was opened
|
|
1003
|
+
* at, with no live channel behind it. Stepping and returning to the head
|
|
1004
|
+
* both throw on that shape, so the SDK's version bar offers neither. */
|
|
1005
|
+
get _isVersionPinned() {
|
|
1006
|
+
return this._isFrozen;
|
|
1007
|
+
}
|
|
824
1008
|
// Switch the Document to a historical version, in place. @internal
|
|
825
1009
|
// (the hosted `history` command drives this). After the promise
|
|
826
1010
|
// resolves, `doc.readOnly` is `true`,
|
|
@@ -870,6 +1054,11 @@ export class Document {
|
|
|
870
1054
|
previewDoc.import(blob);
|
|
871
1055
|
const previewRoot = previewDoc.getMap(this._rootKey);
|
|
872
1056
|
const old = this._checkout;
|
|
1057
|
+
// Snapshot the handles' keys before the active-source pointer moves (see
|
|
1058
|
+
// `_snapshotRootHandleKeys`). Nothing between here and `_rebindRootHandles`
|
|
1059
|
+
// may return early, or the snapshot would outlive its swap and be diffed
|
|
1060
|
+
// against an unrelated later one.
|
|
1061
|
+
this._snapshotRootHandleKeys();
|
|
873
1062
|
this._checkout = {
|
|
874
1063
|
previewDoc,
|
|
875
1064
|
previewRoot,
|
|
@@ -896,29 +1085,38 @@ export class Document {
|
|
|
896
1085
|
// ignore; already freed
|
|
897
1086
|
}
|
|
898
1087
|
}
|
|
899
|
-
this._fireSubscribers({ kind: '
|
|
1088
|
+
this._fireSubscribers({ kind: 'history', version: effectiveVersion });
|
|
900
1089
|
this._maybeFireUndoState();
|
|
901
1090
|
// Preview every opened stream at the same point in time, so a
|
|
902
1091
|
// partitioned document's history shows a consistent historical state
|
|
903
1092
|
// across streams rather than a historical default over live streams.
|
|
904
|
-
await this._checkoutOpenStreams();
|
|
1093
|
+
await this._checkoutOpenStreams(generation);
|
|
905
1094
|
}
|
|
906
1095
|
// Re-materialize each opened non-default stream at its seq recorded by the
|
|
907
1096
|
// active preview's checkpoint. A stream with no recorded seq at this
|
|
908
1097
|
// version (opened after the checkpoint, or before any checkpoint recorded
|
|
909
|
-
// one)
|
|
910
|
-
//
|
|
1098
|
+
// one) has no historical state it can show, so it is held read-only for the
|
|
1099
|
+
// duration rather than left live and writable under a historical default. Best-effort per stream: a stream whose inner
|
|
911
1100
|
// has no checkout fetcher (a guest line, which has no token to fetch
|
|
912
1101
|
// at-version) stays live. Failures are swallowed so one stream cannot
|
|
913
1102
|
// break the doc-level preview.
|
|
914
|
-
async _checkoutOpenStreams() {
|
|
1103
|
+
async _checkoutOpenStreams(generation) {
|
|
915
1104
|
const seqs = this._checkoutStreamSeqs ?? {};
|
|
916
1105
|
for (const [name, openPromise] of this._streamOpens) {
|
|
917
1106
|
try {
|
|
918
1107
|
const stream = await openPromise;
|
|
1108
|
+
// Every stream costs an await, so the reader can have stepped
|
|
1109
|
+
// somewhere else, or back to the live head, since the previous one.
|
|
1110
|
+
// The document's own swap is guarded the same way, and this loop
|
|
1111
|
+
// running on past it is what leaves a partitioned document showing
|
|
1112
|
+
// two moments at once: the document at the version the reader asked
|
|
1113
|
+
// for, and whichever streams a superseded run still reached at its
|
|
1114
|
+
// own.
|
|
1115
|
+
if (generation !== this._checkoutGeneration)
|
|
1116
|
+
return;
|
|
919
1117
|
const lineId = this._streamManifest?.get(name)?.line_id;
|
|
920
1118
|
const seq = lineId !== undefined ? seqs[lineId] : undefined;
|
|
921
|
-
await stream._previewAt(seq);
|
|
1119
|
+
await stream._previewAt(seq, true);
|
|
922
1120
|
}
|
|
923
1121
|
catch {
|
|
924
1122
|
// A stream that failed to open or preview is left as-is.
|
|
@@ -946,10 +1144,13 @@ export class Document {
|
|
|
946
1144
|
// Bump first, unconditionally: a checkout fetch may be in flight with
|
|
947
1145
|
// `_checkout` still null, and this is the signal that supersedes it when
|
|
948
1146
|
// it resolves.
|
|
949
|
-
this._checkoutGeneration += 1;
|
|
1147
|
+
const generation = (this._checkoutGeneration += 1);
|
|
950
1148
|
if (this._checkout === null)
|
|
951
1149
|
return;
|
|
952
1150
|
const old = this._checkout;
|
|
1151
|
+
// Symmetric with `_checkoutVersion`: snapshot before the active-source
|
|
1152
|
+
// pointer moves, and return early nowhere between here and the rebind.
|
|
1153
|
+
this._snapshotRootHandleKeys();
|
|
953
1154
|
this._checkout = null;
|
|
954
1155
|
// Re-point the app's handles back at the live doc and redraw them,
|
|
955
1156
|
// symmetrically with the checkout path, so the model returns to the live
|
|
@@ -974,12 +1175,33 @@ export class Document {
|
|
|
974
1175
|
this._checkoutStreamSeqs = null;
|
|
975
1176
|
for (const openPromise of this._streamOpens.values()) {
|
|
976
1177
|
void openPromise
|
|
977
|
-
.then((stream) =>
|
|
1178
|
+
.then((stream) => {
|
|
1179
|
+
// Each stream is reached a turn or more later, so a checkout the
|
|
1180
|
+
// reader started in the meantime owns the document by now and this
|
|
1181
|
+
// return would drag its streams back to the live head under it.
|
|
1182
|
+
if (generation !== this._checkoutGeneration)
|
|
1183
|
+
return;
|
|
1184
|
+
return stream._previewAt(undefined);
|
|
1185
|
+
})
|
|
978
1186
|
.catch(() => {
|
|
979
1187
|
// A stream that failed to open has nothing to return.
|
|
980
1188
|
});
|
|
981
1189
|
}
|
|
982
1190
|
}
|
|
1191
|
+
// Capture every root map handle's key set for the resync's removed-key diff,
|
|
1192
|
+
// while the handles still describe the outgoing source. This is a phase of
|
|
1193
|
+
// its own, ahead of `_rebindRootHandles`, because a handle resolves its
|
|
1194
|
+
// container from its anchor but its paging state from this Document's active
|
|
1195
|
+
// source: both must still name the outgoing source when the keys are read,
|
|
1196
|
+
// and the caller moves the active-source pointer between the two calls.
|
|
1197
|
+
// Callers must not return early in that window; a snapshot left unconsumed
|
|
1198
|
+
// would be diffed against a later, unrelated swap.
|
|
1199
|
+
_snapshotRootHandleKeys() {
|
|
1200
|
+
for (const entry of this._rootHandles.values()) {
|
|
1201
|
+
if (entry.kind === 'map')
|
|
1202
|
+
snapshotRootMapKeys(entry.handle);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
983
1205
|
// Re-point every cached root handle at the corresponding container in
|
|
984
1206
|
// `source` (the live doc or a checkout preview) and resync its subscribers.
|
|
985
1207
|
// The app holds these same handle objects, so this is what makes a version
|
|
@@ -997,7 +1219,7 @@ export class Document {
|
|
|
997
1219
|
rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
|
|
998
1220
|
}
|
|
999
1221
|
else {
|
|
1000
|
-
rebindRootListAnchor(entry.handle, source.
|
|
1222
|
+
rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
|
|
1001
1223
|
}
|
|
1002
1224
|
}
|
|
1003
1225
|
for (const entry of this._rootHandles.values()) {
|
|
@@ -1099,11 +1321,8 @@ export class Document {
|
|
|
1099
1321
|
setSourceBlobFetcher(fetcher) {
|
|
1100
1322
|
this._sourceBlobFetcher = fetcher;
|
|
1101
1323
|
}
|
|
1102
|
-
// Publish this document's current state back to its drive source
|
|
1103
|
-
//
|
|
1104
|
-
// edits visible to people working outside Brass on the same source:
|
|
1105
|
-
//
|
|
1106
|
-
// await doc.pushToDrive({ message: 'Wall move' });
|
|
1324
|
+
// Publish this document's current state back to its drive source,
|
|
1325
|
+
// driving the hosted toolbar's publish control.
|
|
1107
1326
|
//
|
|
1108
1327
|
// The platform publishes the doc's current state, overwriting the
|
|
1109
1328
|
// source (unknown drive fields preserved), and marks its own write so
|
|
@@ -1111,6 +1330,7 @@ export class Document {
|
|
|
1111
1330
|
// concurrent drive-side edits. Throws on auth / link / write
|
|
1112
1331
|
// failures; resolves with the new external ref and the published
|
|
1113
1332
|
// entity count.
|
|
1333
|
+
/** @internal */
|
|
1114
1334
|
async pushToDrive(input) {
|
|
1115
1335
|
this._assertOpen();
|
|
1116
1336
|
if (this._docId === null) {
|
|
@@ -1122,10 +1342,9 @@ export class Document {
|
|
|
1122
1342
|
return await this._drivePusher(input ?? {});
|
|
1123
1343
|
}
|
|
1124
1344
|
// Resolve a `behind` / `diverged` drive-linked doc by opening the
|
|
1125
|
-
// hosted resolution surface,
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
// if (doc.driveStatus?.state === 'diverged') await doc.resolveDrive();
|
|
1345
|
+
// hosted resolution surface, which offers pull (take the source) / keep
|
|
1346
|
+
// both alongside the publish-over that `pushToDrive` does. Driven by the
|
|
1347
|
+
// hosted toolbar.
|
|
1129
1348
|
//
|
|
1130
1349
|
// Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
|
|
1131
1350
|
// takes over the drive link (take-source trashes this doc, keep-both
|
|
@@ -1135,6 +1354,7 @@ export class Document {
|
|
|
1135
1354
|
// returned `newDocId` lets a multi-doc app do its own switch. Resolves
|
|
1136
1355
|
// with the outcome, or `null` when the user dismissed the dialog.
|
|
1137
1356
|
// Throws when there is no doc identity or no resolver is attached.
|
|
1357
|
+
/** @internal */
|
|
1138
1358
|
async resolveDrive() {
|
|
1139
1359
|
this._assertOpen();
|
|
1140
1360
|
if (this._docId === null) {
|
|
@@ -1185,6 +1405,9 @@ export class Document {
|
|
|
1185
1405
|
_streamRuntime = null;
|
|
1186
1406
|
_streamManifest = null;
|
|
1187
1407
|
_streamOpens = new Map();
|
|
1408
|
+
// The subset of `_streamOpens` that has resolved, so a park reaches them
|
|
1409
|
+
// without waiting on a microtask. Kept in step with the map above.
|
|
1410
|
+
_openStreams = new Map();
|
|
1188
1411
|
_ownStream = null;
|
|
1189
1412
|
// Which stream this Document's own handles read. An open names a stream
|
|
1190
1413
|
// and the usual one has a default, so `default` is a name here rather
|
|
@@ -1261,14 +1484,30 @@ export class Document {
|
|
|
1261
1484
|
// the same point in time rather than showing it live under a
|
|
1262
1485
|
// historical default.
|
|
1263
1486
|
if (this._checkout !== null) {
|
|
1264
|
-
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id]);
|
|
1487
|
+
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
|
|
1265
1488
|
}
|
|
1266
1489
|
return stream;
|
|
1267
1490
|
})();
|
|
1268
1491
|
this._streamOpens.set(name, open);
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1492
|
+
// Hold the RESOLVED stream too. A park has to reach the streams
|
|
1493
|
+
// synchronously (an app's `close()` returns before any microtask runs, and
|
|
1494
|
+
// a remote op landing in between would still call the app back), and a
|
|
1495
|
+
// promise cannot be read that way. An open still in flight when the park
|
|
1496
|
+
// happens resolves into a parked document, so it is parked on arrival.
|
|
1497
|
+
void open.then((stream) => {
|
|
1498
|
+
if (this._streamOpens.get(name) !== open)
|
|
1499
|
+
return;
|
|
1500
|
+
this._openStreams.set(name, stream);
|
|
1501
|
+
// `savingCount` counts this stream from here on, so an indicator bound
|
|
1502
|
+
// to the document has to hear the stream's writes land.
|
|
1503
|
+
stream._inner._observeSavingCount(() => {
|
|
1504
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
1505
|
+
});
|
|
1506
|
+
if (this._suspended)
|
|
1507
|
+
stream._suspend();
|
|
1508
|
+
}, () => {
|
|
1509
|
+
// A failed open (network, deleted stream) must not poison the cache:
|
|
1510
|
+
// drop it so the next call retries.
|
|
1272
1511
|
if (this._streamOpens.get(name) === open)
|
|
1273
1512
|
this._streamOpens.delete(name);
|
|
1274
1513
|
});
|
|
@@ -1336,14 +1575,45 @@ export class Document {
|
|
|
1336
1575
|
// Close every opened stream's own transport + state. Streams opened
|
|
1337
1576
|
// while this Document was live do not outlive it.
|
|
1338
1577
|
_closeStreams() {
|
|
1578
|
+
// A stream that has already opened closes IN THIS CALL, for the reason the
|
|
1579
|
+
// park reaches them through this map: `close()` returns to the app before
|
|
1580
|
+
// any microtask runs, so a stream closed on a `.then` spends the rest of
|
|
1581
|
+
// that turn taking writes and calling the app back while `doc.mutate` on
|
|
1582
|
+
// the same document already refuses. A write in that window reaches a
|
|
1583
|
+
// transport about to be torn down and is never persisted.
|
|
1584
|
+
for (const stream of this._openStreams.values()) {
|
|
1585
|
+
stream._inner._observeSavingCount(null);
|
|
1586
|
+
stream._close();
|
|
1587
|
+
}
|
|
1588
|
+
// An open still in flight has no Document to close yet, so it closes on
|
|
1589
|
+
// arrival. A stream reached both ways is closed twice, which is a no-op.
|
|
1339
1590
|
for (const open of this._streamOpens.values()) {
|
|
1340
1591
|
void open.then((s) => s._close()).catch(() => {
|
|
1341
1592
|
// A failed open has nothing to close.
|
|
1342
1593
|
});
|
|
1343
1594
|
}
|
|
1344
1595
|
this._streamOpens.clear();
|
|
1596
|
+
this._openStreams.clear();
|
|
1345
1597
|
this._streamManifest = null;
|
|
1346
1598
|
}
|
|
1599
|
+
// Park / unpark the streams opened off this document, alongside the
|
|
1600
|
+
// document's own park.
|
|
1601
|
+
//
|
|
1602
|
+
// A non-default stream is its own Document, which nothing else suspends: it
|
|
1603
|
+
// keeps its transport, so an app holding `await doc.stream(name)` across
|
|
1604
|
+
// `doc.close()` would go on receiving remote-op callbacks and could still
|
|
1605
|
+
// write through the stream, while `doc.mutate` correctly throws. That is the
|
|
1606
|
+
// cross-session callback a park exists to stop, plus a write path around the
|
|
1607
|
+
// closed check. The default stream's inner IS this document, which owns its
|
|
1608
|
+
// own lifecycle, so `_ownsInner` gates this exactly as it gates the close.
|
|
1609
|
+
_setStreamsSuspended(suspended) {
|
|
1610
|
+
for (const stream of this._openStreams.values()) {
|
|
1611
|
+
if (suspended)
|
|
1612
|
+
stream._suspend();
|
|
1613
|
+
else
|
|
1614
|
+
stream._resume();
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1347
1617
|
// Seal a deliberate checkpoint on this document's version-
|
|
1348
1618
|
// history timeline. Compaction snapshots continue to appear as
|
|
1349
1619
|
// no-action fallback markers, so an app that never calls this still
|
|
@@ -1411,6 +1681,13 @@ export class Document {
|
|
|
1411
1681
|
else
|
|
1412
1682
|
console.error('[Brass]', err);
|
|
1413
1683
|
}
|
|
1684
|
+
/** @internal Report a failure from the SDK's own chrome (the version
|
|
1685
|
+
* bar's intents) through the same sink the built-in commands use, so a
|
|
1686
|
+
* refused checkout reaches the app's `onError` rather than being dropped
|
|
1687
|
+
* on the way back to a surface that only shows the reader a state. */
|
|
1688
|
+
_reportChromeError(err) {
|
|
1689
|
+
this._reportError(err);
|
|
1690
|
+
}
|
|
1414
1691
|
// Fire all `subscribe` listeners with a synthetic event. Used for
|
|
1415
1692
|
// metadata-only changes (checkout, return-to-head, branch switch,
|
|
1416
1693
|
// branches list update, saving count change, commands change)
|
|
@@ -1419,20 +1696,118 @@ export class Document {
|
|
|
1419
1696
|
// call `toJSON()` in the handler; the discriminant is there for
|
|
1420
1697
|
// the rare consumer that wants to branch on the reason.
|
|
1421
1698
|
_fireSubscribers(event) {
|
|
1699
|
+
// Every change to the outstanding-op map reports itself here, which
|
|
1700
|
+
// makes this the one place the unload guard has to hear about un-acked
|
|
1701
|
+
// work (`close()` releases the doc outright).
|
|
1702
|
+
if (event.kind === 'saving-count') {
|
|
1703
|
+
// Own ops rather than `savingCount`: a stream's inner Document registers
|
|
1704
|
+
// itself, so the registry's total already carries one entry per stream.
|
|
1705
|
+
trackUnsavedWork(this, this._outstandingByClientId.size);
|
|
1706
|
+
this._savingCountObserver?.();
|
|
1707
|
+
}
|
|
1422
1708
|
this._ensureLoroSubscribed();
|
|
1423
1709
|
this._subs.emit(event);
|
|
1424
1710
|
}
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1711
|
+
// Told when this Document's outstanding-op count moves, so a parent
|
|
1712
|
+
// Document's `savingCount` covers the streams it has open and its
|
|
1713
|
+
// subscribers fire when a stream's writes land. One observer, because a
|
|
1714
|
+
// stream's inner Document is opened by, and belongs to, exactly one parent.
|
|
1715
|
+
_savingCountObserver = null;
|
|
1716
|
+
/** @internal */
|
|
1717
|
+
_observeSavingCount(observer) {
|
|
1718
|
+
this._savingCountObserver = observer;
|
|
1719
|
+
}
|
|
1720
|
+
// Saving indicator: number of locally-issued ops not yet acked by the
|
|
1721
|
+
// server, across this document's own state and every stream it has open. A
|
|
1722
|
+
// partitioned document keeps its bulk on its streams, so a count that named
|
|
1723
|
+
// the default stream alone reports "All changes saved" over a stream still
|
|
1724
|
+
// writing. Reactive: `subscribe` fires when this count changes (an op is
|
|
1725
|
+
// sent or an `op_ack` lands, on any of them). Apps bind directly inside the
|
|
1726
|
+
// subscribe handler:
|
|
1429
1727
|
//
|
|
1430
1728
|
// doc.subscribe(() => {
|
|
1431
1729
|
// savingEl.textContent =
|
|
1432
1730
|
// doc.savingCount === 0 ? 'All changes saved'
|
|
1433
1731
|
// : `Saving ${doc.savingCount} change${doc.savingCount === 1 ? '' : 's'}...`;
|
|
1434
1732
|
// });
|
|
1733
|
+
//
|
|
1734
|
+
// Only this count aggregates. `readOnly` and `atCapacity` gate `doc.mutate`,
|
|
1735
|
+
// which writes this document's own state, so folding another stream's
|
|
1736
|
+
// refusal into them would close an editor over a stream taking writes;
|
|
1737
|
+
// `stream.readOnly` / `stream.atCapacity` answer for one stream.
|
|
1738
|
+
// Supplied by the doc transport, which holds the sync state this reports.
|
|
1739
|
+
// Absent until a transport is wired (a read-only or detached Document),
|
|
1740
|
+
// where the honest answer is that nothing is outstanding.
|
|
1741
|
+
_diagnosticsProvider = null;
|
|
1742
|
+
/** @internal */
|
|
1743
|
+
_setDiagnosticsProvider(provider) {
|
|
1744
|
+
this._diagnosticsProvider = provider;
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* How this document's sync is going, as the client sees it: how much is
|
|
1748
|
+
* outstanding, how long it has been, over which transport, across how many
|
|
1749
|
+
* reconnects, and under which correlator, which is what separates a
|
|
1750
|
+
* connection that is answering nothing from one that is merely behind.
|
|
1751
|
+
*
|
|
1752
|
+
* @internal Telemetry for Brass's own surfaces. An app reads `savingCount`
|
|
1753
|
+
* to know a save is outstanding, and the SDK resolves a stuck one itself.
|
|
1754
|
+
*/
|
|
1755
|
+
diagnostics() {
|
|
1756
|
+
const own = this._diagnosticsProvider?.() ?? {
|
|
1757
|
+
unackedOps: 0,
|
|
1758
|
+
oldestUnackedMs: 0,
|
|
1759
|
+
oldestClientOpId: null,
|
|
1760
|
+
reconnects: 0,
|
|
1761
|
+
lastAckAgeMs: null,
|
|
1762
|
+
inFlightBytes: 0,
|
|
1763
|
+
httpOpsInFlight: 0,
|
|
1764
|
+
healRounds: 0,
|
|
1765
|
+
hidden: false,
|
|
1766
|
+
};
|
|
1767
|
+
// Cover the streams this document opened, the way `savingCount` does: a
|
|
1768
|
+
// reader watching a save waits on the document, and which line an op was
|
|
1769
|
+
// written to is not something they can act on. The work counters add
|
|
1770
|
+
// up and the waiting ones take the longest wait, so the answer is about
|
|
1771
|
+
// the oldest edit anywhere in the document.
|
|
1772
|
+
//
|
|
1773
|
+
// The connection counters are the parent's alone rather than a fold,
|
|
1774
|
+
// because every line rides one socket, so reconnects and page
|
|
1775
|
+
// visibility are one fact about the client and not one per line.
|
|
1776
|
+
for (const stream of this._openStreams.values()) {
|
|
1777
|
+
const line = stream._inner.diagnostics();
|
|
1778
|
+
own.unackedOps += line.unackedOps;
|
|
1779
|
+
own.inFlightBytes += line.inFlightBytes;
|
|
1780
|
+
own.httpOpsInFlight += line.httpOpsInFlight;
|
|
1781
|
+
own.healRounds = Math.max(own.healRounds, line.healRounds);
|
|
1782
|
+
// Take the stream's op when it waited longer, and also when this
|
|
1783
|
+
// document has none of its own: a strict comparison against a zero
|
|
1784
|
+
// wait leaves the fold reporting outstanding work while naming no op
|
|
1785
|
+
// to go looking for, which is the field the rest hangs off.
|
|
1786
|
+
if (line.oldestClientOpId !== null &&
|
|
1787
|
+
(own.oldestClientOpId === null ||
|
|
1788
|
+
line.oldestUnackedMs > own.oldestUnackedMs)) {
|
|
1789
|
+
own.oldestUnackedMs = line.oldestUnackedMs;
|
|
1790
|
+
own.oldestClientOpId = line.oldestClientOpId;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return own;
|
|
1794
|
+
}
|
|
1435
1795
|
get savingCount() {
|
|
1796
|
+
let total = this._ownSavingCount;
|
|
1797
|
+
// `_openStreams` holds the streams that opened their own line. The stream
|
|
1798
|
+
// this Document IS wraps `this`, and is not in it, so nothing is counted
|
|
1799
|
+
// twice.
|
|
1800
|
+
for (const stream of this._openStreams.values()) {
|
|
1801
|
+
total += stream._inner.savingCount;
|
|
1802
|
+
}
|
|
1803
|
+
return total;
|
|
1804
|
+
}
|
|
1805
|
+
// The ops issued against THIS line, which is what a stream reports for
|
|
1806
|
+
// itself: `doc.stream('default')` wraps this Document, so a default stream
|
|
1807
|
+
// reading the aggregate would report a sibling stream's writes as its own
|
|
1808
|
+
// and stand at "saving" while its own line has nothing outstanding.
|
|
1809
|
+
/** @internal */
|
|
1810
|
+
get _ownSavingCount() {
|
|
1436
1811
|
return this._outstandingByClientId.size;
|
|
1437
1812
|
}
|
|
1438
1813
|
// Internal: forget an outstanding op once the server has acked it.
|
|
@@ -1449,8 +1824,52 @@ export class Document {
|
|
|
1449
1824
|
this._resyncPushClientOpIds.delete(clientOpId);
|
|
1450
1825
|
if (wasTracked)
|
|
1451
1826
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
1827
|
+
// An ack is proof the branch is taking writes again, which is the only
|
|
1828
|
+
// thing that can clear the capacity latch. A full branch refuses every
|
|
1829
|
+
// op, so no ack can arrive while the refusal stands; that is what makes
|
|
1830
|
+
// this evidence rather than a guess, and what keeps it from clearing a
|
|
1831
|
+
// state the next write would re-enter.
|
|
1832
|
+
if (this._atCapacity) {
|
|
1833
|
+
this._atCapacity = false;
|
|
1834
|
+
this._fireSubscribers({ kind: 'capacity' });
|
|
1835
|
+
}
|
|
1452
1836
|
return wasTracked;
|
|
1453
1837
|
}
|
|
1838
|
+
// Internal: turn this Document read-only because the server refused a
|
|
1839
|
+
// write on the reader's authority rather than on the op. It lands on
|
|
1840
|
+
// `readOnly` because that is the gate an app already renders, so a grant
|
|
1841
|
+
// withdrawn mid-session closes the editor with no new signal to learn.
|
|
1842
|
+
// One-way: the socket resolves write authority when it subscribes, so a
|
|
1843
|
+
// grant restored afterwards is refused by the server too until the
|
|
1844
|
+
// connection recycles, and a reopen is what picks the wider grant up.
|
|
1845
|
+
/** @internal */
|
|
1846
|
+
_markReadOnly(reason) {
|
|
1847
|
+
if (this._forcedReadOnly)
|
|
1848
|
+
return;
|
|
1849
|
+
this._forcedReadOnly = true;
|
|
1850
|
+
this._readOnlyReason = reason;
|
|
1851
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
1852
|
+
this._maybeFireUndoState();
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Return this copy to what the platform holds, discarding anything it
|
|
1856
|
+
* refused. A refused change stays drawn on the copy that made it and
|
|
1857
|
+
* nowhere else, and no local operation removes it (a CRDT merges rather
|
|
1858
|
+
* than replaces, and an undo inverts an operation instead of discarding
|
|
1859
|
+
* it), so the only state holding exactly what the platform has is one
|
|
1860
|
+
* built from it.
|
|
1861
|
+
*
|
|
1862
|
+
* The session closes this document and opens the same id again, so a
|
|
1863
|
+
* `session.subscribe` handler rebinds the way it does on any document
|
|
1864
|
+
* switch.
|
|
1865
|
+
*
|
|
1866
|
+
* @internal The SDK drives this itself on the one refusal that reaches it,
|
|
1867
|
+
* telling the reader first, because an app that renders nothing for it is
|
|
1868
|
+
* exactly the case that must not leave someone on a diverged copy.
|
|
1869
|
+
*/
|
|
1870
|
+
reload() {
|
|
1871
|
+
this._markGone('moved');
|
|
1872
|
+
}
|
|
1454
1873
|
// Internal: forget an outstanding op the server TERMINALLY rejected (an
|
|
1455
1874
|
// `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
|
|
1456
1875
|
// server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
|
|
@@ -1459,15 +1878,71 @@ export class Document {
|
|
|
1459
1878
|
// app can react (the optimistic local commit stays applied but never
|
|
1460
1879
|
// persists, so the app's cue is to reload). Returns true if it was tracked.
|
|
1461
1880
|
/** @internal */
|
|
1462
|
-
_failOutstandingOp(clientOpId, message
|
|
1881
|
+
_failOutstandingOp(clientOpId, message,
|
|
1882
|
+
// Whether the app hears about this one. A refusal that describes the
|
|
1883
|
+
// DOCUMENT answers every op in flight at once, so reporting per op tells
|
|
1884
|
+
// the reader the same thing as many times as they happened to have
|
|
1885
|
+
// unacked writes, which on a bulk write is dozens. The caller reports it
|
|
1886
|
+
// once instead; the op still leaves the tracker either way, because
|
|
1887
|
+
// `savingCount` has to fall whether or not anyone is told.
|
|
1888
|
+
opts = {}) {
|
|
1463
1889
|
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
1464
1890
|
this._resyncPushClientOpIds.delete(clientOpId);
|
|
1465
1891
|
if (wasTracked) {
|
|
1466
1892
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
1467
|
-
|
|
1893
|
+
if (opts.report !== false)
|
|
1894
|
+
this._reportError(new Error(message));
|
|
1468
1895
|
}
|
|
1469
1896
|
return wasTracked;
|
|
1470
1897
|
}
|
|
1898
|
+
// Internal: whether this op is one the durability handshake pushed, rather
|
|
1899
|
+
// than one the app wrote. The transport reads it to tell a refusal the heal
|
|
1900
|
+
// itself provoked from one that arrived on its own, which is what keeps a
|
|
1901
|
+
// refused heal chunk from re-arming the heal that sent it.
|
|
1902
|
+
/** @internal */
|
|
1903
|
+
_isResyncPushOp(clientOpId) {
|
|
1904
|
+
return this._resyncPushClientOpIds.has(clientOpId);
|
|
1905
|
+
}
|
|
1906
|
+
_atCapacity = false;
|
|
1907
|
+
/**
|
|
1908
|
+
* Whether the document has stopped accepting new changes because it has
|
|
1909
|
+
* reached the largest size the platform can keep.
|
|
1910
|
+
*
|
|
1911
|
+
* Read from the server's refusal of a write rather than predicted locally,
|
|
1912
|
+
* so it turns true at the first edit that does not land, and back to false
|
|
1913
|
+
* on the first edit that does. Both directions are evidence: a full branch
|
|
1914
|
+
* refuses every op, so an ack cannot arrive while the refusal stands.
|
|
1915
|
+
*
|
|
1916
|
+
* Edits keep applying to the local copy while it is true, which is what
|
|
1917
|
+
* lets the app show the reader what they did alongside the fact that it is
|
|
1918
|
+
* not saved; the copy is discarded on reload.
|
|
1919
|
+
*
|
|
1920
|
+
* An app reads this to stop offering work that would only be refused (a
|
|
1921
|
+
* bulk import, a generate pass) and to say so once, instead of surfacing one
|
|
1922
|
+
* error per rejected op.
|
|
1923
|
+
*/
|
|
1924
|
+
get atCapacity() {
|
|
1925
|
+
return this._atCapacity;
|
|
1926
|
+
}
|
|
1927
|
+
// Internal: record the capacity verdict from a server refusal. Idempotent.
|
|
1928
|
+
// Cleared again by `resolveOutstandingOp`, so a branch whose compaction
|
|
1929
|
+
// seals it back under the ceiling reaches the open reader through the next
|
|
1930
|
+
// write that lands rather than only through a reload.
|
|
1931
|
+
/**
|
|
1932
|
+
* Returns whether this call is the one that flipped it, which is what lets
|
|
1933
|
+
* the transport report the refusal exactly once. Every op in flight draws
|
|
1934
|
+
* the same refusal, and a bulk write has as many of those as it has unacked
|
|
1935
|
+
* writes, so the flip is the only moment worth telling the app about.
|
|
1936
|
+
*
|
|
1937
|
+
* @internal
|
|
1938
|
+
*/
|
|
1939
|
+
_markAtCapacity() {
|
|
1940
|
+
if (this._atCapacity)
|
|
1941
|
+
return false;
|
|
1942
|
+
this._atCapacity = true;
|
|
1943
|
+
this._fireSubscribers({ kind: 'capacity' });
|
|
1944
|
+
return true;
|
|
1945
|
+
}
|
|
1471
1946
|
_ensureLocalUpdatesSubscribed() {
|
|
1472
1947
|
if (this._unsubLocal)
|
|
1473
1948
|
return;
|
|
@@ -1487,15 +1962,46 @@ export class Document {
|
|
|
1487
1962
|
// actually flushes (the WS client may queue the frame).
|
|
1488
1963
|
const copy = new Uint8Array(bytes.byteLength);
|
|
1489
1964
|
copy.set(bytes);
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1965
|
+
const chunks = this.splitLocalUpdate(copy);
|
|
1966
|
+
if (chunks === null) {
|
|
1967
|
+
this._shipLocalOp(copy);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
// In order: each chunk continues the one before it, so a peer applying
|
|
1971
|
+
// them out of sequence holds the tail unapplied until the gap closes.
|
|
1972
|
+
for (const chunk of chunks)
|
|
1973
|
+
this._shipLocalOp(chunk);
|
|
1497
1974
|
});
|
|
1498
1975
|
}
|
|
1976
|
+
// Hand one op blob to the transport and count it as unsaved, or keep it
|
|
1977
|
+
// local when the server has evicted this document.
|
|
1978
|
+
//
|
|
1979
|
+
// A gone document still takes edits by design: it stays open so the app can
|
|
1980
|
+
// render its own "no longer available" state, and the person may keep
|
|
1981
|
+
// interacting until they navigate. Those edits have nowhere to land. The
|
|
1982
|
+
// transport dropped its retry machinery on `doc_gone`, and the frame would
|
|
1983
|
+
// ship onto a subscription the server evicted and come back as a `forbidden`
|
|
1984
|
+
// that describes nothing the app did. Counting them is the part that shows:
|
|
1985
|
+
// each one adds to `savingCount` for a save that cannot happen, so the doc
|
|
1986
|
+
// reports "no longer available" and "Saving 3 changes..." together and
|
|
1987
|
+
// re-arms the leave-site prompt `_markGone` just released.
|
|
1988
|
+
//
|
|
1989
|
+
// Returns the minted `client_op_id`, or null when the op stayed local.
|
|
1990
|
+
_shipLocalOp(copy) {
|
|
1991
|
+
const hook = this._localOpHook;
|
|
1992
|
+
if (!hook)
|
|
1993
|
+
return null;
|
|
1994
|
+
if (this._gone.reason !== null)
|
|
1995
|
+
return null;
|
|
1996
|
+
const clientOpId = hook(copy);
|
|
1997
|
+
this._outstandingByClientId.set(clientOpId, copy);
|
|
1998
|
+
// The local mutate has already fired `subscribe` (the Loro
|
|
1999
|
+
// event), but `savingCount` just incremented; fire one more
|
|
2000
|
+
// synthetic event so a subscribe handler bound on `savingCount`
|
|
2001
|
+
// sees the change.
|
|
2002
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
2003
|
+
return clientOpId;
|
|
2004
|
+
}
|
|
1499
2005
|
// Lazy-attach the underlying LoroDoc subscriber. **Do not** wire this
|
|
1500
2006
|
// in the constructor or before the initial snapshot import: with a
|
|
1501
2007
|
// subscriber attached, snapshot-import of a 50k doc takes ~8.5 s
|
|
@@ -1518,7 +2024,17 @@ export class Document {
|
|
|
1518
2024
|
// One channel: every event reaches every subscriber, whatever
|
|
1519
2025
|
// produced it. The app reconciles by value and reads the cause off
|
|
1520
2026
|
// the event only when it has a non-render reason to.
|
|
1521
|
-
|
|
2027
|
+
//
|
|
2028
|
+
// What reaches a subscriber is this SDK's own `{ kind, source }`, not
|
|
2029
|
+
// the engine's event object. An app that receives the engine's shape
|
|
2030
|
+
// can read its internals (container ids, peer ids, the raw diff) and
|
|
2031
|
+
// build on them, which pins the storage engine through a surface that
|
|
2032
|
+
// never promised it. The cause survives as `source`, the same
|
|
2033
|
+
// engine-neutral vocabulary the handle surface reports.
|
|
2034
|
+
this._subs.emit({
|
|
2035
|
+
kind: 'change',
|
|
2036
|
+
source: changeSourceOf(event),
|
|
2037
|
+
});
|
|
1522
2038
|
const isLocalCommit = event.by === 'local';
|
|
1523
2039
|
this._maybeFireUndoState();
|
|
1524
2040
|
// A local commit (mutate / undo / redo) schedules a debounced
|
|
@@ -1541,12 +2057,12 @@ export class Document {
|
|
|
1541
2057
|
// construction stays cheap.
|
|
1542
2058
|
//
|
|
1543
2059
|
// `cb` fires for EVERY change, whatever produced it: a local `mutate()`,
|
|
1544
|
-
// a remote
|
|
1545
|
-
//
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
2060
|
+
// a remote op, `undo`, `redo`, a history move, and the SDK's own metadata
|
|
2061
|
+
// events (saving-count, branches, commands). One channel; the app re-reads
|
|
2062
|
+
// / reconciles in the handler and branches on `event.kind` only when it
|
|
2063
|
+
// needs the cause. A content change arrives as
|
|
2064
|
+
// `{ kind: 'change', source }`, carrying the same engine-neutral `source`
|
|
2065
|
+
// the handle surface reports.
|
|
1550
2066
|
subscribe(cb) {
|
|
1551
2067
|
this._assertOpen();
|
|
1552
2068
|
this._ensureLoroSubscribed();
|
|
@@ -1632,10 +2148,10 @@ export class Document {
|
|
|
1632
2148
|
// The Document is NOT auto-closed; callers may want to render a
|
|
1633
2149
|
// "the document was deleted" overlay before closing. Disable any
|
|
1634
2150
|
// pending mutations in the listener and call `close()` when ready.
|
|
1635
|
-
// Mutations after `_markGone` still mutate the local CRDT and
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
2151
|
+
// Mutations after `_markGone` still mutate the local CRDT, and stay
|
|
2152
|
+
// local: `_shipLocalOp` keeps them off the transport and out of
|
|
2153
|
+
// `savingCount`, so this one notice is what the app hears about the
|
|
2154
|
+
// eviction rather than a refusal per later edit.
|
|
1639
2155
|
onGone(cb) {
|
|
1640
2156
|
// Guard like `subscribe` / `subscribeUndoState`: `_suspend` clears these
|
|
1641
2157
|
// signals' listeners so a stale handle from a parked session does not
|
|
@@ -1676,11 +2192,20 @@ export class Document {
|
|
|
1676
2192
|
// false`) is retained so a late `onStatus` subscriber still sees the
|
|
1677
2193
|
// final state.
|
|
1678
2194
|
/** @internal */
|
|
1679
|
-
_emitStatus(status) {
|
|
2195
|
+
_emitStatus(status, opts) {
|
|
1680
2196
|
// A late reload/reconcile closure that resolved after `close()` must not
|
|
1681
2197
|
// re-emit onto a torn-down doc (its undo stack is freed below in `close`).
|
|
1682
2198
|
if (this._closed)
|
|
1683
2199
|
return;
|
|
2200
|
+
// Terminal work that still reports itself as locking editing is inbound
|
|
2201
|
+
// work that ended without content: the platform clears the status when
|
|
2202
|
+
// hydration / extraction succeeds, so what it reports terminally is a
|
|
2203
|
+
// failure. Anything else means the doc is either loading or holds what
|
|
2204
|
+
// it has.
|
|
2205
|
+
this._loadFailed =
|
|
2206
|
+
opts?.contentLoaded !== true &&
|
|
2207
|
+
status.pending === false &&
|
|
2208
|
+
status.blocking === true;
|
|
1684
2209
|
this._workStatus.emit(status);
|
|
1685
2210
|
// A blocking work-status is one of the inputs to `canUndo` / `canRedo`
|
|
1686
2211
|
// (via `_isLoadLocked`), so a status that engages or releases the load
|
|
@@ -1690,10 +2215,10 @@ export class Document {
|
|
|
1690
2215
|
this._maybeFireUndoState();
|
|
1691
2216
|
}
|
|
1692
2217
|
// The current drive sync state of this document, or `null` when the
|
|
1693
|
-
// doc is not linked to a drive source.
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
2218
|
+
// doc is not linked to a drive source. An app reads `state` / `canPush`
|
|
2219
|
+
// to tell the reader they have unpublished edits, and renders the
|
|
2220
|
+
// server-authored `label` / `detail` as the wording; publishing itself
|
|
2221
|
+
// is the hosted toolbar's control. Seeded on open
|
|
1697
2222
|
// (for a drive-linked doc) and kept live as edits, refreshes, and
|
|
1698
2223
|
// pushes move the state.
|
|
1699
2224
|
get driveStatus() {
|
|
@@ -1740,11 +2265,27 @@ export class Document {
|
|
|
1740
2265
|
}
|
|
1741
2266
|
// Plain-JS projection of the current state.
|
|
1742
2267
|
toJSON() {
|
|
2268
|
+
this._assertNotClosed();
|
|
1743
2269
|
// When in checkout, read from the preview LoroDoc so the app
|
|
1744
2270
|
// renders the historical state. Returning to head clears
|
|
1745
2271
|
// `_checkout` and reads go back to the live `_doc`.
|
|
1746
2272
|
const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
1747
|
-
|
|
2273
|
+
const json = source.toJSON();
|
|
2274
|
+
// A paged root holds its records inside page entries beside the store's
|
|
2275
|
+
// own edit bookkeeping, so flatten it to the same record lookup
|
|
2276
|
+
// `doc.map(root).read()` hands back: a serializer and a whole-document
|
|
2277
|
+
// renderer read this surface, and a store that crossed the paging
|
|
2278
|
+
// threshold has to reach them as records. Every root is asked, because a
|
|
2279
|
+
// root no handle was ever taken on is paged by the same rule as one that
|
|
2280
|
+
// was; a root the marker does not name comes back as loro produced it.
|
|
2281
|
+
for (const [rootKey, value] of Object.entries(json)) {
|
|
2282
|
+
if (!isRootLookup(value))
|
|
2283
|
+
continue;
|
|
2284
|
+
if (!readRootPaged(source, rootKey))
|
|
2285
|
+
continue;
|
|
2286
|
+
json[rootKey] = flattenPagedLookup(value, true);
|
|
2287
|
+
}
|
|
2288
|
+
return json;
|
|
1748
2289
|
}
|
|
1749
2290
|
/**
|
|
1750
2291
|
* Snapshot bytes; inverse of `openFromSnapshot`, for round-trip tests.
|
|
@@ -1761,27 +2302,35 @@ export class Document {
|
|
|
1761
2302
|
// in either caller. No-op when the durable frontier already covers the
|
|
1762
2303
|
// local doc, while a prior resync push is still unacked, or when the
|
|
1763
2304
|
// missing slice is too large to ship as one op (see the cap below).
|
|
2305
|
+
//
|
|
2306
|
+
// The return value says whether the caller may stop asking. `covered`
|
|
2307
|
+
// means the durable frontier holds everything local does, so the
|
|
2308
|
+
// handshake is finished; every other outcome leaves the doc diverged and
|
|
2309
|
+
// is the caller's cue to run the handshake again once the push settles.
|
|
1764
2310
|
/** @internal */
|
|
1765
2311
|
maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
|
|
1766
2312
|
// `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
|
|
1767
2313
|
// closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
|
|
1768
2314
|
// closure can't touch freed WASM.
|
|
1769
2315
|
if (this._closed)
|
|
1770
|
-
return;
|
|
2316
|
+
return 'closed';
|
|
1771
2317
|
// Loop guard: skip while any chunk of a prior push is still unacked,
|
|
1772
2318
|
// so a flurry of reconnects cannot re-push the same missing slice.
|
|
1773
2319
|
for (const id of this._resyncPushClientOpIds) {
|
|
1774
2320
|
if (this._outstandingByClientId.has(id))
|
|
1775
|
-
return;
|
|
2321
|
+
return 'in-flight';
|
|
1776
2322
|
}
|
|
1777
2323
|
const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs);
|
|
1778
|
-
if (chunks ===
|
|
1779
|
-
return;
|
|
2324
|
+
if (chunks === 'covered')
|
|
2325
|
+
return 'covered';
|
|
2326
|
+
if (chunks === 'undecodable')
|
|
2327
|
+
return 'undecodable';
|
|
1780
2328
|
for (const chunk of chunks) {
|
|
1781
2329
|
const id = this.injectLocalUpdate(chunk);
|
|
1782
2330
|
if (id !== null)
|
|
1783
2331
|
this._resyncPushClientOpIds.add(id);
|
|
1784
2332
|
}
|
|
2333
|
+
return 'pushed';
|
|
1785
2334
|
}
|
|
1786
2335
|
// Given the server's persisted snapshot version vector and the op
|
|
1787
2336
|
// blobs the server is also serving on the join tail, decide whether
|
|
@@ -1798,10 +2347,13 @@ export class Document {
|
|
|
1798
2347
|
// case. `decodeImportBlobMeta` reads each blob's end vector from its
|
|
1799
2348
|
// header without a full import, so this stays cheap.
|
|
1800
2349
|
//
|
|
1801
|
-
// Returns the chunks to resync-push,
|
|
1802
|
-
// already
|
|
1803
|
-
// blob, which degrades the handshake to a no-op and warns
|
|
1804
|
-
// throwing out of the frame handler that drives the live
|
|
2350
|
+
// Returns the chunks to resync-push, `'covered'` when the durable state
|
|
2351
|
+
// already holds everything local does, or `'undecodable'` for a malformed
|
|
2352
|
+
// vector / tail blob, which degrades the handshake to a no-op and warns
|
|
2353
|
+
// rather than throwing out of the frame handler that drives the live
|
|
2354
|
+
// transport. `'undecodable'` is distinct from `'covered'` because it says
|
|
2355
|
+
// nothing about whether the doc has diverged, so a caller healing a known
|
|
2356
|
+
// divergence must keep asking.
|
|
1805
2357
|
//
|
|
1806
2358
|
// The common case is one chunk: an `update` export from the durable
|
|
1807
2359
|
// frontier carries only the missing ops plus their dependencies, never
|
|
@@ -1813,6 +2365,44 @@ export class Document {
|
|
|
1813
2365
|
// large divergence heals instead of being skipped. Import is idempotent
|
|
1814
2366
|
// and order-independent (Loro buffers a chunk whose dependency is in a
|
|
1815
2367
|
// sibling chunk until it arrives), so the chunks need no ordering.
|
|
2368
|
+
/**
|
|
2369
|
+
* Whether the durable snapshot holds changes by an author this doc has never
|
|
2370
|
+
* heard from.
|
|
2371
|
+
*
|
|
2372
|
+
* Every author a client can hear from reaches it as ops, so a peer present
|
|
2373
|
+
* in the durable frontier and absent from the local doc after the tail has
|
|
2374
|
+
* applied is one whose changes exist only inside the snapshot. That is the
|
|
2375
|
+
* platform having rewritten how the document stores what it stores
|
|
2376
|
+
* (compaction's patch fold is the one thing that does this), which a client
|
|
2377
|
+
* cannot merge with and cannot undo across: its undo stack inverts the
|
|
2378
|
+
* operations it made, and the values those operations wrote now live
|
|
2379
|
+
* somewhere else.
|
|
2380
|
+
*
|
|
2381
|
+
* The answer drives a re-import, which is the existing recovery for exactly
|
|
2382
|
+
* this shape (`_applySnapshot` drops both stacks, so undo reports itself
|
|
2383
|
+
* unavailable instead of silently reverting nothing).
|
|
2384
|
+
*
|
|
2385
|
+
* @internal
|
|
2386
|
+
*/
|
|
2387
|
+
_snapshotHasUnseenAuthor(persistedSnapshotVersionVector) {
|
|
2388
|
+
if (this._closed)
|
|
2389
|
+
return false;
|
|
2390
|
+
let durable;
|
|
2391
|
+
try {
|
|
2392
|
+
durable = VersionVector.decode(persistedSnapshotVersionVector);
|
|
2393
|
+
}
|
|
2394
|
+
catch {
|
|
2395
|
+
// Undecodable says nothing, and the durability handshake already warns
|
|
2396
|
+
// about it on the same frame.
|
|
2397
|
+
return false;
|
|
2398
|
+
}
|
|
2399
|
+
const local = this._doc.version().toJSON();
|
|
2400
|
+
for (const [peer, counter] of durable.toJSON()) {
|
|
2401
|
+
if (counter > 0 && !local.has(peer))
|
|
2402
|
+
return true;
|
|
2403
|
+
}
|
|
2404
|
+
return false;
|
|
2405
|
+
}
|
|
1816
2406
|
/** @internal */
|
|
1817
2407
|
computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
|
|
1818
2408
|
let durable;
|
|
@@ -1825,7 +2415,7 @@ export class Document {
|
|
|
1825
2415
|
}
|
|
1826
2416
|
catch (err) {
|
|
1827
2417
|
console.warn('[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob', err);
|
|
1828
|
-
return
|
|
2418
|
+
return 'undecodable';
|
|
1829
2419
|
}
|
|
1830
2420
|
const local = this._doc.version();
|
|
1831
2421
|
const cmp = local.compare(durable);
|
|
@@ -1835,7 +2425,7 @@ export class Document {
|
|
|
1835
2425
|
// lacks AND vice versa). Push in the ahead and concurrent cases; the
|
|
1836
2426
|
// export from `durable` carries only the local-only ops either way.
|
|
1837
2427
|
if (cmp !== undefined && cmp <= 0)
|
|
1838
|
-
return
|
|
2428
|
+
return 'covered';
|
|
1839
2429
|
// Common case: the whole missing slice fits in one frame. Keep this
|
|
1840
2430
|
// path byte-identical to a plain `update` export so the no-chunking
|
|
1841
2431
|
// case is unchanged.
|
|
@@ -1860,35 +2450,96 @@ export class Document {
|
|
|
1860
2450
|
return ranges;
|
|
1861
2451
|
}
|
|
1862
2452
|
// Recursively export the given ranges as one `updates-in-range` blob,
|
|
1863
|
-
// splitting by op count until each blob is under the per-
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2453
|
+
// splitting by op count until each blob is under the per-chunk cap.
|
|
2454
|
+
//
|
|
2455
|
+
// `dropOversizedSingle` decides what happens to an irreducible op (one that
|
|
2456
|
+
// alone exceeds the cap and the hard ceiling). A resync drops it with a
|
|
2457
|
+
// warning so the loop never wedges on an un-sendable frame while the rest
|
|
2458
|
+
// of the slice heals. A local commit keeps it: it is an edit the app just
|
|
2459
|
+
// made and nothing else will carry it, so it goes to the write path, which
|
|
2460
|
+
// reports the refusal to the app rather than losing it silently.
|
|
2461
|
+
chunkRanges(ranges, dropOversizedSingle) {
|
|
2462
|
+
const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
|
|
2463
|
+
if (total === 0)
|
|
2464
|
+
return [];
|
|
2465
|
+
const blob = this._doc.export({
|
|
2466
|
+
mode: 'updates-in-range',
|
|
2467
|
+
spans: ranges.map((r) => ({
|
|
2468
|
+
id: { peer: r.peer, counter: r.from },
|
|
2469
|
+
len: r.to - r.from,
|
|
2470
|
+
})),
|
|
2471
|
+
});
|
|
2472
|
+
if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
|
|
2473
|
+
return [blob];
|
|
2474
|
+
if (total === 1) {
|
|
2475
|
+
// A single op that cannot be split further.
|
|
2476
|
+
if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES)
|
|
2477
|
+
return [blob];
|
|
2478
|
+
if (!dropOversizedSingle)
|
|
1880
2479
|
return [blob];
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2480
|
+
console.warn(`[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`);
|
|
2481
|
+
return [];
|
|
2482
|
+
}
|
|
2483
|
+
const [first, second] = splitRangesByLength(ranges, total);
|
|
2484
|
+
return [
|
|
2485
|
+
...this.chunkRanges(first, dropOversizedSingle),
|
|
2486
|
+
...this.chunkRanges(second, dropOversizedSingle),
|
|
2487
|
+
];
|
|
2488
|
+
}
|
|
2489
|
+
chunkMissingSlice(local, durable) {
|
|
2490
|
+
return this.chunkRanges(this.missingRanges(local, durable), true);
|
|
2491
|
+
}
|
|
2492
|
+
// A local commit re-exported as several op blobs, or null to ship it whole.
|
|
2493
|
+
//
|
|
2494
|
+
// `mutate` bounds what one commit carries by committing as it goes, but a
|
|
2495
|
+
// commit Loro makes for itself takes no such budget: `redo` replays a whole
|
|
2496
|
+
// undo step, so a bulk write that correctly split into many ops comes back
|
|
2497
|
+
// as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
|
|
2498
|
+
// is refused outright and the redone edit never persists, and below it, it
|
|
2499
|
+
// still lands as a single large row on the document's one storage partition.
|
|
2500
|
+
//
|
|
2501
|
+
// The ops already exist, so the only lever left is which spans they ship in.
|
|
2502
|
+
// The blob names the counter range it covers, and re-exporting a sub-range
|
|
2503
|
+
// is what the durability resync already does, so the chunks are ordinary ops
|
|
2504
|
+
// a peer applies in sequence.
|
|
2505
|
+
//
|
|
2506
|
+
// Splitting is abandoned unless EVERY chunk comes in under the cap. A commit
|
|
2507
|
+
// holding one value nothing can split (a mesh, a large embedded body) would
|
|
2508
|
+
// otherwise ship as a small chunk that lands and an irreducible one that the
|
|
2509
|
+
// write path refuses, which puts a record on the server missing the field
|
|
2510
|
+
// that made it large and leaves the two copies permanently apart. Whole, its
|
|
2511
|
+
// refusal covers the whole edit and the app is told once.
|
|
2512
|
+
splitLocalUpdate(blob) {
|
|
2513
|
+
if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
|
|
2514
|
+
return null;
|
|
2515
|
+
let ranges;
|
|
2516
|
+
try {
|
|
2517
|
+
const meta = decodeImportBlobMeta(blob, false);
|
|
2518
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
2519
|
+
ranges = [];
|
|
2520
|
+
for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
|
|
2521
|
+
const from = start.get(peer) ?? 0;
|
|
2522
|
+
if (to > from)
|
|
2523
|
+
ranges.push({ peer, from, to });
|
|
1887
2524
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
2525
|
+
}
|
|
2526
|
+
catch {
|
|
2527
|
+
// An undecodable blob is one this cannot describe in spans. Shipping it
|
|
2528
|
+
// whole is what happens without this path at all.
|
|
2529
|
+
return null;
|
|
2530
|
+
}
|
|
2531
|
+
const total = rangeOpCount(ranges);
|
|
2532
|
+
if (total < 2)
|
|
2533
|
+
return null;
|
|
2534
|
+
// The blob's own size names how many pieces to ask for, so the split does
|
|
2535
|
+
// not rediscover that by halving.
|
|
2536
|
+
const parts = Math.min(total, Math.ceil(blob.byteLength / RESYNC_PUSH_MAX_BYTES));
|
|
2537
|
+
const chunks = splitRangesEvenly(ranges, parts).flatMap((piece) => this.chunkRanges(piece, false));
|
|
2538
|
+
if (chunks.length < 2)
|
|
2539
|
+
return null;
|
|
2540
|
+
if (chunks.some((c) => c.byteLength > RESYNC_PUSH_MAX_BYTES))
|
|
2541
|
+
return null;
|
|
2542
|
+
return chunks;
|
|
1892
2543
|
}
|
|
1893
2544
|
// Feed an op blob into the local-op send path as if it were a fresh
|
|
1894
2545
|
// local commit: ship it through the transport's send hook (which mints
|
|
@@ -1901,15 +2552,9 @@ export class Document {
|
|
|
1901
2552
|
/** @internal */
|
|
1902
2553
|
injectLocalUpdate(blob) {
|
|
1903
2554
|
this._ensureLocalUpdatesSubscribed();
|
|
1904
|
-
const hook = this._localOpHook;
|
|
1905
|
-
if (!hook)
|
|
1906
|
-
return null;
|
|
1907
2555
|
const copy = new Uint8Array(blob.byteLength);
|
|
1908
2556
|
copy.set(blob);
|
|
1909
|
-
|
|
1910
|
-
this._outstandingByClientId.set(clientOpId, copy);
|
|
1911
|
-
this._fireSubscribers({ kind: 'saving-count' });
|
|
1912
|
-
return clientOpId;
|
|
2557
|
+
return this._shipLocalOp(copy);
|
|
1913
2558
|
}
|
|
1914
2559
|
// Internal: park the Document in the SDK cache. Clears app-side
|
|
1915
2560
|
// listeners so a stale handle from this session does not receive
|
|
@@ -1924,6 +2569,7 @@ export class Document {
|
|
|
1924
2569
|
if (this._closed || this._suspended)
|
|
1925
2570
|
return;
|
|
1926
2571
|
this._suspended = true;
|
|
2572
|
+
this._setStreamsSuspended(true);
|
|
1927
2573
|
// A pending auto-checkpoint would otherwise fire against a parked
|
|
1928
2574
|
// Document and silently no-op via the scheduler's guard.
|
|
1929
2575
|
// Cancelling here keeps the timer count clean across long-lived
|
|
@@ -1965,6 +2611,7 @@ export class Document {
|
|
|
1965
2611
|
/** @internal */
|
|
1966
2612
|
_resume() {
|
|
1967
2613
|
this._suspended = false;
|
|
2614
|
+
this._setStreamsSuspended(false);
|
|
1968
2615
|
// Pair of the `_suspend` presence gate: reopen the presence controller's
|
|
1969
2616
|
// outbound path and re-advertise this client's cursor on the resumed open.
|
|
1970
2617
|
// `_suspend` only closed the gate; without this the parked doc's presence
|
|
@@ -2004,6 +2651,7 @@ export class Document {
|
|
|
2004
2651
|
this._currentSeqProvider = null;
|
|
2005
2652
|
this._outstandingByClientId.clear();
|
|
2006
2653
|
this._resyncPushClientOpIds.clear();
|
|
2654
|
+
releaseUnsavedWork(this);
|
|
2007
2655
|
this._subs.clear();
|
|
2008
2656
|
this._undoStateSubs.clear();
|
|
2009
2657
|
// Drop the signal listeners too, mirroring `_suspend()`. Their frames
|