@brass-build/client 0.4.1 → 0.5.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 +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1022 -112
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/dist/documents.js
CHANGED
|
@@ -8,20 +8,32 @@
|
|
|
8
8
|
// 2. The `getOwnPropertyDescriptor` trap participates in
|
|
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
|
+
import { INPUTS_READ_ROOT, INPUTS_REFS_ROOT } from './inputs.js';
|
|
12
|
+
import { importTailOp } from './tail-op-import.js';
|
|
13
|
+
import { InputStatusHub, } from './inputs-status.js';
|
|
14
|
+
import { InputFollowers, } from './input-documents.js';
|
|
11
15
|
import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from './wasm-runtime.js';
|
|
12
|
-
import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
|
|
16
|
+
import { NON_UNDOABLE_COMMIT_ORIGIN, PAGING_COMMIT_ORIGIN, UndoController, UndoGroup, } from './undo.js';
|
|
17
|
+
import { joinStampOf } from './join-stamp.js';
|
|
18
|
+
import { decodeInputsRoots, inputReadClearWrites, inputReadMarkFor, inputReadMarkWrites, inputRemovalMark, inputRefsWrites, inputStampFromUpdatedAt, isInputDocId, isInputKey, parseInputReference, } from './inputs.js';
|
|
19
|
+
import { ProvisionalInputsTracker, isReservedRootName, latestReadMark, } from './references.js';
|
|
13
20
|
import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
|
|
14
|
-
import {
|
|
21
|
+
import { appCallbackError } from './subscriber-delivery.js';
|
|
22
|
+
import { flattenPagedLookup, isPagedMarker, isPagedMarkerEntry, META_KEY, pagedRootKey, RECORDS_KEY, rootCarriesMarkers, } from './record-paging.js';
|
|
15
23
|
import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
|
|
16
24
|
import { GoneSignal, WorkStatusSignal, DriveStatusSignal, DrivePublishTargetSignal, } from './document-signals.js';
|
|
17
25
|
import { ThumbnailCapture, } from './document-thumbnail-capture.js';
|
|
18
26
|
import { CheckpointScheduler, } from './document-checkpoints.js';
|
|
19
27
|
import { whenSaved } from './when-saved.js';
|
|
28
|
+
import { BrassError } from './errors.js';
|
|
20
29
|
import { BranchController, } from './document-branches.js';
|
|
21
30
|
import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
|
|
22
31
|
import { CommandMenu, } from './document-commands.js';
|
|
23
32
|
import { DocumentStream } from './document-streams.js';
|
|
24
33
|
import { createEmitter } from './emitter.js';
|
|
34
|
+
import { CONVERSION_PEER_ID, SealedContent, } from './sealed-store.js';
|
|
35
|
+
import { sealedRoots } from './sealed-handles.js';
|
|
36
|
+
import { StableHandles } from './stable-handles.js';
|
|
25
37
|
// The stream name `doc.stream()` resolves to the document's own default
|
|
26
38
|
// state; mirrors the server's reserved name.
|
|
27
39
|
export const DEFAULT_STREAM_NAME = 'default';
|
|
@@ -42,6 +54,24 @@ function rootKindOf(containerId) {
|
|
|
42
54
|
}
|
|
43
55
|
// Maps count entries, lists and text count length. Both are the container's
|
|
44
56
|
// own counter rather than a projection of what it holds.
|
|
57
|
+
/** Whether a root map holds nothing but paged markers, which its handle hides,
|
|
58
|
+
* so the collection reads as empty. */
|
|
59
|
+
function holdsOnlyMarkers(rootKey, container) {
|
|
60
|
+
if (!rootCarriesMarkers(rootKey) || !(container instanceof LoroMap))
|
|
61
|
+
return false;
|
|
62
|
+
return container.keys().every((key) => isPagedMarkerEntry(rootKey, key));
|
|
63
|
+
}
|
|
64
|
+
// A root's projected value when the collection holds nothing: a map, list
|
|
65
|
+
// or text with no content.
|
|
66
|
+
function projectsEmpty(value) {
|
|
67
|
+
if (value === '')
|
|
68
|
+
return true;
|
|
69
|
+
if (Array.isArray(value))
|
|
70
|
+
return value.length === 0;
|
|
71
|
+
if (value !== null && typeof value === 'object')
|
|
72
|
+
return Object.keys(value).length === 0;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
45
75
|
function rootIsEmpty(container) {
|
|
46
76
|
const sized = container;
|
|
47
77
|
if (typeof sized.size === 'number')
|
|
@@ -65,6 +95,25 @@ const REDO_COMMIT_ORIGIN = 'brass.redo';
|
|
|
65
95
|
// this (it reconciles by value); it backs a change's `origin.source` for
|
|
66
96
|
// attribution and the SDK's own local-vs-remote side-effect gating
|
|
67
97
|
// (thumbnail capture, auto-checkpoint).
|
|
98
|
+
// A reserved root map as plain entries, the view the write rules diff against.
|
|
99
|
+
function rootView(map) {
|
|
100
|
+
return map.toJSON();
|
|
101
|
+
}
|
|
102
|
+
// Applies one reserved root's writes per entry. Returns whether anything moved.
|
|
103
|
+
function applyRootWrites(map, writes) {
|
|
104
|
+
for (const key of writes.delete)
|
|
105
|
+
map.delete(key);
|
|
106
|
+
for (const [key, value] of Object.entries(writes.set))
|
|
107
|
+
map.set(key, value);
|
|
108
|
+
return writes.delete.length > 0 || Object.keys(writes.set).length > 0;
|
|
109
|
+
}
|
|
110
|
+
// The reserved roots hold the document's references and read marks, which
|
|
111
|
+
// only `dependsOn` and `markRead` write.
|
|
112
|
+
function assertAppRootName(key) {
|
|
113
|
+
if (isReservedRootName(key)) {
|
|
114
|
+
throw new TypeError(`"${key}" is reserved for Brass; choose another collection name`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
68
117
|
function changeSourceOf(event) {
|
|
69
118
|
const by = event.by;
|
|
70
119
|
if (by === 'import')
|
|
@@ -125,19 +174,71 @@ const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
|
|
|
125
174
|
// the staged upload rather than a request body, a path the write already
|
|
126
175
|
// takes on its own size.
|
|
127
176
|
const RESYNC_PUSH_HARD_MAX_BYTES = Math.floor((MAX_OP_BLOB_CHARS * 3) / 4);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
177
|
+
function holdRange(held, peer, from, to) {
|
|
178
|
+
if (to <= from)
|
|
179
|
+
return;
|
|
180
|
+
const merged = [];
|
|
181
|
+
let next = [from, to];
|
|
182
|
+
for (const range of held.get(peer) ?? []) {
|
|
183
|
+
if (range[1] < next[0] || range[0] > next[1]) {
|
|
184
|
+
merged.push(range);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
next = [Math.min(range[0], next[0]), Math.max(range[1], next[1])];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
merged.push(next);
|
|
191
|
+
merged.sort((a, b) => a[0] - b[0]);
|
|
192
|
+
held.set(peer, merged);
|
|
193
|
+
}
|
|
194
|
+
// What the durable state holds: the snapshot, which is causally complete so
|
|
195
|
+
// every author's ops from zero, plus each tail blob's own per-author range.
|
|
196
|
+
// Throws on a malformed vector or blob.
|
|
197
|
+
function durableHoldings(persistedSnapshotVersionVector, durableTailBlobs) {
|
|
198
|
+
const held = new Map();
|
|
199
|
+
for (const [peer, to] of VersionVector.decode(persistedSnapshotVersionVector).toJSON()) {
|
|
200
|
+
holdRange(held, peer, 0, to);
|
|
201
|
+
}
|
|
202
|
+
for (const blob of durableTailBlobs) {
|
|
203
|
+
const meta = decodeImportBlobMeta(blob, false);
|
|
204
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
205
|
+
for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
|
|
206
|
+
holdRange(held, peer, start.get(peer) ?? 0, to);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return held;
|
|
210
|
+
}
|
|
211
|
+
// The ranges of `local` the durable state does not hold, in counter order
|
|
212
|
+
// per author.
|
|
213
|
+
function rangesMissingFrom(local, held) {
|
|
214
|
+
const missing = [];
|
|
215
|
+
for (const [peer, to] of local.toJSON()) {
|
|
216
|
+
let from = 0;
|
|
217
|
+
for (const [heldFrom, heldTo] of held.get(peer) ?? []) {
|
|
218
|
+
if (heldFrom >= to)
|
|
219
|
+
break;
|
|
220
|
+
if (heldFrom > from)
|
|
221
|
+
missing.push({ peer, from, to: heldFrom });
|
|
222
|
+
from = Math.max(from, heldTo);
|
|
223
|
+
}
|
|
224
|
+
if (from < to)
|
|
225
|
+
missing.push({ peer, from, to });
|
|
226
|
+
}
|
|
227
|
+
return missing;
|
|
228
|
+
}
|
|
229
|
+
// Group ranges so no group carries two ranges of one author. A blob's header
|
|
230
|
+
// names one start and one end per author, so a blob spanning two separate
|
|
231
|
+
// ranges of an author would read, to whoever folds it into a frontier, as
|
|
232
|
+
// holding the ops between them as well.
|
|
233
|
+
function oneRangePerAuthor(ranges) {
|
|
234
|
+
const groups = [];
|
|
235
|
+
const seen = new Map();
|
|
236
|
+
for (const range of ranges) {
|
|
237
|
+
const index = seen.get(range.peer) ?? 0;
|
|
238
|
+
seen.set(range.peer, index + 1);
|
|
239
|
+
(groups[index] ??= []).push(range);
|
|
240
|
+
}
|
|
241
|
+
return groups;
|
|
141
242
|
}
|
|
142
243
|
function rangeOpCount(ranges) {
|
|
143
244
|
return ranges.reduce((n, r) => n + (r.to - r.from), 0);
|
|
@@ -293,9 +394,259 @@ const READ_ONLY_MESSAGES = {
|
|
|
293
394
|
'Reopen it to keep working from the saved state.',
|
|
294
395
|
no_connection: 'Document is read-only: it opened with no live connection, so a change ' +
|
|
295
396
|
'would stay on this device.',
|
|
397
|
+
snapshot: 'Document is read-only: it was opened as a snapshot. Open it without ' +
|
|
398
|
+
'asking for one to edit.',
|
|
399
|
+
too_large: 'Document is read-only: it is too large for Brass to make editable ' +
|
|
400
|
+
'(see `doc.status`).',
|
|
401
|
+
not_imported: 'Document is read-only: it holds an uploaded file that has not been ' +
|
|
402
|
+
'imported, and no importer takes its type.',
|
|
403
|
+
version: 'Document is read-only: it is showing a historical version. ' +
|
|
404
|
+
'Return to the latest version to edit.',
|
|
405
|
+
browsing: 'Document is read-only: the reader is browsing its versions. ' +
|
|
406
|
+
'Close the version bar to edit.',
|
|
407
|
+
loading: 'Document is still loading its content; wait for the load to finish ' +
|
|
408
|
+
'before editing (see `doc.onStatus`).',
|
|
409
|
+
reopening: 'Document is read-only while it reopens; edit the copy `reopen` resolves with.',
|
|
410
|
+
load_failed: 'Document is read-only: its content could not be prepared ' +
|
|
411
|
+
'(see `doc.status`). Reopen it to try again.',
|
|
412
|
+
};
|
|
413
|
+
// What `status` reads while this client's edits wait on a connection that has
|
|
414
|
+
// never opened. Pending, because Brass keeps trying and the edits save the
|
|
415
|
+
// moment it connects; not blocking, because editing stays open.
|
|
416
|
+
const NOT_SAVED_NOTICE = {
|
|
417
|
+
message: 'Your changes are not saved yet. Brass cannot connect to save them and ' +
|
|
418
|
+
'keeps trying. Keep this page open until they are saved.',
|
|
419
|
+
pending: true,
|
|
420
|
+
blocking: false,
|
|
296
421
|
};
|
|
422
|
+
// What `status` reads while one of this client's edits goes unanswered by a
|
|
423
|
+
// connection that answers its others. Pending and not blocking for the same
|
|
424
|
+
// reasons as `NOT_SAVED_NOTICE`.
|
|
425
|
+
const UNANSWERED_NOTICE = {
|
|
426
|
+
message: 'Some of your changes are not saved yet. Brass keeps trying to save them. ' +
|
|
427
|
+
'Keep this page open until they are saved.',
|
|
428
|
+
pending: true,
|
|
429
|
+
blocking: false,
|
|
430
|
+
};
|
|
431
|
+
const SAVE_NOTICES = { never_opened: NOT_SAVED_NOTICE, unanswered: UNANSWERED_NOTICE };
|
|
432
|
+
// The roots a document's referenced inputs live in are written only by the
|
|
433
|
+
// SDK's own input writers, so a frozen root carrying one of those names
|
|
434
|
+
// converts to nothing.
|
|
435
|
+
const INPUTS_ROOTS = new Set([INPUTS_REFS_ROOT, INPUTS_READ_ROOT]);
|
|
436
|
+
/**
|
|
437
|
+
* The CRDT a frozen stream becomes: its content written through the same
|
|
438
|
+
* handles an app writes through, so what a conversion produces is what an app
|
|
439
|
+
* writing the same content would have produced, paging and all.
|
|
440
|
+
*
|
|
441
|
+
* Every side converts by calling this. It is authored by the pinned
|
|
442
|
+
* conversion peer (`CONVERSION_PEER_ID`) over the content in stored order, so
|
|
443
|
+
* one frozen stream always converts to the same document; it is NOT a licence
|
|
444
|
+
* for two builds to convert separately and expect to agree, because the rest
|
|
445
|
+
* of the output moves with the paging constants and the batching budget, which
|
|
446
|
+
* is why the platform converts once and stores the result.
|
|
447
|
+
*
|
|
448
|
+
* A list that is a map's key walk is that map's order, so the two are written
|
|
449
|
+
* as one ordered map.
|
|
450
|
+
*
|
|
451
|
+
* @internal
|
|
452
|
+
*/
|
|
453
|
+
export function snapshotFromSealedContent(content) {
|
|
454
|
+
const names = content.rootNames().filter((n) => !INPUTS_ROOTS.has(n));
|
|
455
|
+
const walked = new Map();
|
|
456
|
+
for (const name of names) {
|
|
457
|
+
const map = content.keyWalkOf(name);
|
|
458
|
+
if (map !== null && !walked.has(map))
|
|
459
|
+
walked.set(map, name);
|
|
460
|
+
}
|
|
461
|
+
// A `Document` registers its root container on construction, so the root
|
|
462
|
+
// key is one the content writes anyway rather than an empty `entities` map
|
|
463
|
+
// in every snapshot.
|
|
464
|
+
const rootKey = [...walked.keys()][0] ??
|
|
465
|
+
names.find((n) => content.kindOf(n) === 'map') ??
|
|
466
|
+
'entities';
|
|
467
|
+
const doc = Document.create({ rootKey, peerId: CONVERSION_PEER_ID });
|
|
468
|
+
try {
|
|
469
|
+
for (const name of names) {
|
|
470
|
+
// A reserved root is written only through `dependsOn` / `markRead`.
|
|
471
|
+
if (isReservedRootName(name))
|
|
472
|
+
continue;
|
|
473
|
+
const kind = content.kindOf(name);
|
|
474
|
+
if (kind === 'map') {
|
|
475
|
+
const order = walked.get(name);
|
|
476
|
+
const write = (draft) => {
|
|
477
|
+
content.forEachMapEntry(name, (key, value) => {
|
|
478
|
+
draft[key] = value;
|
|
479
|
+
});
|
|
480
|
+
};
|
|
481
|
+
if (order !== undefined)
|
|
482
|
+
doc.orderedMap(name, order).mutate(write);
|
|
483
|
+
else
|
|
484
|
+
doc.map(name).mutate(write);
|
|
485
|
+
}
|
|
486
|
+
else if (kind === 'list') {
|
|
487
|
+
const map = content.keyWalkOf(name);
|
|
488
|
+
if (map !== null && walked.get(map) === name)
|
|
489
|
+
continue;
|
|
490
|
+
const items = content.list(name) ?? [];
|
|
491
|
+
if (items.length === 0)
|
|
492
|
+
continue;
|
|
493
|
+
doc.list(name).mutate((list) => {
|
|
494
|
+
for (const item of items)
|
|
495
|
+
list.push(item);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
else if (kind === 'text') {
|
|
499
|
+
const text = content.text(name) ?? '';
|
|
500
|
+
if (text !== '')
|
|
501
|
+
doc.text(name).update(text);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return doc._exportSnapshot();
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
doc.close();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* A CRDT snapshot's content as JSON, read the way an app reads it (a paged
|
|
512
|
+
* root flattened to its records), holding exactly the roots the snapshot
|
|
513
|
+
* holds. A Document registers its root map on construction, so it is opened
|
|
514
|
+
* on a root the snapshot already has rather than on the default.
|
|
515
|
+
* @internal
|
|
516
|
+
*/
|
|
517
|
+
export function snapshotContentJSON(snapshot) {
|
|
518
|
+
// One engine, and the roots named from its shallow value: a whole-engine
|
|
519
|
+
// `toJSON` here would hold a second copy of the content beside the one the
|
|
520
|
+
// read below builds.
|
|
521
|
+
const engine = new LoroDoc();
|
|
522
|
+
engine.import(snapshot);
|
|
523
|
+
const shallow = engine.getShallowValue();
|
|
524
|
+
const held = Object.keys(shallow);
|
|
525
|
+
const rootKey = held.find((k) => {
|
|
526
|
+
if (isReservedRootName(k))
|
|
527
|
+
return false;
|
|
528
|
+
const id = shallow[k];
|
|
529
|
+
return typeof id === 'string' && id.endsWith(':Map');
|
|
530
|
+
});
|
|
531
|
+
const doc = Document._fromEngine(engine, rootKey !== undefined ? { rootKey } : undefined);
|
|
532
|
+
try {
|
|
533
|
+
const json = doc.toJSON();
|
|
534
|
+
for (const key of Object.keys(json))
|
|
535
|
+
if (!held.includes(key))
|
|
536
|
+
delete json[key];
|
|
537
|
+
return json;
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
doc.close();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** The CRDT a stored record store becomes. The same conversion as
|
|
544
|
+
* `snapshotFromSealedContent`, reached from the record-store view.
|
|
545
|
+
* @internal */
|
|
546
|
+
/** A whole CRDT document as plain JSON, its paged roots as records. */
|
|
547
|
+
function engineJSON(source) {
|
|
548
|
+
const json = source.toJSON();
|
|
549
|
+
// The document's references and read marks are the platform's, read
|
|
550
|
+
// through `dependsOn` and the input status rather than as content.
|
|
551
|
+
for (const key of Object.keys(json))
|
|
552
|
+
if (isReservedRootName(key))
|
|
553
|
+
delete json[key];
|
|
554
|
+
const meta = json[META_KEY];
|
|
555
|
+
if (isRootLookup(meta)) {
|
|
556
|
+
for (const key of Object.keys(meta)) {
|
|
557
|
+
if (isPagedMarkerEntry(META_KEY, key))
|
|
558
|
+
delete meta[key];
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// A paged root holds its records inside page entries beside the store's
|
|
562
|
+
// own edit bookkeeping, so flatten it to the same record lookup
|
|
563
|
+
// `doc.map(root).read()` hands back: a serializer and a whole-document
|
|
564
|
+
// renderer read this surface, and a store that crossed the paging
|
|
565
|
+
// threshold has to reach them as records. Every root is asked, because a
|
|
566
|
+
// root no handle was ever taken on is paged by the same rule as one that
|
|
567
|
+
// was; a root the marker does not name comes back as loro produced it.
|
|
568
|
+
for (const [rootKey, value] of Object.entries(json)) {
|
|
569
|
+
if (!isRootLookup(value))
|
|
570
|
+
continue;
|
|
571
|
+
if (!readRootPaged(source, rootKey))
|
|
572
|
+
continue;
|
|
573
|
+
json[rootKey] = flattenPagedLookup(value, true);
|
|
574
|
+
}
|
|
575
|
+
return json;
|
|
576
|
+
}
|
|
577
|
+
/** A CRDT engine holding decoded join content. */
|
|
578
|
+
function engineFrom(content) {
|
|
579
|
+
const doc = new LoroDoc();
|
|
580
|
+
if (content.kind === 'crdt')
|
|
581
|
+
doc.import(content.bytes);
|
|
582
|
+
else if (content.kind === 'frozen')
|
|
583
|
+
doc.import(snapshotFromSealedContent(content.content));
|
|
584
|
+
return doc;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* A version of a Document over frozen content, as the content its handles read.
|
|
588
|
+
*
|
|
589
|
+
* Frozen content with nothing above it is the version itself. Anything else
|
|
590
|
+
* (a version stored as a CRDT, or a tail over the seal) is materialised and
|
|
591
|
+
* read back as content: a preview takes no write, so the engine it needs is
|
|
592
|
+
* built for the read and freed after it.
|
|
593
|
+
*/
|
|
594
|
+
function frozenPreview(content, tailOpBlobs) {
|
|
595
|
+
if (content.kind === 'frozen' && tailOpBlobs.length === 0)
|
|
596
|
+
return content.content;
|
|
597
|
+
const engine = engineFrom(content);
|
|
598
|
+
try {
|
|
599
|
+
for (const blob of tailOpBlobs)
|
|
600
|
+
importTailOp(engine, blob);
|
|
601
|
+
return SealedContent.fromJSON(engineJSON(engine));
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
engine.free();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
export function snapshotFromSealedStore(store) {
|
|
608
|
+
return snapshotFromSealedContent(store.content);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Write a record store as a CRDT snapshot through the one conversion every
|
|
612
|
+
* frozen stream takes.
|
|
613
|
+
*/
|
|
614
|
+
export function recordStoreSnapshot(source) {
|
|
615
|
+
const records = [];
|
|
616
|
+
source.forEachRecord((key, tag, body) => {
|
|
617
|
+
records.push([key, tag, body]);
|
|
618
|
+
});
|
|
619
|
+
return snapshotFromSealedContent(SealedContent.fromRecords({
|
|
620
|
+
schemaVersion: source.schemaVersion,
|
|
621
|
+
header: source.header,
|
|
622
|
+
records,
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
297
625
|
export class Document {
|
|
298
|
-
|
|
626
|
+
_importReport = null;
|
|
627
|
+
_changed = false;
|
|
628
|
+
_changeNotice = null;
|
|
629
|
+
_stopChangeNotice = null;
|
|
630
|
+
_changedListeners = new Set();
|
|
631
|
+
// A frozen copy's reload, wired by the transport: re-joins and applies the
|
|
632
|
+
// result in place. `null` on every other open.
|
|
633
|
+
_frozenReloader = null;
|
|
634
|
+
// Opens this document again in another mode. Installed by whichever layer
|
|
635
|
+
// opened it: the session for the document it holds, the client otherwise.
|
|
636
|
+
_reopener = null;
|
|
637
|
+
_reloading = null;
|
|
638
|
+
// `onStatus` listeners on a frozen copy, which hears status on the same
|
|
639
|
+
// notice the change report rides.
|
|
640
|
+
_statusWatches = 0;
|
|
641
|
+
_inputsRuntime = null;
|
|
642
|
+
// Built on the first status subscription.
|
|
643
|
+
_inputStatusHub = null;
|
|
644
|
+
// The `subscribeInputDocuments` followers, which close and park stop so no
|
|
645
|
+
// retry, handler or kept copy outlives the document.
|
|
646
|
+
_inputFollowers = new InputFollowers();
|
|
647
|
+
// The CRDT engine. `null` until something reads it: a Document over frozen
|
|
648
|
+
// content never does, so it holds no engine at all.
|
|
649
|
+
_engine;
|
|
299
650
|
_rootKey;
|
|
300
651
|
// Server-minted `doc_id`, or `null` when constructed without one
|
|
301
652
|
// (test / offline path).
|
|
@@ -324,6 +675,32 @@ export class Document {
|
|
|
324
675
|
// and `returnToHead` reject. The live + checkout-on-demand path
|
|
325
676
|
// leaves this `false`.
|
|
326
677
|
_isFrozen;
|
|
678
|
+
// The frozen content this Document reads, when the platform holds its
|
|
679
|
+
// content in that encoding. Its roots serve `map` / `list` / `orderedMap`
|
|
680
|
+
// and `toJSON` in place of the CRDT's, so an app reads one document
|
|
681
|
+
// surface whichever way the content is stored. `null` for a CRDT document.
|
|
682
|
+
_sealedRoots;
|
|
683
|
+
// The frozen content at the head, and the content the roots read now: the
|
|
684
|
+
// head, or a version a checkout swapped in. `null` with `_sealedRoots`.
|
|
685
|
+
_frozenHead;
|
|
686
|
+
_frozenShown;
|
|
687
|
+
// The version a Document over frozen content is showing, or `null` for its
|
|
688
|
+
// head.
|
|
689
|
+
_frozenVersion = null;
|
|
690
|
+
// The seq the frozen content was sealed at, which is what says whether a
|
|
691
|
+
// later join's frozen content is the same content.
|
|
692
|
+
_frozenSeal;
|
|
693
|
+
// What `map` / `list` / `text` / `orderedMap` hand out: one handle per
|
|
694
|
+
// root, over whichever storage this Document holds now.
|
|
695
|
+
_handles = new StableHandles((err) => this._handleOwner.reportSubscriberThrow(err));
|
|
696
|
+
// What an engine built for this Document after construction is built with:
|
|
697
|
+
// the root a CRDT open would root `mutate` at, and the caller's engine
|
|
698
|
+
// options.
|
|
699
|
+
_engineRootKey;
|
|
700
|
+
_engineOptions;
|
|
701
|
+
// Swaps this Document's frozen content for the CRDT the server now holds,
|
|
702
|
+
// wired by the transport. Absent, a remote op over frozen content reloads.
|
|
703
|
+
_storageUpgrader = null;
|
|
327
704
|
// Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
|
|
328
705
|
// is a version-pinned open with no live channel): the Document keeps a
|
|
329
706
|
// live WS subscription and applies incoming remote ops, but local
|
|
@@ -332,6 +709,9 @@ export class Document {
|
|
|
332
709
|
// grant, a view-only link), and latched on afterwards by
|
|
333
710
|
// `_markReadOnly` when the server refuses a write on the same grounds.
|
|
334
711
|
_forcedReadOnly;
|
|
712
|
+
// Set while `reopen` replaces this copy (see the `reopening` reason).
|
|
713
|
+
_reopening = false;
|
|
714
|
+
_pendingReopen = null;
|
|
335
715
|
// Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
|
|
336
716
|
_readOnlyReason;
|
|
337
717
|
// The UndoManager is constructed eagerly so the first call to
|
|
@@ -342,6 +722,10 @@ export class Document {
|
|
|
342
722
|
// only; it does NOT fire on snapshot import, so the
|
|
343
723
|
// cold-load contract is unaffected (imports are not local commits).
|
|
344
724
|
_undo;
|
|
725
|
+
// The document's one undo stack across its streams. A Document owns the
|
|
726
|
+
// group it was created with; a stream's own Document joins its document's
|
|
727
|
+
// group when the stream opens (`_joinUndoGroup`).
|
|
728
|
+
_undoGroup = new UndoGroup();
|
|
345
729
|
// One subscriber channel. Every event reaches every subscriber, whatever
|
|
346
730
|
// produced it (local mutate, remote import, undo, redo, checkout); the
|
|
347
731
|
// app reconciles by value and reads `origin.source` only when it has a
|
|
@@ -359,6 +743,11 @@ export class Document {
|
|
|
359
743
|
_undoStateSubs = new Set();
|
|
360
744
|
_lastCanUndo = false;
|
|
361
745
|
_lastCanRedo = false;
|
|
746
|
+
_replayingUndo = false;
|
|
747
|
+
// The change a replay produced, held until the replay ends so `subscribe`
|
|
748
|
+
// fires once per `undo()` / `redo()`. A listener that writes in reaction
|
|
749
|
+
// would otherwise commit mid-replay, joining the step being replayed.
|
|
750
|
+
_replayChange = null;
|
|
362
751
|
_unsubLoro = null;
|
|
363
752
|
_unsubLocal = null;
|
|
364
753
|
// Local commits drive this hook with the resulting op blob; the hook
|
|
@@ -400,22 +789,30 @@ export class Document {
|
|
|
400
789
|
// other half of `readOnly` and must NOT gate this, because a document
|
|
401
790
|
// still hydrating is exactly the one the seed watch is armed for, and
|
|
402
791
|
// the arm happens while that lock is still on.
|
|
403
|
-
isCaptureAllowed: () => this._isLive && this.
|
|
792
|
+
isCaptureAllowed: () => this._isLive && this._previewVersion === null && !this._forcedReadOnly,
|
|
404
793
|
isAttached: () => this._isLive,
|
|
405
|
-
|
|
406
|
-
|
|
794
|
+
currentVersion: () => this.currentSeq,
|
|
795
|
+
onCaptured: (version) => {
|
|
796
|
+
this._thumbnailVersion = version ?? this.currentSeq ?? 0;
|
|
407
797
|
},
|
|
408
798
|
});
|
|
409
799
|
_checkpoints = new CheckpointScheduler({
|
|
410
800
|
docId: () => this._docId,
|
|
411
801
|
isAttached: () => this._isLive,
|
|
412
|
-
isAutoAllowed: () => this._isLive && this.
|
|
802
|
+
isAutoAllowed: () => this._isLive && this._previewVersion === null,
|
|
413
803
|
currentSeq: () => this.currentSeq,
|
|
414
804
|
sealUndoStep: () => {
|
|
415
|
-
|
|
416
|
-
this._undo.sealStep();
|
|
805
|
+
this._undoGroup.seal();
|
|
417
806
|
},
|
|
807
|
+
hasUnsavedOps: () => this.savingCount > 0,
|
|
418
808
|
whenLocalOpsSaved: () => whenSaved(this),
|
|
809
|
+
captureFrame: () => this._thumbnails.captureFrame(),
|
|
810
|
+
uploadFrame: (frame, version) => {
|
|
811
|
+
// The debounced capture usually lands first, at the same version.
|
|
812
|
+
if (this._thumbnailVersion === version)
|
|
813
|
+
return;
|
|
814
|
+
void this._thumbnails.uploadFrame(frame, version);
|
|
815
|
+
},
|
|
419
816
|
});
|
|
420
817
|
// Drive push hook. `null` on the offline / test path; `pushToDrive`
|
|
421
818
|
// then throws with a clear message rather than silently dropping.
|
|
@@ -433,6 +830,9 @@ export class Document {
|
|
|
433
830
|
_sourceBlobFetcher = null;
|
|
434
831
|
// Cached doc name. `null` on offline / test docs.
|
|
435
832
|
_name = null;
|
|
833
|
+
// When the platform will remove this document, for one created without an
|
|
834
|
+
// account; `null` for every other document.
|
|
835
|
+
_expiresAt = null;
|
|
436
836
|
// True once the reader has asked for the version-browsing surface without
|
|
437
837
|
// checking anything out (the hosted history dialog's browse-in-the-app
|
|
438
838
|
// outcome). The host's version bar latches on this as well as on a live
|
|
@@ -548,8 +948,20 @@ export class Document {
|
|
|
548
948
|
// Owning construction is what lets this class declare an engine-neutral
|
|
549
949
|
// surface: app code holds a Document, never the doc underneath it.
|
|
550
950
|
constructor(loroDoc, options) {
|
|
551
|
-
this.
|
|
951
|
+
this._undoGroup.setOwner(this, {
|
|
952
|
+
blocked: () => this._undoGroupBlocked(),
|
|
953
|
+
notify: () => this._maybeFireUndoState(),
|
|
954
|
+
// A write to another stream is an edit like one here, so it arms the
|
|
955
|
+
// same idle checkpoint, which is what seals the step it opened.
|
|
956
|
+
memberCommitted: () => this._checkpoints.scheduleAuto(),
|
|
957
|
+
});
|
|
958
|
+
this._engine = loroDoc;
|
|
552
959
|
this._rootKey = options?.rootKey ?? 'entities';
|
|
960
|
+
this._engineRootKey = this._rootKey;
|
|
961
|
+
this._engineOptions = {
|
|
962
|
+
...(options?.peerId !== undefined ? { peerId: options.peerId } : {}),
|
|
963
|
+
...(options?.undo !== undefined ? { undo: options.undo } : {}),
|
|
964
|
+
};
|
|
553
965
|
this._docId = options?.docId ?? null;
|
|
554
966
|
this._branchId = options?.branchId ?? null;
|
|
555
967
|
// A Document constructed with a `version` is a "frozen" cold-link
|
|
@@ -557,14 +969,21 @@ export class Document {
|
|
|
557
969
|
// `loroDoc` and there is no live WS channel. The Document is
|
|
558
970
|
// permanently in checkout against itself; `returnToHead` /
|
|
559
971
|
// `_checkoutVersion` are not meaningful on this shape.
|
|
972
|
+
const frozen = options?.frozenContent;
|
|
560
973
|
const initialVersion = options?.version ?? null;
|
|
561
974
|
if (initialVersion !== null) {
|
|
562
975
|
this._isFrozen = true;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
976
|
+
if (loroDoc !== null) {
|
|
977
|
+
this._checkout = {
|
|
978
|
+
previewDoc: loroDoc,
|
|
979
|
+
previewRoot: loroDoc.getMap(this._rootKey),
|
|
980
|
+
version: initialVersion,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
this._checkout = null;
|
|
985
|
+
this._frozenVersion = initialVersion;
|
|
986
|
+
}
|
|
568
987
|
}
|
|
569
988
|
else {
|
|
570
989
|
this._isFrozen = false;
|
|
@@ -574,12 +993,57 @@ export class Document {
|
|
|
574
993
|
// reader the server will not accept writes from, whether because their
|
|
575
994
|
// grant is view-only or because the link they came through is. A
|
|
576
995
|
// version open is already frozen, so it does not also need the flag.
|
|
996
|
+
// A stored record set answers every read directly, and takes no write: a
|
|
997
|
+
// record set holds no op log for an edit to become, and making one is the
|
|
998
|
+
// platform's (see `open-document.ts`, where the join serves a record set
|
|
999
|
+
// only to a reader). So an app reads one document surface and never
|
|
1000
|
+
// learns which encoding it was handed, and the write it cannot make is
|
|
1001
|
+
// refused by the same read-only gate every other unwritable open uses.
|
|
1002
|
+
this._frozenHead = frozen ?? null;
|
|
1003
|
+
this._frozenShown = frozen ?? null;
|
|
1004
|
+
this._frozenSeal = options?.frozenSeal ?? null;
|
|
1005
|
+
this._sealedRoots =
|
|
1006
|
+
frozen === undefined
|
|
1007
|
+
? null
|
|
1008
|
+
: sealedRoots(() => this._frozenShown, () => this._handleOwner.assertWritable());
|
|
577
1009
|
this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
|
|
578
1010
|
this._readOnlyReason = options?.readOnlyReason ?? 'link';
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1011
|
+
if (loroDoc !== null) {
|
|
1012
|
+
if (options?.peerId !== undefined)
|
|
1013
|
+
loroDoc.setPeerId(options.peerId);
|
|
1014
|
+
// Materialize the root container, so a document nothing has written to
|
|
1015
|
+
// still carries its root in the shape a reader opens it with.
|
|
1016
|
+
loroDoc.getMap(this._rootKey);
|
|
1017
|
+
this._undo = this._newUndoController(loroDoc, options?.undo);
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
this._undo = null;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// The engine, built on first use for a Document constructed without one.
|
|
1024
|
+
get _doc() {
|
|
1025
|
+
if (this._engine === null) {
|
|
1026
|
+
this._engine = new LoroDoc();
|
|
1027
|
+
this._engine.getMap(this._rootKey);
|
|
1028
|
+
}
|
|
1029
|
+
return this._engine;
|
|
1030
|
+
}
|
|
1031
|
+
/** Whether this Document has been closed. @internal */
|
|
1032
|
+
get _isClosed() {
|
|
1033
|
+
return this._closed;
|
|
1034
|
+
}
|
|
1035
|
+
/** Whether this Document has built a CRDT engine. @internal */
|
|
1036
|
+
_holdsEngine() {
|
|
1037
|
+
return this._engine !== null;
|
|
1038
|
+
}
|
|
1039
|
+
/** The seq this Document's frozen content was sealed at, or `null` for a
|
|
1040
|
+
* CRDT document or a seal the join did not name. @internal */
|
|
1041
|
+
get _frozenSealSeq() {
|
|
1042
|
+
return this._frozenSeal;
|
|
1043
|
+
}
|
|
1044
|
+
// The version this Document is showing instead of its head, or `null`.
|
|
1045
|
+
get _previewVersion() {
|
|
1046
|
+
return this._checkout?.version ?? this._frozenVersion;
|
|
583
1047
|
}
|
|
584
1048
|
// ---- Typed container handles (the primary read/write/subscribe surface) --
|
|
585
1049
|
//
|
|
@@ -604,16 +1068,30 @@ export class Document {
|
|
|
604
1068
|
get _pagingSessionToken() {
|
|
605
1069
|
return this._doc.peerIdStr;
|
|
606
1070
|
}
|
|
1071
|
+
/** Whether this document's reads are answered by a stored record set.
|
|
1072
|
+
* @internal */
|
|
1073
|
+
_holdsRecordSet() {
|
|
1074
|
+
return this._sealedRoots !== null;
|
|
1075
|
+
}
|
|
607
1076
|
// Get a handle for a top-level LoroMap at `key`. The container is
|
|
608
1077
|
// materialized in the underlying LoroDoc immediately (Loro's
|
|
609
1078
|
// `getMap` is "get or create"); mutations through the handle do
|
|
610
1079
|
// not need to create the container themselves.
|
|
611
1080
|
map(key) {
|
|
612
1081
|
this._assertOpen();
|
|
1082
|
+
assertAppRootName(key);
|
|
1083
|
+
return this._handles.root(`map:${key}`, 'map', () => this._rootMap(key));
|
|
1084
|
+
}
|
|
1085
|
+
_rootMap(key) {
|
|
613
1086
|
const cacheKey = `map:${key}`;
|
|
614
1087
|
const existing = this._rootHandles.get(cacheKey);
|
|
615
1088
|
if (existing)
|
|
616
1089
|
return existing.handle;
|
|
1090
|
+
if (this._sealedRoots !== null) {
|
|
1091
|
+
const handle = this._sealedRoots.map(key);
|
|
1092
|
+
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
1093
|
+
return handle;
|
|
1094
|
+
}
|
|
617
1095
|
// Source the LoroMap from the active source (live or checkout
|
|
618
1096
|
// preview), same as `toJSON` does. Handles bound to a Document
|
|
619
1097
|
// in checkout see the historical state via the preview doc.
|
|
@@ -642,17 +1120,26 @@ export class Document {
|
|
|
642
1120
|
},
|
|
643
1121
|
sessionToken: () => this._pagingSessionToken,
|
|
644
1122
|
stateVersion: () => activeSource().frontiers(),
|
|
645
|
-
});
|
|
1123
|
+
}, key);
|
|
646
1124
|
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
647
1125
|
return handle;
|
|
648
1126
|
}
|
|
649
1127
|
// Get a handle for a top-level LoroMovableList at `key`.
|
|
650
1128
|
list(key) {
|
|
651
1129
|
this._assertOpen();
|
|
1130
|
+
assertAppRootName(key);
|
|
1131
|
+
return this._handles.root(`list:${key}`, 'list', () => this._rootList(key));
|
|
1132
|
+
}
|
|
1133
|
+
_rootList(key) {
|
|
652
1134
|
const cacheKey = `list:${key}`;
|
|
653
1135
|
const existing = this._rootHandles.get(cacheKey);
|
|
654
1136
|
if (existing)
|
|
655
1137
|
return existing.handle;
|
|
1138
|
+
if (this._sealedRoots !== null) {
|
|
1139
|
+
const handle = this._sealedRoots.list(key);
|
|
1140
|
+
this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
|
|
1141
|
+
return handle;
|
|
1142
|
+
}
|
|
656
1143
|
const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
657
1144
|
const loroList = source.getMovableList(key);
|
|
658
1145
|
const handle = createRootListHandle(loroList, this._handleOwner);
|
|
@@ -678,10 +1165,16 @@ export class Document {
|
|
|
678
1165
|
*/
|
|
679
1166
|
text(key) {
|
|
680
1167
|
this._assertOpen();
|
|
1168
|
+
assertAppRootName(key);
|
|
1169
|
+
return this._handles.root(`text:${key}`, 'text', () => this._rootText(key));
|
|
1170
|
+
}
|
|
1171
|
+
_rootText(key) {
|
|
681
1172
|
const cacheKey = `text:${key}`;
|
|
682
1173
|
const existing = this._rootHandles.get(cacheKey);
|
|
683
1174
|
if (existing)
|
|
684
1175
|
return existing.handle;
|
|
1176
|
+
if (this._sealedRoots !== null)
|
|
1177
|
+
return this._sealedRoots.text(key);
|
|
685
1178
|
const owner = this._handleOwner;
|
|
686
1179
|
const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
687
1180
|
// Reads and writes resolve the active source per call, so a checkout
|
|
@@ -790,10 +1283,20 @@ export class Document {
|
|
|
790
1283
|
}
|
|
791
1284
|
orderedMap(mapKey, orderKey) {
|
|
792
1285
|
this._assertOpen();
|
|
1286
|
+
return this._handles.root(`ordered:${mapKey}\u0000${orderKey}`, 'map', () => this._rootOrderedMap(mapKey, orderKey));
|
|
1287
|
+
}
|
|
1288
|
+
_rootOrderedMap(mapKey, orderKey) {
|
|
793
1289
|
const cacheKey = `${mapKey}\u0000${orderKey}`;
|
|
794
1290
|
let h = this._orderedMapHandles.get(cacheKey);
|
|
1291
|
+
if (!h && this._sealedRoots !== null) {
|
|
1292
|
+
// Taken from the store rather than composed from this document's own
|
|
1293
|
+
// map + list handles: the composition exists to keep a map and its
|
|
1294
|
+
// order list in step through writes, and a stored record set has none.
|
|
1295
|
+
h = this._sealedRoots.orderedMap(mapKey, orderKey);
|
|
1296
|
+
this._orderedMapHandles.set(cacheKey, h);
|
|
1297
|
+
}
|
|
795
1298
|
if (!h) {
|
|
796
|
-
h = createOrderedMapHandle(this.
|
|
1299
|
+
h = createOrderedMapHandle(this._rootMap(mapKey), this._rootList(orderKey), this._handleOwner);
|
|
797
1300
|
this._orderedMapHandles.set(cacheKey, h);
|
|
798
1301
|
}
|
|
799
1302
|
return h;
|
|
@@ -813,34 +1316,24 @@ export class Document {
|
|
|
813
1316
|
},
|
|
814
1317
|
assertWritable: () => {
|
|
815
1318
|
this._assertOpen();
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
if (this._versionBrowsing) {
|
|
821
|
-
throw new Error('Document is read-only: the reader is browsing its versions. ' +
|
|
822
|
-
'Close the version bar to edit.');
|
|
823
|
-
}
|
|
824
|
-
if (this._forcedReadOnly) {
|
|
825
|
-
throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
|
|
826
|
-
}
|
|
827
|
-
if (this._loadFailed) {
|
|
828
|
-
throw new Error('Document is read-only: its content could not be prepared ' +
|
|
829
|
-
'(see `doc.status`). Reopen it to try again.');
|
|
830
|
-
}
|
|
831
|
-
if (this._isLoadLocked()) {
|
|
832
|
-
throw new Error('Document is still loading its content; wait for the load to ' +
|
|
833
|
-
'finish before editing (see `doc.onStatus`).');
|
|
834
|
-
}
|
|
1319
|
+
const reason = this._readOnlyBecause;
|
|
1320
|
+
if (reason !== null)
|
|
1321
|
+
throw new Error(READ_ONLY_MESSAGES[reason]);
|
|
835
1322
|
},
|
|
836
1323
|
ensureLocalUpdatesSubscribed: () => {
|
|
837
1324
|
this._ensureLocalUpdatesSubscribed();
|
|
838
1325
|
},
|
|
839
1326
|
commitMutate: () => {
|
|
840
|
-
this._doc.setNextCommitOrigin(
|
|
1327
|
+
this._doc.setNextCommitOrigin(this._mutateCommitOrigin);
|
|
841
1328
|
this._doc.commit();
|
|
842
1329
|
},
|
|
1330
|
+
runMutate: (options, fn) => {
|
|
1331
|
+
this._runInMutate(options?.undoable !== false, fn);
|
|
1332
|
+
},
|
|
843
1333
|
originOf: (event) => changeSourceOf(event),
|
|
1334
|
+
reportSubscriberThrow: (err) => {
|
|
1335
|
+
this._reportSubscriberThrow(err);
|
|
1336
|
+
},
|
|
844
1337
|
};
|
|
845
1338
|
return this._handleOwnerCache;
|
|
846
1339
|
}
|
|
@@ -869,26 +1362,49 @@ export class Document {
|
|
|
869
1362
|
// and follows a grant the server withdraws mid-session as well as a
|
|
870
1363
|
// checkout the reader entered.
|
|
871
1364
|
get readOnly() {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1365
|
+
return this._readOnlyBecause !== null;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The refusal message when this reader's access, a view-only link, or this
|
|
1369
|
+
* app's standing keeps them from writing however the document is opened,
|
|
1370
|
+
* else null. `session.edit()` rejects with it rather than reopening.
|
|
1371
|
+
* @internal
|
|
1372
|
+
*/
|
|
1373
|
+
get _editRefusal() {
|
|
1374
|
+
const reason = this._forcedReadOnly ? this._readOnlyReason : null;
|
|
1375
|
+
if (reason === 'grant' || reason === 'link' || reason === 'app') {
|
|
1376
|
+
return READ_ONLY_MESSAGES[reason];
|
|
1377
|
+
}
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Why this reader cannot write, or null when they can.
|
|
1382
|
+
*
|
|
1383
|
+
* The one place the conditions are listed. `readOnly` asks whether there is
|
|
1384
|
+
* a reason and `mutate` refuses with the reason's own message, so the two
|
|
1385
|
+
* cannot answer differently about the same document, and a new reason
|
|
1386
|
+
* reaches both by being added here.
|
|
1387
|
+
*
|
|
1388
|
+
* Order decides which message a reader sees when several hold at once, and
|
|
1389
|
+
* it runs from the state they are in to the state they were put in: a
|
|
1390
|
+
* checkout and an open version bar are things this reader did and can
|
|
1391
|
+
* undo, so naming those first tells them what to do. A load that has not
|
|
1392
|
+
* finished comes last because it clears on its own.
|
|
1393
|
+
*/
|
|
1394
|
+
get _readOnlyBecause() {
|
|
1395
|
+
if (this._previewVersion !== null)
|
|
1396
|
+
return 'version';
|
|
1397
|
+
if (this._versionBrowsing)
|
|
1398
|
+
return 'browsing';
|
|
1399
|
+
if (this._forcedReadOnly)
|
|
1400
|
+
return this._readOnlyReason;
|
|
1401
|
+
if (this._reopening)
|
|
1402
|
+
return 'reopening';
|
|
1403
|
+
if (this._loadFailed)
|
|
1404
|
+
return 'load_failed';
|
|
1405
|
+
if (this._isLoadLocked())
|
|
1406
|
+
return 'loading';
|
|
1407
|
+
return null;
|
|
892
1408
|
}
|
|
893
1409
|
// True when content-replacing work (hydration / extraction) is pending
|
|
894
1410
|
// and editing must be locked so incoming bytes do not clobber local
|
|
@@ -903,7 +1419,7 @@ export class Document {
|
|
|
903
1419
|
_isLoadLocked() {
|
|
904
1420
|
if (this._loadFailed)
|
|
905
1421
|
return true;
|
|
906
|
-
const s = this._workStatus.
|
|
1422
|
+
const s = this._workStatus.work;
|
|
907
1423
|
return s?.pending === true && s.blocking !== false;
|
|
908
1424
|
}
|
|
909
1425
|
/**
|
|
@@ -929,8 +1445,8 @@ export class Document {
|
|
|
929
1445
|
* `doc.readOnly` (true for a preview, a view-only link, or a load lock).
|
|
930
1446
|
* @internal
|
|
931
1447
|
*/
|
|
932
|
-
get
|
|
933
|
-
return this.
|
|
1448
|
+
get previewVersion() {
|
|
1449
|
+
return this._previewVersion;
|
|
934
1450
|
}
|
|
935
1451
|
/** @internal Whether the host should be showing its version-browsing
|
|
936
1452
|
* surface for this document. True from a browse-in-the-app outcome and
|
|
@@ -955,8 +1471,9 @@ export class Document {
|
|
|
955
1471
|
* upload pins to it); no app-facing operation takes a seq, since version
|
|
956
1472
|
* history is hosted chrome the app does not drive with numbers. */
|
|
957
1473
|
get currentSeq() {
|
|
958
|
-
|
|
959
|
-
|
|
1474
|
+
const previewing = this._previewVersion;
|
|
1475
|
+
if (previewing !== null)
|
|
1476
|
+
return previewing;
|
|
960
1477
|
if (this._currentSeqProvider)
|
|
961
1478
|
return this._currentSeqProvider();
|
|
962
1479
|
return null;
|
|
@@ -972,6 +1489,36 @@ export class Document {
|
|
|
972
1489
|
loroDoc.import(snapshot);
|
|
973
1490
|
return new Document(loroDoc, options);
|
|
974
1491
|
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Open a Document over frozen content: the document's content as it was
|
|
1494
|
+
* sealed, read with no CRDT engine.
|
|
1495
|
+
*
|
|
1496
|
+
* No CRDT engine is built: every root answers from the content, and the
|
|
1497
|
+
* machinery a Document carries regardless (its id, its subscriber channel,
|
|
1498
|
+
* its close path) runs without one.
|
|
1499
|
+
* @internal
|
|
1500
|
+
*/
|
|
1501
|
+
static openFromSealedContent(content, options) {
|
|
1502
|
+
// Rooted at a map the content holds, so a document reading frozen content
|
|
1503
|
+
// registers no empty root of its own.
|
|
1504
|
+
//
|
|
1505
|
+
// Read-only is stated here rather than left to the caller, because it is
|
|
1506
|
+
// a property of the bytes: frozen content holds no op log, so there is
|
|
1507
|
+
// nothing an edit could be written into, and making one is the platform's
|
|
1508
|
+
// (`runRecordSetConversion`). Stating it at the factory is what lets the
|
|
1509
|
+
// refusal come from the document's own gate, which names the reader's
|
|
1510
|
+
// actual limit, instead of from each handle describing the encoding.
|
|
1511
|
+
const rootKey = content.rootNames().find((n) => content.kindOf(n) === 'map') ?? RECORDS_KEY;
|
|
1512
|
+
const doc = new Document(null, {
|
|
1513
|
+
...options,
|
|
1514
|
+
readOnly: true,
|
|
1515
|
+
readOnlyReason: options?.readOnlyReason ?? 'snapshot',
|
|
1516
|
+
rootKey,
|
|
1517
|
+
frozenContent: content,
|
|
1518
|
+
});
|
|
1519
|
+
doc._engineRootKey = options?.rootKey ?? 'entities';
|
|
1520
|
+
return doc;
|
|
1521
|
+
}
|
|
975
1522
|
/**
|
|
976
1523
|
* Construct an empty Document (empty-snapshot join case, and tests).
|
|
977
1524
|
* @internal
|
|
@@ -1010,7 +1557,17 @@ export class Document {
|
|
|
1010
1557
|
// subscribers.
|
|
1011
1558
|
return;
|
|
1012
1559
|
}
|
|
1013
|
-
|
|
1560
|
+
// An op exists, so the document is now a CRDT and somebody has edited it.
|
|
1561
|
+
// The op is authored against that CRDT, so imported over frozen content it
|
|
1562
|
+
// would land on an empty LoroDoc and sit causally incomplete. The storage
|
|
1563
|
+
// is upgraded instead: the transport re-joins, and the join's CRDT
|
|
1564
|
+
// replaces the frozen content under the same handles (`_upgradeStorage`).
|
|
1565
|
+
// The op is in what that join serves.
|
|
1566
|
+
if (this._sealedRoots !== null) {
|
|
1567
|
+
this._requestStorageUpgrade();
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
importTailOp(this._doc, opBlob);
|
|
1014
1571
|
}
|
|
1015
1572
|
// Apply a snapshot blob over the top of an existing Document.
|
|
1016
1573
|
// Internal: the SDK calls this when the server delivers a fresh
|
|
@@ -1027,6 +1584,13 @@ export class Document {
|
|
|
1027
1584
|
// arrives and the Loro subscriber cannot be relied on to report it: it is
|
|
1028
1585
|
// attached lazily, so a document whose content lands before the session has
|
|
1029
1586
|
// bound its canvas has nobody subscribed and the import fires nothing.
|
|
1587
|
+
// CRDT bytes over frozen content are a storage upgrade, which keeps the
|
|
1588
|
+
// app's handles (see `_upgradeStorage`).
|
|
1589
|
+
if (this._sealedRoots !== null) {
|
|
1590
|
+
if (!this._upgradeStorage(snapshot, []))
|
|
1591
|
+
this._reopen();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1030
1594
|
this._armSeedIfPictureIsStale();
|
|
1031
1595
|
this._doc.import(snapshot);
|
|
1032
1596
|
// The undo stack pre-snapshot referenced ops the new snapshot
|
|
@@ -1037,7 +1601,113 @@ export class Document {
|
|
|
1037
1601
|
if (this._undo)
|
|
1038
1602
|
this._undo.clear();
|
|
1039
1603
|
}
|
|
1040
|
-
|
|
1604
|
+
/** @internal Wire the re-join that upgrades this Document's frozen content
|
|
1605
|
+
* to the CRDT the server now holds. */
|
|
1606
|
+
_setStorageUpgrader(upgrade) {
|
|
1607
|
+
this._storageUpgrader = upgrade;
|
|
1608
|
+
}
|
|
1609
|
+
_requestStorageUpgrade() {
|
|
1610
|
+
const upgrade = this._storageUpgrader;
|
|
1611
|
+
if (upgrade === null) {
|
|
1612
|
+
this._reopen();
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
upgrade();
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Swap this Document's storage from frozen content to a CRDT, in place.
|
|
1619
|
+
* @internal
|
|
1620
|
+
*
|
|
1621
|
+
* Every handle an app took over the frozen content keeps working, now over
|
|
1622
|
+
* the CRDT, and its subscribers move there as a whole-container resync (keys
|
|
1623
|
+
* the CRDT does not hold arrive as `deleted`, and `onSettled` fires again).
|
|
1624
|
+
* Read-only state is unchanged, because the reader's intent is.
|
|
1625
|
+
*
|
|
1626
|
+
* Returns false, leaving the frozen content in place, when the Document is
|
|
1627
|
+
* showing a version of the frozen content: the preview has no CRDT
|
|
1628
|
+
* counterpart to rebind to, so the caller reloads instead.
|
|
1629
|
+
*/
|
|
1630
|
+
_upgradeStorage(snapshot, tailOps) {
|
|
1631
|
+
if (this._closed)
|
|
1632
|
+
return false;
|
|
1633
|
+
if (this._sealedRoots === null)
|
|
1634
|
+
return true;
|
|
1635
|
+
if (this._frozenVersion !== null)
|
|
1636
|
+
return false;
|
|
1637
|
+
const engine = new LoroDoc();
|
|
1638
|
+
if (snapshot !== null)
|
|
1639
|
+
engine.import(snapshot);
|
|
1640
|
+
for (const op of tailOps)
|
|
1641
|
+
importTailOp(engine, op);
|
|
1642
|
+
if (this._engineOptions.peerId !== undefined)
|
|
1643
|
+
engine.setPeerId(this._engineOptions.peerId);
|
|
1644
|
+
this._handles.swap(() => {
|
|
1645
|
+
this._rootKey = this._engineRootKey;
|
|
1646
|
+
engine.getMap(this._rootKey);
|
|
1647
|
+
this._engine = engine;
|
|
1648
|
+
this._undo = this._newUndoController(engine, this._engineOptions.undo);
|
|
1649
|
+
this._sealedRoots = null;
|
|
1650
|
+
this._frozenHead = null;
|
|
1651
|
+
this._frozenShown = null;
|
|
1652
|
+
this._frozenSeal = null;
|
|
1653
|
+
this._rootHandles.clear();
|
|
1654
|
+
this._orderedMapHandles.clear();
|
|
1655
|
+
});
|
|
1656
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Swap this Document's storage from a CRDT to frozen content, in place: the
|
|
1661
|
+
* counterpart of `_upgradeStorage`, for a frozen copy whose reload is served
|
|
1662
|
+
* the line as stored after the copy was opened on a CRDT.
|
|
1663
|
+
* @internal
|
|
1664
|
+
*
|
|
1665
|
+
* Returns false, leaving the CRDT in place, when the Document is showing a
|
|
1666
|
+
* version: the preview has no frozen counterpart, so the caller reloads.
|
|
1667
|
+
*/
|
|
1668
|
+
_downgradeStorage(content, seal) {
|
|
1669
|
+
if (this._closed || this._sealedRoots !== null || this._checkout !== null)
|
|
1670
|
+
return false;
|
|
1671
|
+
this._handles.swap(() => {
|
|
1672
|
+
this._frozenHead = content;
|
|
1673
|
+
this._frozenShown = content;
|
|
1674
|
+
this._frozenSeal = seal;
|
|
1675
|
+
this._sealedRoots = sealedRoots(() => this._frozenShown, () => this._handleOwner.assertWritable());
|
|
1676
|
+
this._rootHandles.clear();
|
|
1677
|
+
this._orderedMapHandles.clear();
|
|
1678
|
+
// Nothing reads the engine from here on. It is dropped rather than
|
|
1679
|
+
// freed, because a watcher an input follower attached to it may still
|
|
1680
|
+
// detach.
|
|
1681
|
+
this._unsubLoro?.();
|
|
1682
|
+
this._unsubLoro = null;
|
|
1683
|
+
this._unsubLocal?.();
|
|
1684
|
+
this._unsubLocal = null;
|
|
1685
|
+
this._undo?.free();
|
|
1686
|
+
this._undo = null;
|
|
1687
|
+
this._engine = null;
|
|
1688
|
+
});
|
|
1689
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
1690
|
+
return true;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Replace this Document's frozen content with other frozen content, in
|
|
1694
|
+
* place, re-delivering every handle's subscribers. A join serves a newer
|
|
1695
|
+
* seal when the document was edited and frozen again in between.
|
|
1696
|
+
* @internal
|
|
1697
|
+
*/
|
|
1698
|
+
_replaceSealedContent(content, seal) {
|
|
1699
|
+
if (this._closed || this._sealedRoots === null)
|
|
1700
|
+
return false;
|
|
1701
|
+
if (this._frozenVersion !== null)
|
|
1702
|
+
return false;
|
|
1703
|
+
this._frozenHead = content;
|
|
1704
|
+
this._frozenShown = content;
|
|
1705
|
+
this._frozenSeal = seal;
|
|
1706
|
+
this._sealedRoots.resync();
|
|
1707
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
// Revert the most recent local step. Returns `true` if an undo
|
|
1041
1711
|
// step was applied, `false` if the stack is empty. Remote peers'
|
|
1042
1712
|
// edits are not in this stack: Loro's UndoManager is local-peer-
|
|
1043
1713
|
// scoped, which is the user-visible behaviour every collaborative
|
|
@@ -1046,25 +1716,18 @@ export class Document {
|
|
|
1046
1716
|
// The reverted change is itself a local commit, so it ships over
|
|
1047
1717
|
// the WS transport like any other mutation and other peers see the
|
|
1048
1718
|
// state revert.
|
|
1719
|
+
//
|
|
1720
|
+
// The stack is the document's, across its streams: a step is every local
|
|
1721
|
+
// commit between two seals in whichever streams took one, and one `undo()`
|
|
1722
|
+
// reverts it in each of them. A stream that refuses writes takes no part.
|
|
1049
1723
|
undo() {
|
|
1050
1724
|
this._assertOpen();
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
// An undo produces a local commit exactly as `mutate` does, so it obeys
|
|
1058
|
-
// the same read-only gates: a view-only guest link (`_forcedReadOnly`)
|
|
1059
|
-
// and a pending content-replacing work-status (`_isLoadLocked()`), whose
|
|
1060
|
-
// whole purpose is to keep local ops from racing the incoming bytes.
|
|
1061
|
-
if (this._forcedReadOnly || this._isLoadLocked())
|
|
1062
|
-
return false;
|
|
1063
|
-
this._ensureLocalUpdatesSubscribed();
|
|
1064
|
-
// Tag the commit the UndoManager is about to make so the resulting
|
|
1065
|
-
// change reads back as `origin.source === 'undo'`, not `'local'`.
|
|
1066
|
-
this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
|
|
1067
|
-
return this._undo.undo();
|
|
1725
|
+
try {
|
|
1726
|
+
return this._undoGroup.replay('undo');
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
this._maybeFireUndoState();
|
|
1730
|
+
}
|
|
1068
1731
|
}
|
|
1069
1732
|
// Re-apply the most recently undone local commit. Returns `true`
|
|
1070
1733
|
// if a redo step was applied, `false` if the stack is empty. The
|
|
@@ -1075,15 +1738,70 @@ export class Document {
|
|
|
1075
1738
|
// composes correctly.
|
|
1076
1739
|
redo() {
|
|
1077
1740
|
this._assertOpen();
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1741
|
+
try {
|
|
1742
|
+
return this._undoGroup.replay('redo');
|
|
1743
|
+
}
|
|
1744
|
+
finally {
|
|
1745
|
+
this._maybeFireUndoState();
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
// Whether `undo()` / `redo()` must no-op. A replay produces local commits
|
|
1749
|
+
// exactly as `mutate` does, so it obeys every gate `mutate` does. During a
|
|
1750
|
+
// checkout the UndoManager still tracks the live head while reads go
|
|
1751
|
+
// through the preview, so a replay would be a silent live edit. A copy with
|
|
1752
|
+
// no CRDT (frozen content) has no undo stack at all. `undo()` / `redo()`
|
|
1753
|
+
// also no-op inside a `mutate` callback, whose open transaction holds
|
|
1754
|
+
// writes the replay would commit outside the op it ships.
|
|
1755
|
+
_undoBlocked() {
|
|
1756
|
+
return this._checkout !== null || this._undo === null || this._readOnlyBecause !== null;
|
|
1757
|
+
}
|
|
1758
|
+
// Tags each commit the step's replay makes, so the resulting change reads
|
|
1759
|
+
// back as `origin.source === 'undo'` / `'redo'`, not `'local'`.
|
|
1760
|
+
//
|
|
1761
|
+
// A step replays as one commit per commit it took, and those ship as ONE op
|
|
1762
|
+
// exported from the version before the replay, so an Undo of a long step is
|
|
1763
|
+
// one write for the server and one import for each peer.
|
|
1764
|
+
/** @internal Replay this stream's own part of the newest step that touched
|
|
1765
|
+
* it (undo) or of the step it most recently undid (redo), leaving the
|
|
1766
|
+
* document's other streams alone. `DocumentStream.undo` / `redo`. */
|
|
1767
|
+
_replayOwnUndoStep(dir) {
|
|
1768
|
+
this._assertOpen();
|
|
1769
|
+
try {
|
|
1770
|
+
if (this._mutateDepth > 0 || this._undoBlocked())
|
|
1771
|
+
return false;
|
|
1772
|
+
const tag = this._undo.peek(dir);
|
|
1773
|
+
return tag !== undefined && this._replayUndoStep(dir, tag);
|
|
1774
|
+
}
|
|
1775
|
+
finally {
|
|
1776
|
+
this._maybeFireUndoState();
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
/** @internal */
|
|
1780
|
+
_replayUndoStep(dir, tag) {
|
|
1084
1781
|
this._ensureLocalUpdatesSubscribed();
|
|
1085
|
-
|
|
1086
|
-
|
|
1782
|
+
const origin = dir === 'undo' ? UNDO_COMMIT_ORIGIN : REDO_COMMIT_ORIGIN;
|
|
1783
|
+
const tagCommit = () => this._doc.setNextCommitOrigin(origin);
|
|
1784
|
+
const before = this._doc.oplogVersion();
|
|
1785
|
+
const beforeHead = JSON.stringify(this._doc.oplogFrontiers());
|
|
1786
|
+
this._replayingUndo = true;
|
|
1787
|
+
this._replayChange = null;
|
|
1788
|
+
try {
|
|
1789
|
+
return this._undo.replayStep(dir, tag, tagCommit);
|
|
1790
|
+
}
|
|
1791
|
+
finally {
|
|
1792
|
+
this._replayingUndo = false;
|
|
1793
|
+
// Whatever the replay committed ships, a replay that threw part-way included.
|
|
1794
|
+
if (this._unsubLocal && JSON.stringify(this._doc.oplogFrontiers()) !== beforeHead) {
|
|
1795
|
+
this._shipLocalUpdate(this._doc.export({ mode: 'update', from: before }));
|
|
1796
|
+
}
|
|
1797
|
+
before.free();
|
|
1798
|
+
const source = this._replayChange;
|
|
1799
|
+
this._replayChange = null;
|
|
1800
|
+
if (source !== null) {
|
|
1801
|
+
this._subs.emit({ kind: 'change', source });
|
|
1802
|
+
}
|
|
1803
|
+
this._maybeFireUndoState();
|
|
1804
|
+
}
|
|
1087
1805
|
}
|
|
1088
1806
|
/**
|
|
1089
1807
|
* Seal the open undo group locally, so the NEXT local commit starts a
|
|
@@ -1094,38 +1812,64 @@ export class Document {
|
|
|
1094
1812
|
* @internal
|
|
1095
1813
|
*/
|
|
1096
1814
|
_sealUndoStep() {
|
|
1097
|
-
|
|
1098
|
-
|
|
1815
|
+
this._undoGroup.seal();
|
|
1816
|
+
}
|
|
1817
|
+
/** @internal */
|
|
1818
|
+
_undoController() {
|
|
1819
|
+
return this._undo;
|
|
1820
|
+
}
|
|
1821
|
+
/** @internal */
|
|
1822
|
+
_undoEligible() {
|
|
1823
|
+
return !this._closed && !this._undoBlocked();
|
|
1824
|
+
}
|
|
1825
|
+
/** @internal */
|
|
1826
|
+
_undoMutating() {
|
|
1827
|
+
return this._mutateDepth > 0;
|
|
1828
|
+
}
|
|
1829
|
+
// A version on screen holds every stream of the document at that moment,
|
|
1830
|
+
// so no stream replays a step while the document shows one, even a stream
|
|
1831
|
+
// whose own preview has not landed yet.
|
|
1832
|
+
_undoGroupBlocked() {
|
|
1833
|
+
return (this._closed ||
|
|
1834
|
+
this._checkout !== null ||
|
|
1835
|
+
this._previewVersion !== null ||
|
|
1836
|
+
this._versionBrowsing);
|
|
1837
|
+
}
|
|
1838
|
+
_newUndoController(loroDoc, options) {
|
|
1839
|
+
return new UndoController(loroDoc, options, this._undoGroup, {
|
|
1840
|
+
onFreshCommit: () => this._undoGroup.freshCommit(this),
|
|
1841
|
+
onStackChange: () => {
|
|
1842
|
+
if (!this._undoGroup.isOwner(this))
|
|
1843
|
+
this._undoGroup.changed();
|
|
1844
|
+
},
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
/** @internal Make this stream's undo part of its document's stack. Called
|
|
1848
|
+
* when the stream opens, before it has taken a local commit. */
|
|
1849
|
+
_joinUndoGroup(group) {
|
|
1850
|
+
this._undoGroup.delete(this);
|
|
1851
|
+
this._undoGroup = group;
|
|
1852
|
+
group.add(this);
|
|
1853
|
+
this._undo?.useClock(group);
|
|
1099
1854
|
}
|
|
1100
1855
|
// Whether `undo()` would have an effect right now. Feeds the
|
|
1101
1856
|
// commands machinery and the undo-state notifier; apps consume it
|
|
1102
1857
|
// through `subscribeUndoState` snapshots.
|
|
1858
|
+
//
|
|
1859
|
+
// The gates `undo()` enforces, so a subscribeUndoState-bound button
|
|
1860
|
+
// disables rather than inviting an undo the call would refuse: during a
|
|
1861
|
+
// checkout the stacks still track the live head while the UI shows the
|
|
1862
|
+
// preview, and a stream that refuses writes has nothing it may replay.
|
|
1103
1863
|
canUndo() {
|
|
1104
1864
|
if (this._closed)
|
|
1105
1865
|
return false;
|
|
1106
|
-
|
|
1107
|
-
// history, but the UI is showing the historical preview and
|
|
1108
|
-
// mutate is rejected. Surface that as "can't undo right now" so
|
|
1109
|
-
// subscribeUndoState-bound buttons disable correctly.
|
|
1110
|
-
if (this._checkout !== null)
|
|
1111
|
-
return false;
|
|
1112
|
-
// Fold the same read-only gates `undo()` enforces so a
|
|
1113
|
-
// subscribeUndoState-bound button disables while the doc is a view-only
|
|
1114
|
-
// guest link or load-locked, rather than inviting an undo the call
|
|
1115
|
-
// would refuse.
|
|
1116
|
-
if (this._forcedReadOnly || this._isLoadLocked())
|
|
1117
|
-
return false;
|
|
1118
|
-
return this._undo.canUndo();
|
|
1866
|
+
return this._undoGroup.canUndo();
|
|
1119
1867
|
}
|
|
1120
1868
|
// Whether `redo()` would have an effect right now. As above.
|
|
1121
1869
|
canRedo() {
|
|
1122
1870
|
if (this._closed)
|
|
1123
1871
|
return false;
|
|
1124
|
-
|
|
1125
|
-
return false;
|
|
1126
|
-
if (this._forcedReadOnly || this._isLoadLocked())
|
|
1127
|
-
return false;
|
|
1128
|
-
return this._undo.canRedo();
|
|
1872
|
+
return this._undoGroup.canRedo();
|
|
1129
1873
|
}
|
|
1130
1874
|
// The plain-object proxy callers mutate. The mutation runs against
|
|
1131
1875
|
// the local LoroDoc immediately (optimistic UI), then commit + the
|
|
@@ -1155,10 +1899,10 @@ export class Document {
|
|
|
1155
1899
|
// edits here exactly as it does through `doc.map(rootKey)`, and a write
|
|
1156
1900
|
// large enough to cross the paging threshold takes the paged shape from
|
|
1157
1901
|
// either.
|
|
1158
|
-
mutate(fn) {
|
|
1902
|
+
mutate(fn, options) {
|
|
1159
1903
|
this.map(this._rootKey).mutate((entities) => {
|
|
1160
1904
|
fn({ entities });
|
|
1161
|
-
});
|
|
1905
|
+
}, options);
|
|
1162
1906
|
}
|
|
1163
1907
|
// Install / replace the local-op send hook; `null` detaches it.
|
|
1164
1908
|
//
|
|
@@ -1200,7 +1944,7 @@ export class Document {
|
|
|
1200
1944
|
/** @internal Whether this Document is currently showing a historical
|
|
1201
1945
|
* preview rather than its live head. */
|
|
1202
1946
|
_isCheckedOut() {
|
|
1203
|
-
return this.
|
|
1947
|
+
return this._previewVersion !== null;
|
|
1204
1948
|
}
|
|
1205
1949
|
/** @internal Whether this Document is pinned to the version it was opened
|
|
1206
1950
|
* at, with no live channel behind it. Stepping and returning to the head
|
|
@@ -1211,7 +1955,7 @@ export class Document {
|
|
|
1211
1955
|
// Switch the Document to a historical version, in place. @internal
|
|
1212
1956
|
// (the hosted `history` command drives this). After the promise
|
|
1213
1957
|
// resolves, `doc.readOnly` is `true`,
|
|
1214
|
-
// `doc.
|
|
1958
|
+
// `doc.previewVersion` is the server's effective version (which may round
|
|
1215
1959
|
// down from the requested one when compaction has TTL'd the exact
|
|
1216
1960
|
// op), and `mutate` throws. `subscribe` fires once after the swap so
|
|
1217
1961
|
// the app's render loop redraws, and the app returns to live through
|
|
@@ -1239,7 +1983,7 @@ export class Document {
|
|
|
1239
1983
|
throw new Error('_checkoutVersion: no fetcher attached. Opened via the SDK factory?');
|
|
1240
1984
|
}
|
|
1241
1985
|
const generation = ++this._checkoutGeneration;
|
|
1242
|
-
const {
|
|
1986
|
+
const { content, tailOpBlobs, effectiveVersion, streamSeqs } = await this._checkoutFetcher(this._docId, version, this._branchId);
|
|
1243
1987
|
if (this._closed || this._suspended)
|
|
1244
1988
|
return;
|
|
1245
1989
|
// A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
|
|
@@ -1248,13 +1992,21 @@ export class Document {
|
|
|
1248
1992
|
if (generation !== this._checkoutGeneration)
|
|
1249
1993
|
return;
|
|
1250
1994
|
this._checkoutStreamSeqs = streamSeqs ?? {};
|
|
1995
|
+
// Handles over frozen content read frozen content, so the version is
|
|
1996
|
+
// swapped in as frozen content whatever encoding the server stores it in.
|
|
1997
|
+
if (this._sealedRoots !== null) {
|
|
1998
|
+
this._frozenShown = frozenPreview(content, tailOpBlobs);
|
|
1999
|
+
this._frozenVersion = effectiveVersion;
|
|
2000
|
+
this._sealedRoots.resync();
|
|
2001
|
+
this._fireSubscribers({ kind: 'history', version: effectiveVersion });
|
|
2002
|
+
await this._checkoutOpenStreams(generation);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
1251
2005
|
// Materialise the preview into a fresh LoroDoc. The previous
|
|
1252
2006
|
// checkout's preview LoroDoc, if any, is freed.
|
|
1253
|
-
const previewDoc =
|
|
1254
|
-
if (snapshotBytes !== null)
|
|
1255
|
-
previewDoc.import(snapshotBytes);
|
|
2007
|
+
const previewDoc = engineFrom(content);
|
|
1256
2008
|
for (const blob of tailOpBlobs)
|
|
1257
|
-
previewDoc
|
|
2009
|
+
importTailOp(previewDoc, blob);
|
|
1258
2010
|
const previewRoot = previewDoc.getMap(this._rootKey);
|
|
1259
2011
|
const old = this._checkout;
|
|
1260
2012
|
// Snapshot the handles' keys before the active-source pointer moves (see
|
|
@@ -1280,7 +2032,7 @@ export class Document {
|
|
|
1280
2032
|
// container captured at attach time (the OLD preview's); freeing first
|
|
1281
2033
|
// would run that detach against a freed LoroDoc. This is the same
|
|
1282
2034
|
// teardown-before-free order `close()` documents.
|
|
1283
|
-
if (old && old.previewDoc !== this.
|
|
2035
|
+
if (old && old.previewDoc !== this._engine) {
|
|
1284
2036
|
try {
|
|
1285
2037
|
old.previewDoc.free();
|
|
1286
2038
|
}
|
|
@@ -1348,6 +2100,14 @@ export class Document {
|
|
|
1348
2100
|
// `_checkout` still null, and this is the signal that supersedes it when
|
|
1349
2101
|
// it resolves.
|
|
1350
2102
|
const generation = (this._checkoutGeneration += 1);
|
|
2103
|
+
if (this._sealedRoots !== null && this._frozenVersion !== null) {
|
|
2104
|
+
this._frozenShown = this._frozenHead;
|
|
2105
|
+
this._frozenVersion = null;
|
|
2106
|
+
this._sealedRoots.resync();
|
|
2107
|
+
this._fireSubscribers({ kind: 'return-to-head' });
|
|
2108
|
+
this._returnOpenStreams(generation);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
1351
2111
|
if (this._checkout === null)
|
|
1352
2112
|
return;
|
|
1353
2113
|
const old = this._checkout;
|
|
@@ -1362,7 +2122,7 @@ export class Document {
|
|
|
1362
2122
|
// Free the preview only after the rebind: `_rebindRootHandles` detaches
|
|
1363
2123
|
// each subscriber against the preview container it was attached to, so
|
|
1364
2124
|
// freeing first would be a use-after-free (see `_checkoutVersion`).
|
|
1365
|
-
if (old.previewDoc !== this.
|
|
2125
|
+
if (old.previewDoc !== this._engine) {
|
|
1366
2126
|
try {
|
|
1367
2127
|
old.previewDoc.free();
|
|
1368
2128
|
}
|
|
@@ -1372,9 +2132,12 @@ export class Document {
|
|
|
1372
2132
|
}
|
|
1373
2133
|
this._fireSubscribers({ kind: 'return-to-head' });
|
|
1374
2134
|
this._maybeFireUndoState();
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2135
|
+
this._returnOpenStreams(generation);
|
|
2136
|
+
}
|
|
2137
|
+
// Return every opened stream to its live head too, symmetric with the
|
|
2138
|
+
// checkout path. Fire-and-forget (returnToHead is synchronous and has no
|
|
2139
|
+
// fetch): a stream not previewing is a no-op.
|
|
2140
|
+
_returnOpenStreams(generation) {
|
|
1378
2141
|
this._checkoutStreamSeqs = null;
|
|
1379
2142
|
for (const openPromise of this._streamOpens.values()) {
|
|
1380
2143
|
void openPromise
|
|
@@ -1399,7 +2162,22 @@ export class Document {
|
|
|
1399
2162
|
// and the caller moves the active-source pointer between the two calls.
|
|
1400
2163
|
// Callers must not return early in that window; a snapshot left unconsumed
|
|
1401
2164
|
// would be diffed against a later, unrelated swap.
|
|
2165
|
+
// Detach every cached root handle's live subscriptions. Reached only on a
|
|
2166
|
+
// CRDT document: the teardown, the key snapshot and the rebind below are
|
|
2167
|
+
// `handles.ts`'s own internal protocol, spoken by the handles it produces,
|
|
2168
|
+
// and a stored record set's roots hold no container subscription to detach,
|
|
2169
|
+
// no anchor to re-point and no prior keys to diff against a swap.
|
|
2170
|
+
_teardownRootHandles() {
|
|
2171
|
+
for (const entry of this._rootHandles.values()) {
|
|
2172
|
+
if (entry.kind === 'text')
|
|
2173
|
+
entry.handle._teardown();
|
|
2174
|
+
else
|
|
2175
|
+
teardownRootHandle(entry.handle);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
1402
2178
|
_snapshotRootHandleKeys() {
|
|
2179
|
+
if (this._sealedRoots !== null)
|
|
2180
|
+
return;
|
|
1403
2181
|
for (const entry of this._rootHandles.values()) {
|
|
1404
2182
|
if (entry.kind === 'map')
|
|
1405
2183
|
snapshotRootMapKeys(entry.handle);
|
|
@@ -1412,6 +2190,8 @@ export class Document {
|
|
|
1412
2190
|
// `getMap` / `getList` are get-or-create, so a container absent in the
|
|
1413
2191
|
// preview resolves to an empty one and its subscribers redraw to empty.
|
|
1414
2192
|
_rebindRootHandles(source) {
|
|
2193
|
+
if (this._sealedRoots !== null)
|
|
2194
|
+
return;
|
|
1415
2195
|
// Two passes, not one per handle: rebind every anchor to the new source
|
|
1416
2196
|
// first, then resync. A composed handle can read a sibling root while
|
|
1417
2197
|
// firing (the ordered map reads its order list during its map redraw), so
|
|
@@ -1570,13 +2350,11 @@ export class Document {
|
|
|
1570
2350
|
// both alongside the publish-over that `pushToDrive` does. Driven by the
|
|
1571
2351
|
// hosted toolbar.
|
|
1572
2352
|
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
// returned `newDocId` lets a multi-doc app do its own switch. Resolves
|
|
1579
|
-
// with the outcome, or `null` when the user dismissed the dialog.
|
|
2353
|
+
// Taking the drive's version keeps this document's id: the open copy moves
|
|
2354
|
+
// onto that version when it lands (`onGone('moved')`, which a session
|
|
2355
|
+
// re-opens). A `newDocId` in the result is opened through the same
|
|
2356
|
+
// `openSiblingDoc` path the history fork uses. Resolves with the outcome,
|
|
2357
|
+
// or `null` when the user dismissed the dialog.
|
|
1580
2358
|
// Throws when there is no doc identity or no resolver is attached.
|
|
1581
2359
|
/** @internal */
|
|
1582
2360
|
async resolveDrive() {
|
|
@@ -1590,20 +2368,8 @@ export class Document {
|
|
|
1590
2368
|
return await this._driveResolver();
|
|
1591
2369
|
}
|
|
1592
2370
|
// Fetch the file a document was uploaded as: a short-lived presigned GET
|
|
1593
|
-
// plus the file's name / content type / size.
|
|
1594
|
-
//
|
|
1595
|
-
// content into this document through the normal handle surface
|
|
1596
|
-
// (`doc.map('instances').mutate(...)`):
|
|
1597
|
-
//
|
|
1598
|
-
// const src = await doc.sourceBlob();
|
|
1599
|
-
// const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
1600
|
-
// const records = parseMyFormat(bytes); // app's own parser
|
|
1601
|
-
// doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
|
|
1602
|
-
//
|
|
1603
|
-
// A source file is a set of one or more streams: `src.streams` lists
|
|
1604
|
-
// every stream (primary first, so a single-stream file has one entry),
|
|
1605
|
-
// each with its own URL, so a multi-stream format (a glTF with its
|
|
1606
|
-
// `.bin` buffer) fetches only the streams it needs.
|
|
2371
|
+
// plus the file's name / content type / size. The document's content is
|
|
2372
|
+
// what its importer returned for this file, sealed by the platform.
|
|
1607
2373
|
//
|
|
1608
2374
|
// Rejects with a `BrassError` when the document holds no such file, or
|
|
1609
2375
|
// the caller cannot reach it (both 404).
|
|
@@ -1693,21 +2459,22 @@ export class Document {
|
|
|
1693
2459
|
throw new Error(`This document has no stream named "${name}". ` +
|
|
1694
2460
|
`doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`);
|
|
1695
2461
|
}
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
2462
|
+
// Every stream the platform writes is a document line and reports
|
|
2463
|
+
// `'crdt'`, whether it is stored as a CRDT or frozen. Any other value
|
|
2464
|
+
// names a kind this version does not know, so it is refused rather
|
|
2465
|
+
// than joined as a line.
|
|
1700
2466
|
if (info.format !== 'crdt') {
|
|
1701
2467
|
throw new Error(`Stream "${name}" has format "${info.format}", which this ` +
|
|
1702
|
-
'SDK version cannot open
|
|
2468
|
+
'SDK version cannot open.');
|
|
1703
2469
|
}
|
|
1704
2470
|
const inner = await runtime.openLine(info.line_id);
|
|
2471
|
+
inner._joinUndoGroup(this._undoGroup);
|
|
1705
2472
|
const stream = new DocumentStream(name, inner, true);
|
|
1706
2473
|
try {
|
|
1707
2474
|
// Opened while a version preview is active: bring the new stream to
|
|
1708
2475
|
// the same point in time rather than showing it live under a
|
|
1709
2476
|
// historical default.
|
|
1710
|
-
if (this.
|
|
2477
|
+
if (this._previewVersion !== null) {
|
|
1711
2478
|
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
|
|
1712
2479
|
}
|
|
1713
2480
|
}
|
|
@@ -1739,6 +2506,9 @@ export class Document {
|
|
|
1739
2506
|
stream._inner._observeSavingCount(() => {
|
|
1740
2507
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
1741
2508
|
});
|
|
2509
|
+
stream._inner._observeConnectionNotice((kind) => {
|
|
2510
|
+
this._showConnectionNotice(kind);
|
|
2511
|
+
});
|
|
1742
2512
|
if (this._suspended)
|
|
1743
2513
|
stream._suspend();
|
|
1744
2514
|
}, () => {
|
|
@@ -1819,6 +2589,7 @@ export class Document {
|
|
|
1819
2589
|
// transport about to be torn down and is never persisted.
|
|
1820
2590
|
for (const stream of this._openStreams.values()) {
|
|
1821
2591
|
stream._inner._observeSavingCount(null);
|
|
2592
|
+
stream._inner._observeConnectionNotice(null);
|
|
1822
2593
|
stream._close();
|
|
1823
2594
|
}
|
|
1824
2595
|
// An open still in flight has no Document to close yet, so it closes on
|
|
@@ -1850,17 +2621,21 @@ export class Document {
|
|
|
1850
2621
|
stream._resume();
|
|
1851
2622
|
}
|
|
1852
2623
|
}
|
|
1853
|
-
//
|
|
1854
|
-
// history
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2624
|
+
// Mark the end of a user action: seals the undo step at once, and records a
|
|
2625
|
+
// version-history marker covering this call (calls made while a write is
|
|
2626
|
+
// in flight share the next one, written at the latest state). Call it
|
|
2627
|
+
// after every action and do not await it in the action's handler.
|
|
2628
|
+
// Resolves once the marker covering the call is written. While `readOnly`
|
|
2629
|
+
// is true it writes nothing and resolves at once (as `undo` / `redo` do
|
|
2630
|
+
// nothing there).
|
|
2631
|
+
createCheckpoint() {
|
|
2632
|
+
if (this._isLive && this.readOnly)
|
|
2633
|
+
return Promise.resolve();
|
|
2634
|
+
const written = this._checkpoints.create().then(() => undefined);
|
|
2635
|
+
// Apps call this without awaiting it; a rejection still reaches a caller
|
|
2636
|
+
// that awaits.
|
|
2637
|
+
written.catch(() => { });
|
|
2638
|
+
return written;
|
|
1864
2639
|
}
|
|
1865
2640
|
// Configure the implicit auto-checkpoint debounce. Default is 60s
|
|
1866
2641
|
// after the last local edit; pass `0` to disable. Apps in test
|
|
@@ -1888,6 +2663,23 @@ export class Document {
|
|
|
1888
2663
|
this._name = name;
|
|
1889
2664
|
this._fireSubscribers({ kind: 'commands' });
|
|
1890
2665
|
}
|
|
2666
|
+
/** When Brass will remove this document, or `null` when it is kept. Set
|
|
2667
|
+
* for a document created while signed out; a sign-in claims it and the
|
|
2668
|
+
* claimed document reads `null`. */
|
|
2669
|
+
get expiresAt() {
|
|
2670
|
+
return this._expiresAt;
|
|
2671
|
+
}
|
|
2672
|
+
/** @internal */
|
|
2673
|
+
_setExpiresAt(expiresAt) {
|
|
2674
|
+
const same = (this._expiresAt === null && expiresAt === null) ||
|
|
2675
|
+
(this._expiresAt !== null &&
|
|
2676
|
+
expiresAt !== null &&
|
|
2677
|
+
this._expiresAt.getTime() === expiresAt.getTime());
|
|
2678
|
+
if (same)
|
|
2679
|
+
return;
|
|
2680
|
+
this._expiresAt = expiresAt;
|
|
2681
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
2682
|
+
}
|
|
1891
2683
|
/** @internal */
|
|
1892
2684
|
setHostedUiProvider(provider) {
|
|
1893
2685
|
this._commandMenu.setHostedUiProvider(provider);
|
|
@@ -1948,6 +2740,13 @@ export class Document {
|
|
|
1948
2740
|
else
|
|
1949
2741
|
console.error('[Brass]', err);
|
|
1950
2742
|
}
|
|
2743
|
+
// The console keeps the original stack for the developer; the app's
|
|
2744
|
+
// `onError` gets a sentence it can show the reader.
|
|
2745
|
+
_reportSubscriberThrow(err) {
|
|
2746
|
+
console.error('[Brass] subscriber threw:', err);
|
|
2747
|
+
if (this._errorReporter)
|
|
2748
|
+
this._errorReporter(appCallbackError(err));
|
|
2749
|
+
}
|
|
1951
2750
|
/** @internal Report a failure from the SDK's own chrome (the version
|
|
1952
2751
|
* bar's intents) through the same sink the built-in commands use, so a
|
|
1953
2752
|
* refused checkout reaches the app's `onError` rather than being dropped
|
|
@@ -1971,6 +2770,9 @@ export class Document {
|
|
|
1971
2770
|
// itself, so the registry's total already carries one entry per stream.
|
|
1972
2771
|
trackUnsavedWork(this, this._outstandingByClientId.size);
|
|
1973
2772
|
this._savingCountObserver?.();
|
|
2773
|
+
if (this._workStatus.notice !== null && this.savingCount === 0) {
|
|
2774
|
+
this._workStatus.setNotice(null);
|
|
2775
|
+
}
|
|
1974
2776
|
}
|
|
1975
2777
|
this._ensureLoroSubscribed();
|
|
1976
2778
|
this._subs.emit(event);
|
|
@@ -2082,8 +2884,9 @@ export class Document {
|
|
|
2082
2884
|
// Returns true if the clientOpId was tracked (lets the caller detect
|
|
2083
2885
|
// a stale ack).
|
|
2084
2886
|
/** @internal */
|
|
2085
|
-
resolveOutstandingOp(clientOpId) {
|
|
2887
|
+
resolveOutstandingOp(clientOpId, seq) {
|
|
2086
2888
|
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
2889
|
+
this._provisionalInputs.acked(clientOpId, seq);
|
|
2087
2890
|
// Release this chunk from the resync-push loop guard once its op is
|
|
2088
2891
|
// acked: the pushed op is now in the log, so the next handshake's
|
|
2089
2892
|
// tail covers it and a fresh push is welcome (it would be a genuine
|
|
@@ -2135,9 +2938,161 @@ export class Document {
|
|
|
2135
2938
|
* telling the reader first, because an app that renders nothing for it is
|
|
2136
2939
|
* exactly the case that must not leave someone on a diverged copy.
|
|
2137
2940
|
*/
|
|
2138
|
-
|
|
2941
|
+
_reopen() {
|
|
2139
2942
|
this._markGone('moved');
|
|
2140
2943
|
}
|
|
2944
|
+
/** @internal The app registered its importer, so inputs whose open stopped
|
|
2945
|
+
* waiting for it run again. */
|
|
2946
|
+
_importerRegistered() {
|
|
2947
|
+
this._inputFollowers.importerRegistered();
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* @internal Typed on `ReadOnlyDocument`. A frozen copy loads the document as
|
|
2951
|
+
* it stands now, in place: the handles stay the same and their subscribers
|
|
2952
|
+
* are told what changed. A live read-only copy already holds the document as
|
|
2953
|
+
* it stands, so there is nothing to load. An editable copy is reopened
|
|
2954
|
+
* (`_reopen`).
|
|
2955
|
+
*/
|
|
2956
|
+
reload() {
|
|
2957
|
+
this._assertOpen();
|
|
2958
|
+
const reloader = this._frozenReloader;
|
|
2959
|
+
if (reloader === null) {
|
|
2960
|
+
if (!this.readOnly)
|
|
2961
|
+
this._reopen();
|
|
2962
|
+
return Promise.resolve();
|
|
2963
|
+
}
|
|
2964
|
+
// One reload at a time: a call while one runs shares it, since both ask
|
|
2965
|
+
// for the document as it stands once it lands.
|
|
2966
|
+
if (this._reloading !== null)
|
|
2967
|
+
return this._reloading;
|
|
2968
|
+
const run = (async () => {
|
|
2969
|
+
try {
|
|
2970
|
+
// Cleared before the join, so a change landing during it is reported
|
|
2971
|
+
// against the load it may or may not have reached.
|
|
2972
|
+
this._changed = false;
|
|
2973
|
+
this._stopChangeNotice?.();
|
|
2974
|
+
this._stopChangeNotice = null;
|
|
2975
|
+
await reloader();
|
|
2976
|
+
}
|
|
2977
|
+
finally {
|
|
2978
|
+
this._reloading = null;
|
|
2979
|
+
if (!this._closed && (this._changedListeners.size > 0 || this._statusWatches > 0)) {
|
|
2980
|
+
this._watchForChange();
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
})();
|
|
2984
|
+
this._reloading = run;
|
|
2985
|
+
return run;
|
|
2986
|
+
}
|
|
2987
|
+
async reopen(options) {
|
|
2988
|
+
this._assertOpen();
|
|
2989
|
+
if (options?.frozen === true && options.readOnly === false) {
|
|
2990
|
+
throw new TypeError('reopen: a frozen copy is read-only, so `readOnly: false` cannot apply');
|
|
2991
|
+
}
|
|
2992
|
+
const mode = options?.frozen === true ? 'frozen' : options?.readOnly === true ? 'read' : 'edit';
|
|
2993
|
+
const pending = this._pendingReopen;
|
|
2994
|
+
if (pending !== null) {
|
|
2995
|
+
if (pending.mode === mode)
|
|
2996
|
+
return pending.promise;
|
|
2997
|
+
// The later mode wins: the earlier call rejects and the copy it opens is
|
|
2998
|
+
// reopened in this mode, so every copy opened is either returned or closed.
|
|
2999
|
+
pending.superseded = true;
|
|
3000
|
+
return this._trackReopen(mode, async () => {
|
|
3001
|
+
const earlier = await pending.result.catch(() => null);
|
|
3002
|
+
if (earlier !== null) {
|
|
3003
|
+
return earlier.reopen(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' });
|
|
3004
|
+
}
|
|
3005
|
+
const reopener = this._reopenerFor(mode);
|
|
3006
|
+
if (reopener === null)
|
|
3007
|
+
return this;
|
|
3008
|
+
return this._runReopen(mode, reopener);
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
const reopener = this._reopenerFor(mode);
|
|
3012
|
+
if (reopener === null)
|
|
3013
|
+
return this;
|
|
3014
|
+
return this._trackReopen(mode, () => this._runReopen(mode, reopener));
|
|
3015
|
+
}
|
|
3016
|
+
// The layer that reopens this copy in `mode`, or `null` when it is already
|
|
3017
|
+
// in that mode. Throws when it cannot be reopened so.
|
|
3018
|
+
_reopenerFor(mode) {
|
|
3019
|
+
// A copy showing a historical version is not at the head in any mode, so
|
|
3020
|
+
// every reopen loads the head.
|
|
3021
|
+
// A copy locked over a refused change shows something the platform does
|
|
3022
|
+
// not, so even a reopen to read builds a new one.
|
|
3023
|
+
const diverged = this._forcedReadOnly && this._readOnlyReason === 'unsaved';
|
|
3024
|
+
if (mode === this._openMode && this._previewVersion === null && !diverged) {
|
|
3025
|
+
return null;
|
|
3026
|
+
}
|
|
3027
|
+
if (mode === 'edit') {
|
|
3028
|
+
const refusal = this._editRefusal;
|
|
3029
|
+
if (refusal !== null)
|
|
3030
|
+
throw new BrassError(refusal, { status: 403 });
|
|
3031
|
+
}
|
|
3032
|
+
const reopener = this._reopener;
|
|
3033
|
+
if (reopener === null) {
|
|
3034
|
+
throw new BrassError('This document cannot be reopened', { status: 0 });
|
|
3035
|
+
}
|
|
3036
|
+
return reopener;
|
|
3037
|
+
}
|
|
3038
|
+
async _runReopen(mode, reopener) {
|
|
3039
|
+
if (this._openMode === 'edit') {
|
|
3040
|
+
// A reopen called from inside a `mutate` callback runs before that
|
|
3041
|
+
// mutation commits, so the wait starts once it has.
|
|
3042
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3043
|
+
if (!(await whenSaved(this))) {
|
|
3044
|
+
throw new BrassError('Changes to this document have not finished saving', {
|
|
3045
|
+
status: 0,
|
|
3046
|
+
});
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
return reopener(mode);
|
|
3050
|
+
}
|
|
3051
|
+
// Records `run` as the reopen in flight, which a second call to the same
|
|
3052
|
+
// mode shares and a call to another mode supersedes. `reopening` holds until
|
|
3053
|
+
// the last overlapping reopen settles.
|
|
3054
|
+
_trackReopen(mode, run) {
|
|
3055
|
+
this._setReopening(true);
|
|
3056
|
+
const entry = { mode, superseded: false };
|
|
3057
|
+
this._pendingReopen = entry;
|
|
3058
|
+
entry.result = run().finally(() => {
|
|
3059
|
+
if (this._pendingReopen !== entry)
|
|
3060
|
+
return;
|
|
3061
|
+
this._pendingReopen = null;
|
|
3062
|
+
this._setReopening(false);
|
|
3063
|
+
});
|
|
3064
|
+
entry.promise = entry.result.then((next) => {
|
|
3065
|
+
if (entry.superseded) {
|
|
3066
|
+
throw new BrassError('The reopen was replaced by a later reopen', { status: 0 });
|
|
3067
|
+
}
|
|
3068
|
+
return next;
|
|
3069
|
+
});
|
|
3070
|
+
return entry.promise;
|
|
3071
|
+
}
|
|
3072
|
+
_setReopening(reopening) {
|
|
3073
|
+
if (this._reopening === reopening)
|
|
3074
|
+
return;
|
|
3075
|
+
this._reopening = reopening;
|
|
3076
|
+
if (!this._isLive)
|
|
3077
|
+
return;
|
|
3078
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
3079
|
+
this._maybeFireUndoState();
|
|
3080
|
+
}
|
|
3081
|
+
/** @internal Which of the three open modes this copy is in. */
|
|
3082
|
+
get _openMode() {
|
|
3083
|
+
if (this._frozenReloader !== null)
|
|
3084
|
+
return 'frozen';
|
|
3085
|
+
return this._forcedReadOnly ? 'read' : 'edit';
|
|
3086
|
+
}
|
|
3087
|
+
/** @internal Install what `reopen` calls. */
|
|
3088
|
+
_setReopener(reopener) {
|
|
3089
|
+
this._reopener = reopener;
|
|
3090
|
+
}
|
|
3091
|
+
/** @internal What `reopen` calls, so a layer that takes over this copy can
|
|
3092
|
+
* fall back to the one it replaced. */
|
|
3093
|
+
get _reopenerFn() {
|
|
3094
|
+
return this._reopener;
|
|
3095
|
+
}
|
|
2141
3096
|
// Internal: forget an outstanding op the server TERMINALLY rejected (an
|
|
2142
3097
|
// `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
|
|
2143
3098
|
// server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
|
|
@@ -2156,6 +3111,7 @@ export class Document {
|
|
|
2156
3111
|
opts = {}) {
|
|
2157
3112
|
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
2158
3113
|
this._resyncPushClientOpIds.delete(clientOpId);
|
|
3114
|
+
this._provisionalInputs.failed(clientOpId);
|
|
2159
3115
|
if (wasTracked) {
|
|
2160
3116
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
2161
3117
|
if (opts.report !== false)
|
|
@@ -2214,6 +3170,9 @@ export class Document {
|
|
|
2214
3170
|
_ensureLocalUpdatesSubscribed() {
|
|
2215
3171
|
if (this._unsubLocal)
|
|
2216
3172
|
return;
|
|
3173
|
+
// Frozen content takes no write, so there is no local update to send.
|
|
3174
|
+
if (this._sealedRoots !== null)
|
|
3175
|
+
return;
|
|
2217
3176
|
if (!this._localOpHook) {
|
|
2218
3177
|
this._reportTransportUnavailable();
|
|
2219
3178
|
return;
|
|
@@ -2223,26 +3182,31 @@ export class Document {
|
|
|
2223
3182
|
// does NOT trigger this callback, so we don't risk feeding the
|
|
2224
3183
|
// same op back to the server in a loop.
|
|
2225
3184
|
this._unsubLocal = this._doc.subscribeLocalUpdates((bytes) => {
|
|
2226
|
-
|
|
2227
|
-
if (
|
|
2228
|
-
return;
|
|
2229
|
-
// Copy: Loro reuses its internal buffer across callback calls in
|
|
2230
|
-
// some builds. Hand the consumer a stable view so the eventual
|
|
2231
|
-
// WS send sees the correct bytes regardless of when the send
|
|
2232
|
-
// actually flushes (the WS client may queue the frame).
|
|
2233
|
-
const copy = new Uint8Array(bytes.byteLength);
|
|
2234
|
-
copy.set(bytes);
|
|
2235
|
-
const chunks = this.splitLocalUpdate(copy);
|
|
2236
|
-
if (chunks === null) {
|
|
2237
|
-
this._shipLocalOp(copy);
|
|
3185
|
+
// A replay's commits ship together once it ends (`_replayUndo`).
|
|
3186
|
+
if (this._replayingUndo)
|
|
2238
3187
|
return;
|
|
2239
|
-
|
|
2240
|
-
// In order: each chunk continues the one before it, so a peer applying
|
|
2241
|
-
// them out of sequence holds the tail unapplied until the gap closes.
|
|
2242
|
-
for (const chunk of chunks)
|
|
2243
|
-
this._shipLocalOp(chunk);
|
|
3188
|
+
this._shipLocalUpdate(bytes);
|
|
2244
3189
|
});
|
|
2245
3190
|
}
|
|
3191
|
+
_shipLocalUpdate(bytes) {
|
|
3192
|
+
if (!this._localOpHook)
|
|
3193
|
+
return;
|
|
3194
|
+
// Copy: Loro reuses its internal buffer across callback calls in
|
|
3195
|
+
// some builds. Hand the consumer a stable view so the eventual
|
|
3196
|
+
// WS send sees the correct bytes regardless of when the send
|
|
3197
|
+
// actually flushes (the WS client may queue the frame).
|
|
3198
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
3199
|
+
copy.set(bytes);
|
|
3200
|
+
const chunks = this.splitLocalUpdate(copy);
|
|
3201
|
+
if (chunks === null) {
|
|
3202
|
+
this._shipLocalOp(copy);
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
// In order: each chunk continues the one before it, so a peer applying
|
|
3206
|
+
// them out of sequence holds the tail unapplied until the gap closes.
|
|
3207
|
+
for (const chunk of chunks)
|
|
3208
|
+
this._shipLocalOp(chunk);
|
|
3209
|
+
}
|
|
2246
3210
|
// Hand one op blob to the transport and count it as unsaved, or keep it
|
|
2247
3211
|
// local when the server has evicted this document.
|
|
2248
3212
|
//
|
|
@@ -2265,6 +3229,7 @@ export class Document {
|
|
|
2265
3229
|
return null;
|
|
2266
3230
|
const clientOpId = hook(copy);
|
|
2267
3231
|
this._outstandingByClientId.set(clientOpId, copy);
|
|
3232
|
+
this._shipCapture?.push(clientOpId);
|
|
2268
3233
|
// The local mutate has already fired `subscribe` (the Loro
|
|
2269
3234
|
// event), but `savingCount` just incremented; fire one more
|
|
2270
3235
|
// synthetic event so a subscribe handler bound on `savingCount`
|
|
@@ -2290,6 +3255,9 @@ export class Document {
|
|
|
2290
3255
|
return;
|
|
2291
3256
|
if (this._unsubLoro)
|
|
2292
3257
|
return;
|
|
3258
|
+
// Frozen roots deliver their own subscribers; there is no engine to hear.
|
|
3259
|
+
if (this._sealedRoots !== null)
|
|
3260
|
+
return;
|
|
2293
3261
|
this._unsubLoro = this._doc.subscribe((event) => {
|
|
2294
3262
|
// One channel: every event reaches every subscriber, whatever
|
|
2295
3263
|
// produced it. The app reconciles by value and reads the cause off
|
|
@@ -2301,10 +3269,15 @@ export class Document {
|
|
|
2301
3269
|
// build on them, which pins the storage engine through a surface that
|
|
2302
3270
|
// never promised it. The cause survives as `source`, the same
|
|
2303
3271
|
// engine-neutral vocabulary the handle surface reports.
|
|
2304
|
-
this.
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
3272
|
+
if (this._replayingUndo) {
|
|
3273
|
+
this._replayChange = changeSourceOf(event);
|
|
3274
|
+
}
|
|
3275
|
+
else {
|
|
3276
|
+
this._subs.emit({
|
|
3277
|
+
kind: 'change',
|
|
3278
|
+
source: changeSourceOf(event),
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
2308
3281
|
const isLocalCommit = event.by === 'local';
|
|
2309
3282
|
this._maybeFireUndoState();
|
|
2310
3283
|
// A local commit (mutate / undo / redo) schedules a debounced
|
|
@@ -2374,7 +3347,12 @@ export class Document {
|
|
|
2374
3347
|
// suspend like every other app-side subscriber.
|
|
2375
3348
|
subscribeUndoState(cb) {
|
|
2376
3349
|
this._assertOpen();
|
|
2377
|
-
|
|
3350
|
+
// A copy that cannot write never has anything of its own to undo (the
|
|
3351
|
+
// reason it cannot write does not lift while it is open), so it does not
|
|
3352
|
+
// listen to its own engine. A stream of the document can still take
|
|
3353
|
+
// writes, and reports them through the undo group.
|
|
3354
|
+
if (!this._forcedReadOnly)
|
|
3355
|
+
this._ensureLoroSubscribed();
|
|
2378
3356
|
this._undoStateSubs.add(cb);
|
|
2379
3357
|
// Fire immediately with current state so the consumer does
|
|
2380
3358
|
// not need separate initial-paint code. Capture the values
|
|
@@ -2401,8 +3379,16 @@ export class Document {
|
|
|
2401
3379
|
// Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
|
|
2402
3380
|
// Called from `_ensureLoroSubscribed`'s event dispatch.
|
|
2403
3381
|
_maybeFireUndoState() {
|
|
3382
|
+
// A stream's undo state is its document's: whatever moved here (a write
|
|
3383
|
+
// landing, the stream turning read-only) re-reads the document's.
|
|
3384
|
+
if (!this._undoGroup.isOwner(this))
|
|
3385
|
+
this._undoGroup.changed();
|
|
2404
3386
|
if (this._undoStateSubs.size === 0)
|
|
2405
3387
|
return;
|
|
3388
|
+
// A step can replay as several commits, and each fires a Loro event
|
|
3389
|
+
// before the stacks settle; `_replayUndo` fires once at the end.
|
|
3390
|
+
if (this._replayingUndo)
|
|
3391
|
+
return;
|
|
2406
3392
|
const canUndo = this.canUndo();
|
|
2407
3393
|
const canRedo = this.canRedo();
|
|
2408
3394
|
if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
|
|
@@ -2444,41 +3430,270 @@ export class Document {
|
|
|
2444
3430
|
this._assertOpen();
|
|
2445
3431
|
return this._gone.on(cb);
|
|
2446
3432
|
}
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
3433
|
+
/** What the importer reported about the file this document was read
|
|
3434
|
+
* from, as the open found it. `null` on a document no import produced and
|
|
3435
|
+
* on a clean import. */
|
|
3436
|
+
get importReport() {
|
|
3437
|
+
return this._importReport;
|
|
3438
|
+
}
|
|
3439
|
+
/** @internal Seeded from the join. */
|
|
3440
|
+
_setImportReport(report) {
|
|
3441
|
+
this._importReport = report;
|
|
3442
|
+
}
|
|
3443
|
+
/** @internal On a frozen open: whether the document has moved since this
|
|
3444
|
+
* copy last loaded. False on every other open, which applies changes as
|
|
3445
|
+
* they come. */
|
|
3446
|
+
get changed() {
|
|
3447
|
+
return this._changed;
|
|
3448
|
+
}
|
|
3449
|
+
/** @internal On a frozen open, fires when the copy learns the document
|
|
3450
|
+
* moved since it last loaded (a content edit, an import landing or
|
|
3451
|
+
* failing, the document being deleted): once per load, and again after a
|
|
3452
|
+
* `reload()` once the document moves past what that load brought. Never
|
|
3453
|
+
* fires on any other open. The document is watched only while a listener is
|
|
3454
|
+
* registered and until the copy is closed. */
|
|
3455
|
+
onChanged(cb) {
|
|
3456
|
+
this._assertOpen();
|
|
3457
|
+
this._changedListeners.add(cb);
|
|
3458
|
+
if (this._changed) {
|
|
3459
|
+
queueMicrotask(() => {
|
|
3460
|
+
if (!this._closed && this._changed && this._changedListeners.has(cb))
|
|
3461
|
+
cb();
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
else {
|
|
3465
|
+
this._watchForChange();
|
|
3466
|
+
}
|
|
3467
|
+
return () => {
|
|
3468
|
+
this._changedListeners.delete(cb);
|
|
3469
|
+
this._unwatchIfIdle();
|
|
3470
|
+
};
|
|
3471
|
+
}
|
|
3472
|
+
_unwatchIfIdle() {
|
|
3473
|
+
if (this._changedListeners.size === 0 && this._statusWatches === 0) {
|
|
3474
|
+
this._stopChangeNotice?.();
|
|
3475
|
+
this._stopChangeNotice = null;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
_watchForChange() {
|
|
3479
|
+
if (this._stopChangeNotice === null && this._changeNotice !== null) {
|
|
3480
|
+
this._stopChangeNotice = this._changeNotice(() => this._markChanged());
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
/**
|
|
3484
|
+
* Calls the handlers with the input files of `key`. `loaded` gets every
|
|
3485
|
+
* file on each subscribe, then each new version, on any copy. `changed`
|
|
3486
|
+
* gets the files changed since this document last read them, on a copy
|
|
3487
|
+
* that can edit, each recorded as read off the Undo stack once its handler
|
|
3488
|
+
* resolves. `removed` gets each file that left. Calls for one file never
|
|
3489
|
+
* overlap. Subscriptions to one key share one set of calls, and each
|
|
3490
|
+
* subscription's handlers are called for every input; a `changed` input is
|
|
3491
|
+
* recorded as read once all of them resolve. Returns an unsubscribe.
|
|
3492
|
+
*/
|
|
3493
|
+
subscribeInputDocuments(key, handlers, options) {
|
|
3494
|
+
this._assertOpen();
|
|
3495
|
+
if (handlers === null || typeof handlers !== 'object') {
|
|
3496
|
+
throw new TypeError('subscribeInputDocuments: pass an object of handlers');
|
|
3497
|
+
}
|
|
3498
|
+
if (!isInputKey(key))
|
|
3499
|
+
throw new TypeError(`subscribeInputDocuments: "${String(key)}" is not a valid key`);
|
|
3500
|
+
const named = handlers;
|
|
3501
|
+
if (!['loaded', 'changed', 'removed', 'status', 'settled'].some((name) => typeof named[name] === 'function')) {
|
|
3502
|
+
throw new TypeError('subscribeInputDocuments: handlers must include loaded, changed, removed, status or settled');
|
|
3503
|
+
}
|
|
3504
|
+
const hub = this._inputStatus();
|
|
3505
|
+
return this._inputFollowers.subscribe({
|
|
3506
|
+
subscribeStatus: (k, cb) => hub.subscribeKey(k, cb),
|
|
3507
|
+
canWrite: () => !this._closed && !this.readOnly,
|
|
3508
|
+
markRead: (status, options) => this.markRead(status, options),
|
|
3509
|
+
open: this._inputsRuntime?.open ?? null,
|
|
3510
|
+
}, key, handlers, options);
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* @internal Calls `cb` with the status of `key` once it is read, and again
|
|
3514
|
+
* whenever it changes: an input file was added, modified or removed, the
|
|
3515
|
+
* key's references changed, or this document's read marks moved (an Undo
|
|
3516
|
+
* of `markRead` included, which reports at once). Returns an unsubscribe.
|
|
3517
|
+
*/
|
|
3518
|
+
subscribeInputStatus(key, cb) {
|
|
3519
|
+
this._assertOpen();
|
|
3520
|
+
if (typeof cb !== 'function')
|
|
3521
|
+
throw new TypeError('subscribeInputStatus: pass a callback');
|
|
3522
|
+
if (!isInputKey(key))
|
|
3523
|
+
throw new TypeError(`subscribeInputStatus: "${String(key)}" is not a valid key`);
|
|
3524
|
+
return this._inputStatus().subscribeKey(key, cb);
|
|
3525
|
+
}
|
|
3526
|
+
/** @internal Wired by the SDK's open factory. */
|
|
3527
|
+
_setInputsRuntime(runtime) {
|
|
3528
|
+
this._inputsRuntime = runtime;
|
|
3529
|
+
}
|
|
3530
|
+
/** @internal The platform moved `key`'s status to `rev`; both null after a
|
|
3531
|
+
* resubscribe, which may have missed a nudge for any key. */
|
|
3532
|
+
_inputStatusNudged(key, rev) {
|
|
3533
|
+
this._inputStatusHub?.nudged(key, rev);
|
|
3534
|
+
}
|
|
3535
|
+
_inputStatus() {
|
|
3536
|
+
if (this._inputStatusHub !== null)
|
|
3537
|
+
return this._inputStatusHub;
|
|
3538
|
+
const runtime = this._inputsRuntime;
|
|
3539
|
+
if (runtime === null) {
|
|
3540
|
+
throw new Error('subscribeInputStatus: Document has no server identity. Opened via the SDK factory?');
|
|
3541
|
+
}
|
|
3542
|
+
this._inputStatusHub = new InputStatusHub({
|
|
3543
|
+
runtime,
|
|
3544
|
+
lineId: () => this._branchId,
|
|
3545
|
+
localRoots: () => this._inputsLocalRoots(),
|
|
3546
|
+
watchLocalRoots: (cb) => this._watchInputsRoots(cb),
|
|
3547
|
+
});
|
|
3548
|
+
return this._inputStatusHub;
|
|
3549
|
+
}
|
|
3550
|
+
// The reserved roots' plain values. A root nobody has written is read as
|
|
3551
|
+
// empty without asking the engine for it, since asking creates it.
|
|
3552
|
+
_inputsLocalRoots() {
|
|
3553
|
+
if (this._sealedRoots !== null || this._closed)
|
|
3554
|
+
return null;
|
|
3555
|
+
const engine = this._doc;
|
|
3556
|
+
const present = engine.getShallowValue();
|
|
3557
|
+
return {
|
|
3558
|
+
refs: INPUTS_REFS_ROOT in present ? engine.getMap(INPUTS_REFS_ROOT).toJSON() : {},
|
|
3559
|
+
read: INPUTS_READ_ROOT in present ? engine.getMap(INPUTS_READ_ROOT).toJSON() : {},
|
|
3560
|
+
};
|
|
3561
|
+
}
|
|
3562
|
+
_watchInputsRoots(cb) {
|
|
3563
|
+
if (this._sealedRoots !== null || this._closed)
|
|
3564
|
+
return () => { };
|
|
3565
|
+
const targets = new Set([`cid:root-${INPUTS_REFS_ROOT}:Map`, `cid:root-${INPUTS_READ_ROOT}:Map`]);
|
|
3566
|
+
return this._doc.subscribe((batch) => {
|
|
3567
|
+
if (batch.events.some((event) => targets.has(String(event.target))))
|
|
3568
|
+
cb();
|
|
3569
|
+
});
|
|
3570
|
+
}
|
|
3571
|
+
/** @internal How a frozen copy watches its document: starts watching,
|
|
3572
|
+
* calls `fire` when the document moves, and returns the stop. */
|
|
3573
|
+
_setChangeNotice(start) {
|
|
3574
|
+
this._changeNotice = start;
|
|
3575
|
+
}
|
|
3576
|
+
/** @internal Stops a frozen copy's notice once it has reported a move and
|
|
3577
|
+
* nobody is waiting on pending work. */
|
|
3578
|
+
_settleChangeNotice() {
|
|
3579
|
+
if (!this._changed)
|
|
3580
|
+
return;
|
|
3581
|
+
if (this._statusWatches > 0 && this._workStatus.work?.pending === true)
|
|
3582
|
+
return;
|
|
3583
|
+
this._stopChangeNotice?.();
|
|
3584
|
+
this._stopChangeNotice = null;
|
|
3585
|
+
}
|
|
3586
|
+
/** @internal Wire a frozen copy's reload: re-join, apply the result in
|
|
3587
|
+
* place, and re-point the change notice at what that join brought. */
|
|
3588
|
+
_setFrozenReloader(reload) {
|
|
3589
|
+
this._frozenReloader = reload;
|
|
3590
|
+
}
|
|
3591
|
+
// The notice is spent on its first frame, so the watch stops here: the topic
|
|
3592
|
+
// carries every op of the document, and a copy that already knows it moved
|
|
3593
|
+
// has no use for the rest until it reloads. A copy whose work is still
|
|
3594
|
+
// pending keeps it while an `onStatus` listener waits, because the import
|
|
3595
|
+
// landing moves the document before its terminal status arrives.
|
|
3596
|
+
_markChanged() {
|
|
3597
|
+
if (this._changed || this._closed)
|
|
3598
|
+
return;
|
|
3599
|
+
this._changed = true;
|
|
3600
|
+
this._settleChangeNotice();
|
|
3601
|
+
const listeners = [...this._changedListeners];
|
|
3602
|
+
for (const cb of listeners) {
|
|
3603
|
+
try {
|
|
3604
|
+
cb();
|
|
3605
|
+
}
|
|
3606
|
+
catch (err) {
|
|
3607
|
+
console.error('Brass: an onChanged listener threw', err);
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
/**
|
|
3612
|
+
* The work in flight on this document (a new version importing, a drive
|
|
3613
|
+
* sync), or `null` when there is none. Render `message` verbatim, draw a
|
|
3614
|
+
* bar from `progress` when present, and show a waiting state while
|
|
3615
|
+
* `pending`. Later phases arrive through `onStatus`.
|
|
3616
|
+
*/
|
|
2453
3617
|
get status() {
|
|
2454
3618
|
return this._workStatus.status;
|
|
2455
3619
|
}
|
|
2456
|
-
//
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
3620
|
+
// The platform's work status alone, without the SDK's own notice over it.
|
|
3621
|
+
// Every SDK-side decision about whether work is pending reads this, since
|
|
3622
|
+
// the notice describes this client's connection rather than the platform's
|
|
3623
|
+
// work.
|
|
3624
|
+
/** @internal */
|
|
3625
|
+
get _workStatusNow() {
|
|
3626
|
+
return this._workStatus.work;
|
|
3627
|
+
}
|
|
3628
|
+
// Tell the reader their edits are waiting to save, through `status`, while
|
|
3629
|
+
// they wait: on a connection that has not opened (`NEVER_OPENED_NOTICE_MS`),
|
|
3630
|
+
// or on one that leaves an edit unanswered (`UNANSWERED_OP_NOTICE_AFTER`).
|
|
3631
|
+
// The doc transport decides when; the notice clears itself once nothing is
|
|
3632
|
+
// waiting (`_fireSubscribers`). A stream raises it on the document that
|
|
3633
|
+
// opened it as well, since the app renders that document's status.
|
|
3634
|
+
/** @internal */
|
|
3635
|
+
_showConnectionNotice(kind = 'never_opened') {
|
|
3636
|
+
if (this._closed || this.savingCount === 0)
|
|
3637
|
+
return;
|
|
3638
|
+
this._workStatus.setNotice(SAVE_NOTICES[kind]);
|
|
3639
|
+
this._connectionNoticeObserver?.(kind);
|
|
3640
|
+
}
|
|
3641
|
+
_connectionNoticeObserver = null;
|
|
3642
|
+
/** @internal */
|
|
3643
|
+
_observeConnectionNotice(observer) {
|
|
3644
|
+
this._connectionNoticeObserver = observer;
|
|
3645
|
+
}
|
|
3646
|
+
/**
|
|
3647
|
+
* Calls `cb` with every work-status update, and once on attach when a
|
|
3648
|
+
* status is already known. `pending: false` marks the work finished, as a
|
|
3649
|
+
* success or as a failure whose `message` the app shows. Returns an
|
|
3650
|
+
* unsubscribe.
|
|
3651
|
+
*
|
|
3652
|
+
* Do not parse `message` or infer phases from it: its wording changes
|
|
3653
|
+
* without an SDK release.
|
|
3654
|
+
*/
|
|
2467
3655
|
onStatus(cb) {
|
|
2468
3656
|
// See `onGone`: guard the cross-session listener leak.
|
|
2469
3657
|
this._assertOpen();
|
|
2470
|
-
|
|
3658
|
+
const stop = this._workStatus.on(cb);
|
|
3659
|
+
// A frozen copy hears the work that was pending when it loaded on its
|
|
3660
|
+
// change notice, which runs only while somebody listens. A live copy's
|
|
3661
|
+
// transport always delivers status.
|
|
3662
|
+
if (this._changeNotice === null || this._workStatus.work?.pending !== true)
|
|
3663
|
+
return stop;
|
|
3664
|
+
this._statusWatches += 1;
|
|
3665
|
+
if (!this._changed)
|
|
3666
|
+
this._watchForChange();
|
|
3667
|
+
let stopped = false;
|
|
3668
|
+
return () => {
|
|
3669
|
+
stop();
|
|
3670
|
+
if (stopped)
|
|
3671
|
+
return;
|
|
3672
|
+
stopped = true;
|
|
3673
|
+
this._statusWatches -= 1;
|
|
3674
|
+
this._unwatchIfIdle();
|
|
3675
|
+
this._settleChangeNotice();
|
|
3676
|
+
};
|
|
2471
3677
|
}
|
|
2472
3678
|
// Internal: store the latest status and fire listeners. Not
|
|
2473
3679
|
// one-shot: every phase re-emits. A terminal status (`pending:
|
|
2474
3680
|
// false`) is retained so a late `onStatus` subscriber still sees the
|
|
2475
3681
|
// final state.
|
|
2476
3682
|
/** @internal */
|
|
2477
|
-
_emitStatus(
|
|
3683
|
+
_emitStatus(incoming, opts) {
|
|
2478
3684
|
// A late reload/reconcile closure that resolved after `close()` must not
|
|
2479
3685
|
// re-emit onto a torn-down doc (its undo stack is freed below in `close`).
|
|
2480
3686
|
if (this._closed)
|
|
2481
3687
|
return;
|
|
3688
|
+
// An import in flight holds the document against a settled status from
|
|
3689
|
+
// anywhere else (a late frame for the work that stored the file), because
|
|
3690
|
+
// an edit made before the import lands leaves the file unimportable. A
|
|
3691
|
+
// join reporting the import done clears `_awaitsImport`, which releases
|
|
3692
|
+
// it for a client waiting on another's import.
|
|
3693
|
+
const status = this._importHold !== null && this._awaitsImport && incoming.pending !== true
|
|
3694
|
+
? this._importHold
|
|
3695
|
+
: incoming;
|
|
3696
|
+
const wasReadOnly = this.readOnly;
|
|
2482
3697
|
// Terminal work that still reports itself as locking editing is inbound
|
|
2483
3698
|
// work that ended without content: the platform clears the status when
|
|
2484
3699
|
// hydration / extraction succeeds, so what it reports terminally is a
|
|
@@ -2495,6 +3710,11 @@ export class Document {
|
|
|
2495
3710
|
// Refresh the undo-state notifier here so a subscribeUndoState-bound
|
|
2496
3711
|
// button tracks the lock, not only CRDT edits.
|
|
2497
3712
|
this._maybeFireUndoState();
|
|
3713
|
+
// An app re-reads `readOnly` in its `subscribe` handler, so a status that
|
|
3714
|
+
// locks or releases a document it has already bound has to reach that
|
|
3715
|
+
// handler, or its edit controls stay as they were.
|
|
3716
|
+
if (this.readOnly !== wasReadOnly)
|
|
3717
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
2498
3718
|
}
|
|
2499
3719
|
// The current drive sync state of this document, or `null` when the
|
|
2500
3720
|
// doc is not linked to a drive source. An app reads `state` / `canPush`
|
|
@@ -2586,8 +3806,242 @@ export class Document {
|
|
|
2586
3806
|
this._resyncPushClientOpIds.clear();
|
|
2587
3807
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
2588
3808
|
}
|
|
3809
|
+
this._provisionalInputs.clear();
|
|
2589
3810
|
this._gone.mark(reason);
|
|
2590
3811
|
}
|
|
3812
|
+
// --- References and read marks -------------------------------------------
|
|
3813
|
+
// How many handle `mutate` calls are running, and how many of those asked
|
|
3814
|
+
// to stay off the Undo stack. A commit inside any of them carries the
|
|
3815
|
+
// non-undoable origin, so a write the app asked to keep off the stack does
|
|
3816
|
+
// not reach it through a batch boundary or a nested handle.
|
|
3817
|
+
_mutateDepth = 0;
|
|
3818
|
+
_nonUndoableDepth = 0;
|
|
3819
|
+
// `dependsOn` / `markRead` writes waiting for the running mutate to finish.
|
|
3820
|
+
// A mutate can commit several times (`mutate-batching.ts`), and a write
|
|
3821
|
+
// left in the open transaction rides whichever commit comes next, so a
|
|
3822
|
+
// read mark could arrive before the results it acknowledges. Each runs as
|
|
3823
|
+
// a commit of its own after the mutate's last one.
|
|
3824
|
+
_inputsQueue = [];
|
|
3825
|
+
// The client op ids `_shipLocalOp` mints while a reference commit runs.
|
|
3826
|
+
_shipCapture = null;
|
|
3827
|
+
_provisionalInputs = new ProvisionalInputsTracker();
|
|
3828
|
+
/**
|
|
3829
|
+
* @internal The document's `updated_at` as this copy read it when it
|
|
3830
|
+
* opened. `markRead` records a document at this value. `null` for a
|
|
3831
|
+
* document not opened from the platform.
|
|
3832
|
+
*/
|
|
3833
|
+
get updatedAt() {
|
|
3834
|
+
return joinStampOf(this) ?? null;
|
|
3835
|
+
}
|
|
3836
|
+
/** @internal Where a key's provisional entry is posted once the op
|
|
3837
|
+
* carrying its change is acknowledged. */
|
|
3838
|
+
_setProvisionalInputsPoster(poster) {
|
|
3839
|
+
this._provisionalInputs.setPoster(poster);
|
|
3840
|
+
// A post's answer names the key's revision after it, which reads the same
|
|
3841
|
+
// as a nudge: a status behind it fetches the change.
|
|
3842
|
+
this._provisionalInputs.onAnswer(poster === null ? null : (answer) => this._inputStatusNudged(answer.key, answer.rev));
|
|
3843
|
+
}
|
|
3844
|
+
/** @internal Run a handle's mutate, so its commits carry the origin it asked
|
|
3845
|
+
* for and the reference writes queued inside it commit after it. */
|
|
3846
|
+
_runInMutate(undoable, fn) {
|
|
3847
|
+
// A nested mutate that changes the origin first commits what the enclosing
|
|
3848
|
+
// one has written so far, and commits its own writes before handing back,
|
|
3849
|
+
// so each write reaches the Undo stack per the mutate that made it.
|
|
3850
|
+
const switchesOrigin = !undoable && this._nonUndoableDepth === 0 && this._mutateDepth > 0;
|
|
3851
|
+
if (switchesOrigin)
|
|
3852
|
+
this._commitPendingMutate();
|
|
3853
|
+
this._mutateDepth += 1;
|
|
3854
|
+
if (!undoable)
|
|
3855
|
+
this._nonUndoableDepth += 1;
|
|
3856
|
+
let threw = true;
|
|
3857
|
+
try {
|
|
3858
|
+
fn();
|
|
3859
|
+
threw = false;
|
|
3860
|
+
}
|
|
3861
|
+
finally {
|
|
3862
|
+
if (switchesOrigin)
|
|
3863
|
+
this._commitPendingMutate();
|
|
3864
|
+
this._mutateDepth -= 1;
|
|
3865
|
+
if (!undoable)
|
|
3866
|
+
this._nonUndoableDepth -= 1;
|
|
3867
|
+
if (this._mutateDepth === 0) {
|
|
3868
|
+
const queued = this._inputsQueue;
|
|
3869
|
+
this._inputsQueue = [];
|
|
3870
|
+
// A mutate that threw left its results partly written, so the marks
|
|
3871
|
+
// that acknowledge them are not written either.
|
|
3872
|
+
if (!threw)
|
|
3873
|
+
this._flushInputs(queued);
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
_commitPendingMutate() {
|
|
3878
|
+
if (this._doc.getPendingTxnLength() > 0)
|
|
3879
|
+
this._handleOwner.commitMutate();
|
|
3880
|
+
}
|
|
3881
|
+
get _mutateCommitOrigin() {
|
|
3882
|
+
return this._nonUndoableDepth > 0 ? NON_UNDOABLE_COMMIT_ORIGIN : MUTATE_COMMIT_ORIGIN;
|
|
3883
|
+
}
|
|
3884
|
+
/**
|
|
3885
|
+
* Records the documents `key` depends on, replacing what it held. `null`
|
|
3886
|
+
* removes the key and its read marks. Writes only what differs from this
|
|
3887
|
+
* copy, so a reference another reader adds or removes at the same time
|
|
3888
|
+
* keeps their change. Undoable, as part of the current step, unless called
|
|
3889
|
+
* inside `mutate(fn, { undoable: false })`.
|
|
3890
|
+
*/
|
|
3891
|
+
dependsOn(key, refs) {
|
|
3892
|
+
this._handleOwner.assertWritable();
|
|
3893
|
+
if (!isInputKey(key))
|
|
3894
|
+
throw new TypeError(`dependsOn: "${String(key)}" is not a valid key`);
|
|
3895
|
+
if (refs !== null && !Array.isArray(refs)) {
|
|
3896
|
+
throw new TypeError('dependsOn: refs must be an array of references or null');
|
|
3897
|
+
}
|
|
3898
|
+
const parsed = [];
|
|
3899
|
+
for (const ref of refs ?? []) {
|
|
3900
|
+
const one = parseInputReference(ref);
|
|
3901
|
+
if (one === null)
|
|
3902
|
+
throw new TypeError('dependsOn: refs holds a value that is not a reference');
|
|
3903
|
+
if (one.extent === 'document' && this._docId !== null && one.doc_id === this._docId) {
|
|
3904
|
+
throw new Error('dependsOn: a document cannot reference itself');
|
|
3905
|
+
}
|
|
3906
|
+
parsed.push(one);
|
|
3907
|
+
}
|
|
3908
|
+
const refusal = this._inputsRuntime?.refusesReferences;
|
|
3909
|
+
if (refusal !== undefined && parsed.length > 0)
|
|
3910
|
+
throw new BrassError(refusal);
|
|
3911
|
+
const next = refs === null ? null : parsed;
|
|
3912
|
+
this._queueInputs(this._nonUndoableDepth === 0, () => {
|
|
3913
|
+
const refsMap = this._doc.getMap(INPUTS_REFS_ROOT);
|
|
3914
|
+
let changed = applyRootWrites(refsMap, inputRefsWrites(rootView(refsMap), key, next));
|
|
3915
|
+
if (next === null) {
|
|
3916
|
+
const readMap = this._doc.getMap(INPUTS_READ_ROOT);
|
|
3917
|
+
changed = applyRootWrites(readMap, inputReadClearWrites(rootView(readMap), key)) || changed;
|
|
3918
|
+
}
|
|
3919
|
+
return changed ? key : null;
|
|
3920
|
+
});
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* @internal Records that this reader read files for the key `status` reports, and
|
|
3924
|
+
* acknowledged the removed files that status lists. With no `files`, every
|
|
3925
|
+
* file the status lists as added or modified is recorded at the version the
|
|
3926
|
+
* status reports. Otherwise each entry of `files` is recorded: an opened
|
|
3927
|
+
* copy at the `updatedAt` it was opened at (left out while its import is
|
|
3928
|
+
* pending, so it stays changed), and an entry of the status's own `files`
|
|
3929
|
+
* or `changed` at the version the status reports. Joins the current Undo
|
|
3930
|
+
* step unless `undoable` is false.
|
|
3931
|
+
*/
|
|
3932
|
+
markRead(status, options) {
|
|
3933
|
+
this._handleOwner.assertWritable();
|
|
3934
|
+
const key = status?.key;
|
|
3935
|
+
if (!isInputKey(key))
|
|
3936
|
+
throw new TypeError('markRead: pass the status `subscribeInputStatus` reported');
|
|
3937
|
+
// A list of files passed where the options go would otherwise read as no
|
|
3938
|
+
// files, and record every changed one.
|
|
3939
|
+
if (Array.isArray(options))
|
|
3940
|
+
throw new TypeError('markRead: pass the files as `{ files }`');
|
|
3941
|
+
const files = options?.files;
|
|
3942
|
+
if (files !== undefined && !Array.isArray(files)) {
|
|
3943
|
+
throw new TypeError('markRead: `files` must be an array');
|
|
3944
|
+
}
|
|
3945
|
+
// The version the status reports for each file, which is what an entry
|
|
3946
|
+
// taken from the status is recorded at: an edit that lands after the
|
|
3947
|
+
// status stays changed.
|
|
3948
|
+
const reported = new Map();
|
|
3949
|
+
for (const file of status.inputs ?? [])
|
|
3950
|
+
reported.set(file.doc_id, file.updated_at);
|
|
3951
|
+
const items = files ?? status.changed.filter((c) => c.change !== 'removed');
|
|
3952
|
+
const reads = [];
|
|
3953
|
+
for (const item of items) {
|
|
3954
|
+
let docId;
|
|
3955
|
+
let version;
|
|
3956
|
+
if ('doc_id' in item) {
|
|
3957
|
+
// A removed file is acknowledged from `status.changed` below.
|
|
3958
|
+
if (item.change === 'removed')
|
|
3959
|
+
continue;
|
|
3960
|
+
docId = item.doc_id;
|
|
3961
|
+
version = reported.get(docId) ?? null;
|
|
3962
|
+
if (version === null) {
|
|
3963
|
+
throw new TypeError(`markRead: the status reports no version for ${docId}`);
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
else {
|
|
3967
|
+
if (item.status?.pending === true)
|
|
3968
|
+
continue;
|
|
3969
|
+
docId = item.id;
|
|
3970
|
+
version = item.updatedAt;
|
|
3971
|
+
}
|
|
3972
|
+
const stamp = typeof version === 'string' ? inputStampFromUpdatedAt(version) : null;
|
|
3973
|
+
if (!isInputDocId(docId) || stamp === null) {
|
|
3974
|
+
throw new TypeError('markRead: each file must be a document opened from Brass or an entry of the status');
|
|
3975
|
+
}
|
|
3976
|
+
reads.push({ docId, stamp });
|
|
3977
|
+
}
|
|
3978
|
+
const removed = status.changed
|
|
3979
|
+
.filter((c) => c.change === 'removed')
|
|
3980
|
+
.filter((c) => isInputDocId(c.doc_id))
|
|
3981
|
+
.map((c) => ({ docId: c.doc_id, leftAt: c.updated_at ?? null }));
|
|
3982
|
+
this._queueInputs(options?.undoable !== false, () => {
|
|
3983
|
+
const readMap = this._doc.getMap(INPUTS_READ_ROOT);
|
|
3984
|
+
const view = rootView(readMap);
|
|
3985
|
+
let changed = false;
|
|
3986
|
+
const write = (docId, mark) => {
|
|
3987
|
+
const writes = inputReadMarkWrites(view, key, docId, mark);
|
|
3988
|
+
for (const k of writes.delete)
|
|
3989
|
+
delete view[k];
|
|
3990
|
+
Object.assign(view, writes.set);
|
|
3991
|
+
changed = applyRootWrites(readMap, writes) || changed;
|
|
3992
|
+
};
|
|
3993
|
+
for (const { docId, stamp } of reads) {
|
|
3994
|
+
write(docId, inputReadMarkFor(latestReadMark(view, key, docId), stamp));
|
|
3995
|
+
}
|
|
3996
|
+
for (const { docId, leftAt } of removed) {
|
|
3997
|
+
write(docId, inputRemovalMark(latestReadMark(view, key, docId), leftAt));
|
|
3998
|
+
}
|
|
3999
|
+
return changed ? key : null;
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
_queueInputs(undoable, apply) {
|
|
4003
|
+
const entry = { undoable, apply };
|
|
4004
|
+
if (this._mutateDepth > 0) {
|
|
4005
|
+
this._inputsQueue.push(entry);
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
this._flushInputs([entry]);
|
|
4009
|
+
}
|
|
4010
|
+
// Commit queued reference writes in call order, one commit per run of
|
|
4011
|
+
// writes that share an origin, and wait to post each key they changed.
|
|
4012
|
+
_flushInputs(entries) {
|
|
4013
|
+
if (entries.length === 0 || this._readOnlyBecause !== null)
|
|
4014
|
+
return;
|
|
4015
|
+
this._ensureLocalUpdatesSubscribed();
|
|
4016
|
+
let i = 0;
|
|
4017
|
+
while (i < entries.length) {
|
|
4018
|
+
const undoable = entries[i].undoable;
|
|
4019
|
+
const keys = new Set();
|
|
4020
|
+
for (; i < entries.length && entries[i].undoable === undoable; i += 1) {
|
|
4021
|
+
const key = entries[i].apply();
|
|
4022
|
+
if (key !== null)
|
|
4023
|
+
keys.add(key);
|
|
4024
|
+
}
|
|
4025
|
+
if (keys.size === 0)
|
|
4026
|
+
continue;
|
|
4027
|
+
this._shipCapture = [];
|
|
4028
|
+
let shipped;
|
|
4029
|
+
try {
|
|
4030
|
+
this._doc.setNextCommitOrigin(undoable ? MUTATE_COMMIT_ORIGIN : NON_UNDOABLE_COMMIT_ORIGIN);
|
|
4031
|
+
this._doc.commit();
|
|
4032
|
+
}
|
|
4033
|
+
finally {
|
|
4034
|
+
shipped = this._shipCapture;
|
|
4035
|
+
this._shipCapture = null;
|
|
4036
|
+
}
|
|
4037
|
+
if (shipped.length === 0)
|
|
4038
|
+
continue;
|
|
4039
|
+
const state = decodeInputsRoots(this._doc.getMap(INPUTS_REFS_ROOT).toJSON(), this._doc.getMap(INPUTS_READ_ROOT).toJSON());
|
|
4040
|
+
for (const key of keys) {
|
|
4041
|
+
this._provisionalInputs.register(key, this._branchId, state.get(key) ?? { refs: [], read: {} }, shipped);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
2591
4045
|
/**
|
|
2592
4046
|
* The top-level collections of the stream this document is open at, keyed
|
|
2593
4047
|
* as it keys them, each naming the kind of handle that reads it.
|
|
@@ -2607,11 +4061,13 @@ export class Document {
|
|
|
2607
4061
|
*/
|
|
2608
4062
|
roots() {
|
|
2609
4063
|
this._assertNotClosed();
|
|
4064
|
+
if (this._sealedRoots !== null)
|
|
4065
|
+
return this._sealedRoots.roots();
|
|
2610
4066
|
const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
2611
4067
|
const found = [];
|
|
2612
4068
|
for (const [key, containerId] of Object.entries(source.getShallowValue())) {
|
|
2613
4069
|
const kind = rootKindOf(containerId);
|
|
2614
|
-
if (kind === null)
|
|
4070
|
+
if (kind === null || isReservedRootName(key))
|
|
2615
4071
|
continue;
|
|
2616
4072
|
// Reading the container's own count, rather than its value, is what
|
|
2617
4073
|
// keeps this off the projection: an empty collection is skipped without
|
|
@@ -2619,6 +4075,8 @@ export class Document {
|
|
|
2619
4075
|
const container = source.getContainerById(containerId);
|
|
2620
4076
|
if (container === undefined || rootIsEmpty(container))
|
|
2621
4077
|
continue;
|
|
4078
|
+
if (holdsOnlyMarkers(key, container))
|
|
4079
|
+
continue;
|
|
2622
4080
|
found.push([key, kind]);
|
|
2623
4081
|
}
|
|
2624
4082
|
// Loro reports roots in registration order, which is an accident of who
|
|
@@ -2627,27 +4085,23 @@ export class Document {
|
|
|
2627
4085
|
found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
2628
4086
|
return Object.fromEntries(found);
|
|
2629
4087
|
}
|
|
2630
|
-
// Plain-JS projection of the current state
|
|
4088
|
+
// Plain-JS projection of the current state, leaving out a root that holds
|
|
4089
|
+
// nothing. A CRDT cannot store an empty collection, and the root a Document
|
|
4090
|
+
// registers on open is empty until written, so a projection that kept empty
|
|
4091
|
+
// roots would change shape when the storage swaps in place under a reader.
|
|
4092
|
+
// Every other root stays whatever its kind: the sealed-copy builder reads
|
|
4093
|
+
// this projection to refuse a kind the frozen form cannot carry.
|
|
2631
4094
|
toJSON() {
|
|
2632
4095
|
this._assertNotClosed();
|
|
2633
4096
|
// When in checkout, read from the preview LoroDoc so the app
|
|
2634
4097
|
// renders the historical state. Returning to head clears
|
|
2635
4098
|
// `_checkout` and reads go back to the live `_doc`.
|
|
2636
|
-
const
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
// threshold has to reach them as records. Every root is asked, because a
|
|
2643
|
-
// root no handle was ever taken on is paged by the same rule as one that
|
|
2644
|
-
// was; a root the marker does not name comes back as loro produced it.
|
|
2645
|
-
for (const [rootKey, value] of Object.entries(json)) {
|
|
2646
|
-
if (!isRootLookup(value))
|
|
2647
|
-
continue;
|
|
2648
|
-
if (!readRootPaged(source, rootKey))
|
|
2649
|
-
continue;
|
|
2650
|
-
json[rootKey] = flattenPagedLookup(value, true);
|
|
4099
|
+
const json = this._sealedRoots !== null
|
|
4100
|
+
? this._sealedRoots.toJSON()
|
|
4101
|
+
: engineJSON(this._checkout !== null ? this._checkout.previewDoc : this._doc);
|
|
4102
|
+
for (const [key, value] of Object.entries(json)) {
|
|
4103
|
+
if (projectsEmpty(value))
|
|
4104
|
+
delete json[key];
|
|
2651
4105
|
}
|
|
2652
4106
|
return json;
|
|
2653
4107
|
}
|
|
@@ -2672,7 +4126,7 @@ export class Document {
|
|
|
2672
4126
|
// handshake is finished; every other outcome leaves the doc diverged and
|
|
2673
4127
|
// is the caller's cue to run the handshake again once the push settles.
|
|
2674
4128
|
/** @internal */
|
|
2675
|
-
maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
|
|
4129
|
+
maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs = []) {
|
|
2676
4130
|
// `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
|
|
2677
4131
|
// closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
|
|
2678
4132
|
// closure can't touch freed WASM.
|
|
@@ -2684,7 +4138,7 @@ export class Document {
|
|
|
2684
4138
|
if (this._outstandingByClientId.has(id))
|
|
2685
4139
|
return 'in-flight';
|
|
2686
4140
|
}
|
|
2687
|
-
const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs);
|
|
4141
|
+
const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs);
|
|
2688
4142
|
if (chunks === 'covered')
|
|
2689
4143
|
return 'covered';
|
|
2690
4144
|
if (chunks === 'undecodable')
|
|
@@ -2701,14 +4155,15 @@ export class Document {
|
|
|
2701
4155
|
// this client holds CRDT knowledge the durable server state lacks,
|
|
2702
4156
|
// and if so return the update blob that fills exactly those holes.
|
|
2703
4157
|
//
|
|
2704
|
-
//
|
|
2705
|
-
//
|
|
2706
|
-
// log even though no snapshot has absorbed
|
|
2707
|
-
//
|
|
2708
|
-
//
|
|
2709
|
-
//
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
4158
|
+
// What the server holds is the snapshot (every author's ops from zero to
|
|
4159
|
+
// its version vector) plus each tail op it served, range by range: a tail
|
|
4160
|
+
// op is durable in the log even though no snapshot has absorbed it. Holding
|
|
4161
|
+
// the tail as ranges rather than folding it into one counter per author is
|
|
4162
|
+
// what sees a hole: a log that lost an author's op and stored that author's
|
|
4163
|
+
// later ones can apply none of them, so the op that fills the hole is
|
|
4164
|
+
// exactly what has to go back. Leaving the tail out altogether would flag
|
|
4165
|
+
// the whole tail as missing and push it back on every join, which grows the
|
|
4166
|
+
// log and loops. `decodeImportBlobMeta` reads each blob's range from its
|
|
2712
4167
|
// header without a full import, so this stays cheap.
|
|
2713
4168
|
//
|
|
2714
4169
|
// Returns the chunks to resync-push, `'covered'` when the durable state
|
|
@@ -2719,10 +4174,11 @@ export class Document {
|
|
|
2719
4174
|
// nothing about whether the doc has diverged, so a caller healing a known
|
|
2720
4175
|
// divergence must keep asking.
|
|
2721
4176
|
//
|
|
2722
|
-
// The common case is one chunk:
|
|
2723
|
-
//
|
|
2724
|
-
//
|
|
2725
|
-
//
|
|
4177
|
+
// The common case is one chunk: when each author is missing only its
|
|
4178
|
+
// newest ops, an `update` export carries exactly those, and a realistic
|
|
4179
|
+
// gap is far under the per-frame cap. A hole below ops the server holds
|
|
4180
|
+
// ships as its own `updates-in-range` span, one range per author per blob,
|
|
4181
|
+
// so a blob never re-sends what the server holds. When the missing slice exceeds the cap (a BIM-sized edit set:
|
|
2726
4182
|
// a geometry import or bulk parametric change discarded then healed, or
|
|
2727
4183
|
// a large offline divergence on reconnect), it is split into multiple
|
|
2728
4184
|
// chunks via `updates-in-range` spans, each under the cap, so even a
|
|
@@ -2749,7 +4205,7 @@ export class Document {
|
|
|
2749
4205
|
* @internal
|
|
2750
4206
|
*/
|
|
2751
4207
|
_snapshotHasUnseenAuthor(persistedSnapshotVersionVector) {
|
|
2752
|
-
if (this._closed)
|
|
4208
|
+
if (this._closed || this._sealedRoots !== null)
|
|
2753
4209
|
return false;
|
|
2754
4210
|
let durable;
|
|
2755
4211
|
try {
|
|
@@ -2768,50 +4224,45 @@ export class Document {
|
|
|
2768
4224
|
return false;
|
|
2769
4225
|
}
|
|
2770
4226
|
/** @internal */
|
|
2771
|
-
computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
|
|
2772
|
-
|
|
4227
|
+
computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs = []) {
|
|
4228
|
+
// Frozen content holds no local ops, so the durable frontier covers it.
|
|
4229
|
+
if (this._sealedRoots !== null)
|
|
4230
|
+
return 'covered';
|
|
4231
|
+
let held;
|
|
2773
4232
|
try {
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
4233
|
+
// An op the transport has only just sent is on its way rather than
|
|
4234
|
+
// missing; counting it as held keeps the handshake from writing a
|
|
4235
|
+
// second copy of it beside the transport's own send.
|
|
4236
|
+
held = durableHoldings(persistedSnapshotVersionVector, [
|
|
4237
|
+
...durableTailBlobs,
|
|
4238
|
+
...deliveringBlobs,
|
|
4239
|
+
]);
|
|
2779
4240
|
}
|
|
2780
4241
|
catch (err) {
|
|
2781
4242
|
console.warn('[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob', err);
|
|
2782
4243
|
return 'undecodable';
|
|
2783
4244
|
}
|
|
2784
4245
|
const local = this._doc.version();
|
|
2785
|
-
const
|
|
2786
|
-
|
|
2787
|
-
// frontier (nothing to push), 1 when local is strictly ahead, and
|
|
2788
|
-
// undefined when the two are concurrent (local holds ops the server
|
|
2789
|
-
// lacks AND vice versa). Push in the ahead and concurrent cases; the
|
|
2790
|
-
// export from `durable` carries only the local-only ops either way.
|
|
2791
|
-
if (cmp !== undefined && cmp <= 0)
|
|
4246
|
+
const missing = rangesMissingFrom(local, held);
|
|
4247
|
+
if (missing.length === 0)
|
|
2792
4248
|
return 'covered';
|
|
2793
|
-
// Common case:
|
|
2794
|
-
//
|
|
2795
|
-
|
|
2796
|
-
const
|
|
2797
|
-
if (
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
const from = durableMap.get(peer) ?? 0;
|
|
2811
|
-
if (to > from)
|
|
2812
|
-
ranges.push({ peer, from, to });
|
|
2813
|
-
}
|
|
2814
|
-
return ranges;
|
|
4249
|
+
// Common case: each author is missing only its newest ops, which is an
|
|
4250
|
+
// `update` export from a version vector, and the slice fits in one frame.
|
|
4251
|
+
const tails = new Map(missing.map((r) => [r.peer, r]));
|
|
4252
|
+
const localMap = local.toJSON();
|
|
4253
|
+
if (tails.size === missing.length &&
|
|
4254
|
+
missing.every((r) => r.to === localMap.get(r.peer))) {
|
|
4255
|
+
const from = new Map(localMap);
|
|
4256
|
+
for (const r of missing)
|
|
4257
|
+
from.set(r.peer, r.from);
|
|
4258
|
+
const full = this._doc.export({
|
|
4259
|
+
mode: 'update',
|
|
4260
|
+
from: VersionVector.parseJSON(from),
|
|
4261
|
+
});
|
|
4262
|
+
if (full.byteLength <= RESYNC_PUSH_MAX_BYTES)
|
|
4263
|
+
return [full];
|
|
4264
|
+
}
|
|
4265
|
+
return oneRangePerAuthor(missing).flatMap((group) => this.chunkRanges(group, true));
|
|
2815
4266
|
}
|
|
2816
4267
|
// Recursively export the given ranges as one `updates-in-range` blob,
|
|
2817
4268
|
// splitting by op count until each blob is under the per-chunk cap.
|
|
@@ -2850,9 +4301,6 @@ export class Document {
|
|
|
2850
4301
|
...this.chunkRanges(second, dropOversizedSingle),
|
|
2851
4302
|
];
|
|
2852
4303
|
}
|
|
2853
|
-
chunkMissingSlice(local, durable) {
|
|
2854
|
-
return this.chunkRanges(this.missingRanges(local, durable), true);
|
|
2855
|
-
}
|
|
2856
4304
|
// A local commit re-exported as several op blobs, or null to ship it whole.
|
|
2857
4305
|
//
|
|
2858
4306
|
// `mutate` bounds what one commit carries by committing as it goes, but a
|
|
@@ -2934,17 +4382,19 @@ export class Document {
|
|
|
2934
4382
|
return;
|
|
2935
4383
|
this._suspended = true;
|
|
2936
4384
|
this._setStreamsSuspended(true);
|
|
2937
|
-
//
|
|
2938
|
-
//
|
|
2939
|
-
//
|
|
2940
|
-
|
|
2941
|
-
this._checkpoints.cancelAuto();
|
|
4385
|
+
// Calls still waiting for a marker get one now rather than after the
|
|
4386
|
+
// spacing, and a pending auto-checkpoint is cancelled rather than left to
|
|
4387
|
+
// fire against a parked Document.
|
|
4388
|
+
this._checkpoints.detach();
|
|
2942
4389
|
this._subs.clear();
|
|
2943
4390
|
this._undoStateSubs.clear();
|
|
2944
4391
|
this._gone.clearListeners();
|
|
2945
4392
|
this._workStatus.clearListeners();
|
|
2946
4393
|
this._driveStatus.clearListeners();
|
|
2947
4394
|
this._drivePublishTarget.clearListeners();
|
|
4395
|
+
this._inputFollowers.stopAll();
|
|
4396
|
+
this._inputStatusHub?.dispose();
|
|
4397
|
+
this._inputStatusHub = null;
|
|
2948
4398
|
// Presence subscribes its OWN WS topic and keeps it alive across a park
|
|
2949
4399
|
// (resume does not re-create the controller), so drop its app-side
|
|
2950
4400
|
// change listeners here the same way as the sets above: a parked doc
|
|
@@ -2959,11 +4409,11 @@ export class Document {
|
|
|
2959
4409
|
// so a subscription an app registered before `close()` would keep firing
|
|
2960
4410
|
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
2961
4411
|
// fresh handles are minted on resume + next access.
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
4412
|
+
// The handles over the ones torn down go with them, so a resumed open is
|
|
4413
|
+
// handed fresh handles like any other.
|
|
4414
|
+
if (this._sealedRoots === null) {
|
|
4415
|
+
this._teardownRootHandles();
|
|
4416
|
+
this._handles.clear();
|
|
2967
4417
|
}
|
|
2968
4418
|
this._rootHandles.clear();
|
|
2969
4419
|
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
@@ -3002,9 +4452,24 @@ export class Document {
|
|
|
3002
4452
|
get _openGeneration() {
|
|
3003
4453
|
return this._openEpoch;
|
|
3004
4454
|
}
|
|
4455
|
+
/** @internal Whether the latest join reported the document as its stored
|
|
4456
|
+
* file with no import run yet. Set by the open layer on every join, so an
|
|
4457
|
+
* importer can hold the document before its claim goes out. */
|
|
4458
|
+
_awaitsImport = false;
|
|
4459
|
+
/** @internal The status an importer holds the document under, or null. */
|
|
4460
|
+
_importHold = null;
|
|
4461
|
+
/** @internal Why this client stopped waiting to import the document, or
|
|
4462
|
+
* null. */
|
|
4463
|
+
_importAbandoned = null;
|
|
3005
4464
|
close() {
|
|
3006
4465
|
if (this._closed)
|
|
3007
4466
|
return;
|
|
4467
|
+
// Before anything is torn down: the final marker reads the seq and the
|
|
4468
|
+
// unacked-op count this close is about to drop.
|
|
4469
|
+
this._checkpoints.detach();
|
|
4470
|
+
this._stopChangeNotice?.();
|
|
4471
|
+
this._stopChangeNotice = null;
|
|
4472
|
+
this._changedListeners.clear();
|
|
3008
4473
|
this._closeStreams();
|
|
3009
4474
|
this._closed = true;
|
|
3010
4475
|
this._suspended = false;
|
|
@@ -3020,6 +4485,10 @@ export class Document {
|
|
|
3020
4485
|
this._undo.free();
|
|
3021
4486
|
this._undo = null;
|
|
3022
4487
|
}
|
|
4488
|
+
if (!this._undoGroup.isOwner(this)) {
|
|
4489
|
+
this._undoGroup.delete(this);
|
|
4490
|
+
this._undoGroup.changed();
|
|
4491
|
+
}
|
|
3023
4492
|
this._thumbnails.destroy();
|
|
3024
4493
|
this._checkpoints.cancelAuto();
|
|
3025
4494
|
this._localOpHook = null;
|
|
@@ -3037,15 +4506,14 @@ export class Document {
|
|
|
3037
4506
|
this._workStatus.clearListeners();
|
|
3038
4507
|
this._driveStatus.clearListeners();
|
|
3039
4508
|
this._drivePublishTarget.clearListeners();
|
|
4509
|
+
this._inputFollowers.stopAll();
|
|
4510
|
+
this._inputStatusHub?.dispose();
|
|
4511
|
+
this._inputStatusHub = null;
|
|
3040
4512
|
// Detach handle subscriptions before freeing the doc (below): a handle's
|
|
3041
4513
|
// per-container Loro subscription outlives the cache reference, and
|
|
3042
4514
|
// `_doc.free()` must not run with subscriptions still attached.
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
entry.handle._teardown();
|
|
3046
|
-
else
|
|
3047
|
-
teardownRootHandle(entry.handle);
|
|
3048
|
-
}
|
|
4515
|
+
if (this._sealedRoots === null)
|
|
4516
|
+
this._teardownRootHandles();
|
|
3049
4517
|
this._rootHandles.clear();
|
|
3050
4518
|
this._orderedMapHandles.clear();
|
|
3051
4519
|
// Free a live historical-checkout preview: it is a distinct LoroDoc
|
|
@@ -3054,7 +4522,7 @@ export class Document {
|
|
|
3054
4522
|
// mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
|
|
3055
4523
|
// has `previewDoc === _doc`, where `_doc.free()` below already covers it
|
|
3056
4524
|
// and an unguarded free would double-free.
|
|
3057
|
-
if (this._checkout && this._checkout.previewDoc !== this.
|
|
4525
|
+
if (this._checkout && this._checkout.previewDoc !== this._engine) {
|
|
3058
4526
|
try {
|
|
3059
4527
|
this._checkout.previewDoc.free();
|
|
3060
4528
|
}
|
|
@@ -3066,7 +4534,7 @@ export class Document {
|
|
|
3066
4534
|
// Loro's WASM has a `free()` to drop the heap-allocated doc state.
|
|
3067
4535
|
// Calling it after `close()` invalidates the doc, which is what we
|
|
3068
4536
|
// want; future mutate/subscribe calls throw above.
|
|
3069
|
-
this.
|
|
4537
|
+
this._engine?.free();
|
|
3070
4538
|
}
|
|
3071
4539
|
}
|
|
3072
4540
|
//# sourceMappingURL=documents.js.map
|