@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/src/documents.ts
CHANGED
|
@@ -9,16 +9,63 @@
|
|
|
9
9
|
// `Object.keys`; doing `loroMap.keys().includes(key)` there makes
|
|
10
10
|
// `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
|
|
11
11
|
|
|
12
|
+
import type { ImportReport } from './import-report.js';
|
|
13
|
+
import { INPUTS_READ_ROOT, INPUTS_REFS_ROOT } from './inputs.js';
|
|
14
|
+
import { importTailOp } from './tail-op-import.js';
|
|
15
|
+
import {
|
|
16
|
+
InputStatusHub,
|
|
17
|
+
type InputStatus,
|
|
18
|
+
type InputsLocalRoots,
|
|
19
|
+
type InputsStatusRuntime,
|
|
20
|
+
} from './inputs-status.js';
|
|
21
|
+
import {
|
|
22
|
+
InputFollowers,
|
|
23
|
+
type InputDocumentHandlers,
|
|
24
|
+
type InputDocumentOptions,
|
|
25
|
+
} from './input-documents.js';
|
|
12
26
|
import {
|
|
13
27
|
LoroDoc,
|
|
14
28
|
LoroMap,
|
|
15
29
|
type LoroText,
|
|
16
30
|
type PeerID,
|
|
31
|
+
type Value,
|
|
17
32
|
VersionVector,
|
|
18
33
|
decodeImportBlobMeta,
|
|
19
34
|
} from './wasm-runtime.js';
|
|
20
35
|
|
|
21
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
NON_UNDOABLE_COMMIT_ORIGIN,
|
|
38
|
+
PAGING_COMMIT_ORIGIN,
|
|
39
|
+
UndoController,
|
|
40
|
+
UndoGroup,
|
|
41
|
+
} from './undo.js';
|
|
42
|
+
import { joinStampOf } from './join-stamp.js';
|
|
43
|
+
import {
|
|
44
|
+
decodeInputsRoots,
|
|
45
|
+
inputReadClearWrites,
|
|
46
|
+
inputReadMarkFor,
|
|
47
|
+
inputReadMarkWrites,
|
|
48
|
+
inputRemovalMark,
|
|
49
|
+
inputRefsWrites,
|
|
50
|
+
inputStampFromUpdatedAt,
|
|
51
|
+
isInputDocId,
|
|
52
|
+
isInputKey,
|
|
53
|
+
parseInputReference,
|
|
54
|
+
type InputReadMark,
|
|
55
|
+
type InputReferenceWire as WireReference,
|
|
56
|
+
type InputsRootWrites,
|
|
57
|
+
} from './inputs.js';
|
|
58
|
+
import {
|
|
59
|
+
ProvisionalInputsTracker,
|
|
60
|
+
isReservedRootName,
|
|
61
|
+
latestReadMark,
|
|
62
|
+
type InputsWrite,
|
|
63
|
+
type MarkReadEntry,
|
|
64
|
+
type MarkReadHandle,
|
|
65
|
+
type MarkReadOptions,
|
|
66
|
+
type MarkReadStatus,
|
|
67
|
+
type ProvisionalInputsPoster,
|
|
68
|
+
} from './references.js';
|
|
22
69
|
import type { DocSyncDiagnostics, UndoControllerOptions } from './types.js';
|
|
23
70
|
import {
|
|
24
71
|
createOrderedMapHandle,
|
|
@@ -37,26 +84,30 @@ import type {
|
|
|
37
84
|
HandleOwner,
|
|
38
85
|
ListHandle,
|
|
39
86
|
MapHandle,
|
|
87
|
+
MutateOptions,
|
|
40
88
|
OrderedMapHandle,
|
|
41
89
|
TextHandle,
|
|
42
90
|
TextSubscriber,
|
|
43
91
|
} from './handle-types.js';
|
|
92
|
+
import { appCallbackError } from './subscriber-delivery.js';
|
|
44
93
|
import {
|
|
45
94
|
flattenPagedLookup,
|
|
46
95
|
isPagedMarker,
|
|
96
|
+
isPagedMarkerEntry,
|
|
47
97
|
META_KEY,
|
|
48
98
|
pagedRootKey,
|
|
99
|
+
RECORDS_KEY,
|
|
100
|
+
rootCarriesMarkers,
|
|
49
101
|
} from './record-paging.js';
|
|
50
102
|
import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
|
|
51
103
|
import type {
|
|
52
104
|
BranchSummary,
|
|
53
|
-
DocumentCheckpoint,
|
|
54
105
|
DocumentSourceBlob,
|
|
55
106
|
DocumentStreamInfo,
|
|
56
107
|
PushToDriveInput,
|
|
57
108
|
PushToDriveResult,
|
|
58
109
|
} from './documents-namespace.js';
|
|
59
|
-
import type { DriveResolveResult } from './hosted-ui.js';
|
|
110
|
+
import type { DriveResolveResult, InputReference } from './hosted-ui.js';
|
|
60
111
|
import {
|
|
61
112
|
GoneSignal,
|
|
62
113
|
WorkStatusSignal,
|
|
@@ -86,6 +137,9 @@ import {
|
|
|
86
137
|
type CheckpointCreator,
|
|
87
138
|
} from './document-checkpoints.js';
|
|
88
139
|
import { whenSaved } from './when-saved.js';
|
|
140
|
+
import { BrassError } from './errors.js';
|
|
141
|
+
import type { OpenedDocument } from './open-document.js';
|
|
142
|
+
import type { OpenModeOptions, ReadOnlyDocument } from './read-only-document.js';
|
|
89
143
|
import {
|
|
90
144
|
BranchController,
|
|
91
145
|
type BranchProvider,
|
|
@@ -100,6 +154,15 @@ import {
|
|
|
100
154
|
} from './document-commands.js';
|
|
101
155
|
import { DocumentStream, type StreamRuntime } from './document-streams.js';
|
|
102
156
|
import { createEmitter } from './emitter.js';
|
|
157
|
+
import {
|
|
158
|
+
CONVERSION_PEER_ID,
|
|
159
|
+
SealedContent,
|
|
160
|
+
type SealedContentJson,
|
|
161
|
+
type SealedStore,
|
|
162
|
+
} from './sealed-store.js';
|
|
163
|
+
import { sealedRoots, type SealedRoots } from './sealed-handles.js';
|
|
164
|
+
import { StableHandles } from './stable-handles.js';
|
|
165
|
+
import type { JoinContent } from './join-content.js';
|
|
103
166
|
|
|
104
167
|
// The stream name `doc.stream()` resolves to the document's own default
|
|
105
168
|
// state; mirrors the server's reserved name.
|
|
@@ -190,6 +253,22 @@ function rootKindOf(containerId: string): KnownRootKind | null {
|
|
|
190
253
|
|
|
191
254
|
// Maps count entries, lists and text count length. Both are the container's
|
|
192
255
|
// own counter rather than a projection of what it holds.
|
|
256
|
+
/** Whether a root map holds nothing but paged markers, which its handle hides,
|
|
257
|
+
* so the collection reads as empty. */
|
|
258
|
+
function holdsOnlyMarkers(rootKey: string, container: unknown): boolean {
|
|
259
|
+
if (!rootCarriesMarkers(rootKey) || !(container instanceof LoroMap)) return false;
|
|
260
|
+
return container.keys().every((key) => isPagedMarkerEntry(rootKey, key));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A root's projected value when the collection holds nothing: a map, list
|
|
264
|
+
// or text with no content.
|
|
265
|
+
function projectsEmpty(value: unknown): boolean {
|
|
266
|
+
if (value === '') return true;
|
|
267
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
268
|
+
if (value !== null && typeof value === 'object') return Object.keys(value).length === 0;
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
193
272
|
function rootIsEmpty(container: unknown): boolean {
|
|
194
273
|
const sized = container as { size?: number; length?: number };
|
|
195
274
|
if (typeof sized.size === 'number') return sized.size === 0;
|
|
@@ -247,6 +326,26 @@ const REDO_COMMIT_ORIGIN = 'brass.redo';
|
|
|
247
326
|
// this (it reconciles by value); it backs a change's `origin.source` for
|
|
248
327
|
// attribution and the SDK's own local-vs-remote side-effect gating
|
|
249
328
|
// (thumbnail capture, auto-checkpoint).
|
|
329
|
+
// A reserved root map as plain entries, the view the write rules diff against.
|
|
330
|
+
function rootView(map: LoroMap): Record<string, unknown> {
|
|
331
|
+
return map.toJSON() as Record<string, unknown>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Applies one reserved root's writes per entry. Returns whether anything moved.
|
|
335
|
+
function applyRootWrites<V>(map: LoroMap, writes: InputsRootWrites<V>): boolean {
|
|
336
|
+
for (const key of writes.delete) map.delete(key);
|
|
337
|
+
for (const [key, value] of Object.entries(writes.set)) map.set(key, value as unknown as Value);
|
|
338
|
+
return writes.delete.length > 0 || Object.keys(writes.set).length > 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// The reserved roots hold the document's references and read marks, which
|
|
342
|
+
// only `dependsOn` and `markRead` write.
|
|
343
|
+
function assertAppRootName(key: string): void {
|
|
344
|
+
if (isReservedRootName(key)) {
|
|
345
|
+
throw new TypeError(`"${key}" is reserved for Brass; choose another collection name`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
250
349
|
function changeSourceOf(event: unknown): ChangeSource {
|
|
251
350
|
const by = (event as { by?: unknown }).by;
|
|
252
351
|
if (by === 'import') return 'remote';
|
|
@@ -322,24 +421,84 @@ export type ResyncPushOutcome =
|
|
|
322
421
|
| 'undecodable'
|
|
323
422
|
| 'closed';
|
|
324
423
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
424
|
+
type CounterRange = { peer: PeerID; from: number; to: number };
|
|
425
|
+
|
|
426
|
+
// Per author, the sorted, disjoint half-open counter ranges a server holds.
|
|
427
|
+
//
|
|
428
|
+
// A version vector cannot describe this: it says an author's ops are held up
|
|
429
|
+
// to a counter, and a server's op log can hold an author's later ops while
|
|
430
|
+
// missing an earlier one. Each tail blob therefore contributes its own range
|
|
431
|
+
// rather than raising a per-author maximum, which would read the hole as held.
|
|
432
|
+
type HeldRanges = Map<PeerID, [number, number][]>;
|
|
433
|
+
|
|
434
|
+
function holdRange(held: HeldRanges, peer: PeerID, from: number, to: number): void {
|
|
435
|
+
if (to <= from) return;
|
|
436
|
+
const merged: [number, number][] = [];
|
|
437
|
+
let next: [number, number] = [from, to];
|
|
438
|
+
for (const range of held.get(peer) ?? []) {
|
|
439
|
+
if (range[1] < next[0] || range[0] > next[1]) {
|
|
440
|
+
merged.push(range);
|
|
441
|
+
} else {
|
|
442
|
+
next = [Math.min(range[0], next[0]), Math.max(range[1], next[1])];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
merged.push(next);
|
|
446
|
+
merged.sort((a, b) => a[0] - b[0]);
|
|
447
|
+
held.set(peer, merged);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// What the durable state holds: the snapshot, which is causally complete so
|
|
451
|
+
// every author's ops from zero, plus each tail blob's own per-author range.
|
|
452
|
+
// Throws on a malformed vector or blob.
|
|
453
|
+
function durableHoldings(
|
|
454
|
+
persistedSnapshotVersionVector: Uint8Array,
|
|
455
|
+
durableTailBlobs: readonly Uint8Array[],
|
|
456
|
+
): HeldRanges {
|
|
457
|
+
const held: HeldRanges = new Map();
|
|
458
|
+
for (const [peer, to] of VersionVector.decode(persistedSnapshotVersionVector).toJSON()) {
|
|
459
|
+
holdRange(held, peer, 0, to);
|
|
460
|
+
}
|
|
461
|
+
for (const blob of durableTailBlobs) {
|
|
462
|
+
const meta = decodeImportBlobMeta(blob, false);
|
|
463
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
464
|
+
for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
|
|
465
|
+
holdRange(held, peer, start.get(peer) ?? 0, to);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return held;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The ranges of `local` the durable state does not hold, in counter order
|
|
472
|
+
// per author.
|
|
473
|
+
function rangesMissingFrom(local: VersionVector, held: HeldRanges): CounterRange[] {
|
|
474
|
+
const missing: CounterRange[] = [];
|
|
475
|
+
for (const [peer, to] of local.toJSON()) {
|
|
476
|
+
let from = 0;
|
|
477
|
+
for (const [heldFrom, heldTo] of held.get(peer) ?? []) {
|
|
478
|
+
if (heldFrom >= to) break;
|
|
479
|
+
if (heldFrom > from) missing.push({ peer, from, to: heldFrom });
|
|
480
|
+
from = Math.max(from, heldTo);
|
|
481
|
+
}
|
|
482
|
+
if (from < to) missing.push({ peer, from, to });
|
|
483
|
+
}
|
|
484
|
+
return missing;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Group ranges so no group carries two ranges of one author. A blob's header
|
|
488
|
+
// names one start and one end per author, so a blob spanning two separate
|
|
489
|
+
// ranges of an author would read, to whoever folds it into a frontier, as
|
|
490
|
+
// holding the ops between them as well.
|
|
491
|
+
function oneRangePerAuthor(ranges: readonly CounterRange[]): CounterRange[][] {
|
|
492
|
+
const groups: CounterRange[][] = [];
|
|
493
|
+
const seen = new Map<PeerID, number>();
|
|
494
|
+
for (const range of ranges) {
|
|
495
|
+
const index = seen.get(range.peer) ?? 0;
|
|
496
|
+
seen.set(range.peer, index + 1);
|
|
497
|
+
(groups[index] ??= []).push(range);
|
|
498
|
+
}
|
|
499
|
+
return groups;
|
|
340
500
|
}
|
|
341
501
|
|
|
342
|
-
type CounterRange = { peer: PeerID; from: number; to: number };
|
|
343
502
|
|
|
344
503
|
function rangeOpCount(ranges: readonly CounterRange[]): number {
|
|
345
504
|
return ranges.reduce((n, r) => n + (r.to - r.from), 0);
|
|
@@ -504,8 +663,29 @@ function pageRoot(
|
|
|
504
663
|
// Why a live Document refuses local writes. Each names a refusal the server
|
|
505
664
|
// would make, so `mutate` can say which one applies rather than answering
|
|
506
665
|
// every refusal with one message.
|
|
507
|
-
/**
|
|
508
|
-
|
|
666
|
+
/**
|
|
667
|
+
* Why a reader may not write, where the refusal comes from somewhere OUTSIDE
|
|
668
|
+
* this document: the server, the link they arrived through, or the caller's
|
|
669
|
+
* own ask. `_markReadOnly` takes one of these and nothing else, because the
|
|
670
|
+
* reasons below it are states the document computes for itself and could not
|
|
671
|
+
* be handed one of.
|
|
672
|
+
* @internal
|
|
673
|
+
*/
|
|
674
|
+
/** The three ways a document is open: editable, read-only and following
|
|
675
|
+
* edits, or frozen at what it loaded.
|
|
676
|
+
* @internal */
|
|
677
|
+
export type ReopenMode = 'edit' | 'read' | 'frozen';
|
|
678
|
+
|
|
679
|
+
interface PendingReopen {
|
|
680
|
+
mode: ReopenMode;
|
|
681
|
+
// What the reopen produced, whether or not a later reopen superseded it.
|
|
682
|
+
result: Promise<OpenedDocument>;
|
|
683
|
+
superseded: boolean;
|
|
684
|
+
// What the call that started it receives.
|
|
685
|
+
promise: Promise<OpenedDocument>;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export type ForcedReadOnlyReason =
|
|
509
689
|
// Opened through a view-only share link.
|
|
510
690
|
| 'link'
|
|
511
691
|
// The reader's grant on this document does not carry write access.
|
|
@@ -523,7 +703,55 @@ export type ReadOnlyReason =
|
|
|
523
703
|
| 'capacity'
|
|
524
704
|
// The document opened with no live connection to write through, so a
|
|
525
705
|
// change would stay on this device.
|
|
526
|
-
| 'no_connection'
|
|
706
|
+
| 'no_connection'
|
|
707
|
+
// The caller asked for a snapshot of the document rather than a session on
|
|
708
|
+
// it. Unlike every reason above, this one is the caller's own choice rather
|
|
709
|
+
// than a limit on them, so a reader seeing it may well hold write access and
|
|
710
|
+
// reach it by opening the document again without asking for a snapshot.
|
|
711
|
+
| 'snapshot'
|
|
712
|
+
// The document is too large for the platform to make editable, so it opens
|
|
713
|
+
// as a copy to read and no reload changes that.
|
|
714
|
+
| 'too_large'
|
|
715
|
+
// The document still holds only the file it was uploaded as, and no
|
|
716
|
+
// importer takes that file's type, so the file is its content.
|
|
717
|
+
| 'not_imported';
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Why a reader may not write, in the vocabulary `mutate` refuses in.
|
|
721
|
+
*
|
|
722
|
+
* The four beyond the forced ones are states this document computes for
|
|
723
|
+
* itself: it is showing a historical version, its reader has the version bar
|
|
724
|
+
* open, its content has not arrived, or the work that would bring it ended
|
|
725
|
+
* with nothing. They are derived rather than set, which is why one function
|
|
726
|
+
* (`_readOnlyBecause`) answers both `readOnly` and the refusal message. Two
|
|
727
|
+
* ladders over the same conditions is what they replace, and the pair could
|
|
728
|
+
* disagree about whether a document was writable and about why.
|
|
729
|
+
* @internal
|
|
730
|
+
*/
|
|
731
|
+
export type ReadOnlyReason =
|
|
732
|
+
| ForcedReadOnlyReason
|
|
733
|
+
// A historical version is showing (a checkout).
|
|
734
|
+
| 'version'
|
|
735
|
+
// The reader has the version bar open. Browsing locks the document at the
|
|
736
|
+
// live head as well as at an earlier point: stepping moves between the
|
|
737
|
+
// points history KEEPS, and an edit at the head makes no such point, so an
|
|
738
|
+
// edit made under the open bar puts the reader's own newest work somewhere
|
|
739
|
+
// the bar cannot travel to. Reading history and writing it are separate
|
|
740
|
+
// modes, and the bar carries the Close that leaves this one.
|
|
741
|
+
| 'browsing'
|
|
742
|
+
// Content-replacing work is still coming. Editing would write ops onto a
|
|
743
|
+
// document the incoming bytes are about to replace, and it is also what
|
|
744
|
+
// stops an app's "seed an empty doc" path from clobbering a document that
|
|
745
|
+
// only looks empty because it has not loaded yet.
|
|
746
|
+
| 'loading'
|
|
747
|
+
// That work ended with no content. A document nothing could prepare is
|
|
748
|
+
// empty, and an edit there writes ops into one the app is about to be told
|
|
749
|
+
// could not be opened.
|
|
750
|
+
| 'load_failed'
|
|
751
|
+
// `reopen` is replacing this copy. The copy closes once the new one has
|
|
752
|
+
// joined, and closing it drops whatever it has not sent, so an edit made
|
|
753
|
+
// here in the meantime would be lost.
|
|
754
|
+
| 'reopening';
|
|
527
755
|
|
|
528
756
|
const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
529
757
|
link: 'Document is read-only: it was opened via a view-only share link.',
|
|
@@ -538,12 +766,80 @@ const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
|
538
766
|
no_connection:
|
|
539
767
|
'Document is read-only: it opened with no live connection, so a change ' +
|
|
540
768
|
'would stay on this device.',
|
|
769
|
+
snapshot:
|
|
770
|
+
'Document is read-only: it was opened as a snapshot. Open it without ' +
|
|
771
|
+
'asking for one to edit.',
|
|
772
|
+
too_large:
|
|
773
|
+
'Document is read-only: it is too large for Brass to make editable ' +
|
|
774
|
+
'(see `doc.status`).',
|
|
775
|
+
not_imported:
|
|
776
|
+
'Document is read-only: it holds an uploaded file that has not been ' +
|
|
777
|
+
'imported, and no importer takes its type.',
|
|
778
|
+
version:
|
|
779
|
+
'Document is read-only: it is showing a historical version. ' +
|
|
780
|
+
'Return to the latest version to edit.',
|
|
781
|
+
browsing:
|
|
782
|
+
'Document is read-only: the reader is browsing its versions. ' +
|
|
783
|
+
'Close the version bar to edit.',
|
|
784
|
+
loading:
|
|
785
|
+
'Document is still loading its content; wait for the load to finish ' +
|
|
786
|
+
'before editing (see `doc.onStatus`).',
|
|
787
|
+
reopening:
|
|
788
|
+
'Document is read-only while it reopens; edit the copy `reopen` resolves with.',
|
|
789
|
+
load_failed:
|
|
790
|
+
'Document is read-only: its content could not be prepared ' +
|
|
791
|
+
'(see `doc.status`). Reopen it to try again.',
|
|
541
792
|
};
|
|
542
793
|
|
|
794
|
+
// What `status` reads while this client's edits wait on a connection that has
|
|
795
|
+
// never opened. Pending, because Brass keeps trying and the edits save the
|
|
796
|
+
// moment it connects; not blocking, because editing stays open.
|
|
797
|
+
const NOT_SAVED_NOTICE: DocumentWorkStatus = {
|
|
798
|
+
message:
|
|
799
|
+
'Your changes are not saved yet. Brass cannot connect to save them and ' +
|
|
800
|
+
'keeps trying. Keep this page open until they are saved.',
|
|
801
|
+
pending: true,
|
|
802
|
+
blocking: false,
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
// What `status` reads while one of this client's edits goes unanswered by a
|
|
806
|
+
// connection that answers its others. Pending and not blocking for the same
|
|
807
|
+
// reasons as `NOT_SAVED_NOTICE`.
|
|
808
|
+
const UNANSWERED_NOTICE: DocumentWorkStatus = {
|
|
809
|
+
message:
|
|
810
|
+
'Some of your changes are not saved yet. Brass keeps trying to save them. ' +
|
|
811
|
+
'Keep this page open until they are saved.',
|
|
812
|
+
pending: true,
|
|
813
|
+
blocking: false,
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const SAVE_NOTICES = { never_opened: NOT_SAVED_NOTICE, unanswered: UNANSWERED_NOTICE };
|
|
817
|
+
|
|
543
818
|
export interface DocumentOptions {
|
|
544
819
|
// Root key the Document wraps. Defaults to `"entities"` to match the
|
|
545
820
|
// wire protocol's top-level shape.
|
|
546
821
|
rootKey?: string;
|
|
822
|
+
/** The document's content in its frozen form, for a stream the platform
|
|
823
|
+
* holds that way. Its roots answer every read, and no CRDT engine
|
|
824
|
+
* materialises them.
|
|
825
|
+
* @internal */
|
|
826
|
+
frozenContent?: SealedContent;
|
|
827
|
+
/** The seq `frozenContent` was sealed at, as the join named it.
|
|
828
|
+
* @internal */
|
|
829
|
+
frozenSeal?: number;
|
|
830
|
+
/** Author every commit as this peer rather than a random one.
|
|
831
|
+
*
|
|
832
|
+
* What it buys is a DETERMINISTIC document: the same records written under
|
|
833
|
+
* the same peer produce the same ops, so two sides that build a document
|
|
834
|
+
* from one record set independently build the same one, and their later
|
|
835
|
+
* edits merge instead of doubling every record. That is what lets a stored
|
|
836
|
+
* record set become a CRDT wherever it is first needed, with nothing
|
|
837
|
+
* coordinating which side does it.
|
|
838
|
+
*
|
|
839
|
+
* Only a conversion sets this. A live editor keeps its own peer, so the
|
|
840
|
+
* ops it writes after a conversion are its own.
|
|
841
|
+
* @internal */
|
|
842
|
+
peerId?: `${number}`;
|
|
547
843
|
// The server-minted `doc_id`. Set by the SDK's open / create paths so
|
|
548
844
|
// callers read `doc.id` to track the active doc. `null` on
|
|
549
845
|
// direct-construction (`Document.create()` / `openFromSnapshot`),
|
|
@@ -555,11 +851,12 @@ export interface DocumentOptions {
|
|
|
555
851
|
branchId?: string;
|
|
556
852
|
// True when opened at a historical version. A read-only Document
|
|
557
853
|
// throws from `mutate` and carries no live WS transport.
|
|
854
|
+
/** @deprecated Pass `readOnly: true` to `session.open` / `Brass.openDocumentById`, which types the result `ReadOnlyDocument`. */
|
|
558
855
|
readOnly?: boolean;
|
|
559
856
|
// Why `readOnly` was set, which picks the message `mutate` throws.
|
|
560
857
|
// Defaults to the view-only link.
|
|
561
858
|
/** @internal */
|
|
562
|
-
readOnlyReason?:
|
|
859
|
+
readOnlyReason?: ForcedReadOnlyReason;
|
|
563
860
|
// The historical version materialized at, when opened via `{ version
|
|
564
861
|
// }`; `null` for a live (head) open. The server's *effective* version,
|
|
565
862
|
// which may round down when compaction has TTL'd the exact op.
|
|
@@ -588,9 +885,242 @@ interface RootTextHandle extends TextHandle {
|
|
|
588
885
|
_teardown(): void;
|
|
589
886
|
}
|
|
590
887
|
|
|
888
|
+
// The roots a document's referenced inputs live in are written only by the
|
|
889
|
+
// SDK's own input writers, so a frozen root carrying one of those names
|
|
890
|
+
// converts to nothing.
|
|
891
|
+
const INPUTS_ROOTS: ReadonlySet<string> = new Set([INPUTS_REFS_ROOT, INPUTS_READ_ROOT]);
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The CRDT a frozen stream becomes: its content written through the same
|
|
895
|
+
* handles an app writes through, so what a conversion produces is what an app
|
|
896
|
+
* writing the same content would have produced, paging and all.
|
|
897
|
+
*
|
|
898
|
+
* Every side converts by calling this. It is authored by the pinned
|
|
899
|
+
* conversion peer (`CONVERSION_PEER_ID`) over the content in stored order, so
|
|
900
|
+
* one frozen stream always converts to the same document; it is NOT a licence
|
|
901
|
+
* for two builds to convert separately and expect to agree, because the rest
|
|
902
|
+
* of the output moves with the paging constants and the batching budget, which
|
|
903
|
+
* is why the platform converts once and stores the result.
|
|
904
|
+
*
|
|
905
|
+
* A list that is a map's key walk is that map's order, so the two are written
|
|
906
|
+
* as one ordered map.
|
|
907
|
+
*
|
|
908
|
+
* @internal
|
|
909
|
+
*/
|
|
910
|
+
export function snapshotFromSealedContent(content: SealedContent): Uint8Array {
|
|
911
|
+
const names = content.rootNames().filter((n) => !INPUTS_ROOTS.has(n));
|
|
912
|
+
const walked = new Map<string, string>();
|
|
913
|
+
for (const name of names) {
|
|
914
|
+
const map = content.keyWalkOf(name);
|
|
915
|
+
if (map !== null && !walked.has(map)) walked.set(map, name);
|
|
916
|
+
}
|
|
917
|
+
// A `Document` registers its root container on construction, so the root
|
|
918
|
+
// key is one the content writes anyway rather than an empty `entities` map
|
|
919
|
+
// in every snapshot.
|
|
920
|
+
const rootKey =
|
|
921
|
+
[...walked.keys()][0] ??
|
|
922
|
+
names.find((n) => content.kindOf(n) === 'map') ??
|
|
923
|
+
'entities';
|
|
924
|
+
const doc = Document.create({ rootKey, peerId: CONVERSION_PEER_ID });
|
|
925
|
+
try {
|
|
926
|
+
for (const name of names) {
|
|
927
|
+
// A reserved root is written only through `dependsOn` / `markRead`.
|
|
928
|
+
if (isReservedRootName(name)) continue;
|
|
929
|
+
const kind = content.kindOf(name);
|
|
930
|
+
if (kind === 'map') {
|
|
931
|
+
const order = walked.get(name);
|
|
932
|
+
const write = (draft: Record<string, unknown>): void => {
|
|
933
|
+
content.forEachMapEntry(name, (key, value) => {
|
|
934
|
+
draft[key] = value;
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
if (order !== undefined) doc.orderedMap<unknown>(name, order).mutate(write);
|
|
938
|
+
else doc.map<unknown>(name).mutate(write);
|
|
939
|
+
} else if (kind === 'list') {
|
|
940
|
+
const map = content.keyWalkOf(name);
|
|
941
|
+
if (map !== null && walked.get(map) === name) continue;
|
|
942
|
+
const items = content.list(name) ?? [];
|
|
943
|
+
if (items.length === 0) continue;
|
|
944
|
+
doc.list<unknown>(name).mutate((list) => {
|
|
945
|
+
for (const item of items) list.push(item);
|
|
946
|
+
});
|
|
947
|
+
} else if (kind === 'text') {
|
|
948
|
+
const text = content.text(name) ?? '';
|
|
949
|
+
if (text !== '') doc.text(name).update(text);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return doc._exportSnapshot();
|
|
953
|
+
} finally {
|
|
954
|
+
doc.close();
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* A CRDT snapshot's content as JSON, read the way an app reads it (a paged
|
|
960
|
+
* root flattened to its records), holding exactly the roots the snapshot
|
|
961
|
+
* holds. A Document registers its root map on construction, so it is opened
|
|
962
|
+
* on a root the snapshot already has rather than on the default.
|
|
963
|
+
* @internal
|
|
964
|
+
*/
|
|
965
|
+
export function snapshotContentJSON(snapshot: Uint8Array): Record<string, unknown> {
|
|
966
|
+
// One engine, and the roots named from its shallow value: a whole-engine
|
|
967
|
+
// `toJSON` here would hold a second copy of the content beside the one the
|
|
968
|
+
// read below builds.
|
|
969
|
+
const engine = new LoroDoc();
|
|
970
|
+
engine.import(snapshot);
|
|
971
|
+
const shallow = engine.getShallowValue() as Record<string, unknown>;
|
|
972
|
+
const held = Object.keys(shallow);
|
|
973
|
+
const rootKey = held.find((k) => {
|
|
974
|
+
if (isReservedRootName(k)) return false;
|
|
975
|
+
const id = shallow[k];
|
|
976
|
+
return typeof id === 'string' && id.endsWith(':Map');
|
|
977
|
+
});
|
|
978
|
+
const doc = Document._fromEngine(engine, rootKey !== undefined ? { rootKey } : undefined);
|
|
979
|
+
try {
|
|
980
|
+
const json = doc.toJSON();
|
|
981
|
+
for (const key of Object.keys(json)) if (!held.includes(key)) delete json[key];
|
|
982
|
+
return json;
|
|
983
|
+
} finally {
|
|
984
|
+
doc.close();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The CRDT a stored record store becomes. The same conversion as
|
|
989
|
+
* `snapshotFromSealedContent`, reached from the record-store view.
|
|
990
|
+
* @internal */
|
|
991
|
+
/** A whole CRDT document as plain JSON, its paged roots as records. */
|
|
992
|
+
function engineJSON(source: LoroDoc): Record<string, unknown> {
|
|
993
|
+
const json = source.toJSON() as Record<string, unknown>;
|
|
994
|
+
// The document's references and read marks are the platform's, read
|
|
995
|
+
// through `dependsOn` and the input status rather than as content.
|
|
996
|
+
for (const key of Object.keys(json)) if (isReservedRootName(key)) delete json[key];
|
|
997
|
+
const meta = json[META_KEY];
|
|
998
|
+
if (isRootLookup(meta)) {
|
|
999
|
+
for (const key of Object.keys(meta)) {
|
|
1000
|
+
if (isPagedMarkerEntry(META_KEY, key)) delete meta[key];
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// A paged root holds its records inside page entries beside the store's
|
|
1004
|
+
// own edit bookkeeping, so flatten it to the same record lookup
|
|
1005
|
+
// `doc.map(root).read()` hands back: a serializer and a whole-document
|
|
1006
|
+
// renderer read this surface, and a store that crossed the paging
|
|
1007
|
+
// threshold has to reach them as records. Every root is asked, because a
|
|
1008
|
+
// root no handle was ever taken on is paged by the same rule as one that
|
|
1009
|
+
// was; a root the marker does not name comes back as loro produced it.
|
|
1010
|
+
for (const [rootKey, value] of Object.entries(json)) {
|
|
1011
|
+
if (!isRootLookup(value)) continue;
|
|
1012
|
+
if (!readRootPaged(source, rootKey)) continue;
|
|
1013
|
+
json[rootKey] = flattenPagedLookup(value, true);
|
|
1014
|
+
}
|
|
1015
|
+
return json;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** A CRDT engine holding decoded join content. */
|
|
1019
|
+
function engineFrom(content: JoinContent): LoroDoc {
|
|
1020
|
+
const doc = new LoroDoc();
|
|
1021
|
+
if (content.kind === 'crdt') doc.import(content.bytes);
|
|
1022
|
+
else if (content.kind === 'frozen') doc.import(snapshotFromSealedContent(content.content));
|
|
1023
|
+
return doc;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* A version of a Document over frozen content, as the content its handles read.
|
|
1028
|
+
*
|
|
1029
|
+
* Frozen content with nothing above it is the version itself. Anything else
|
|
1030
|
+
* (a version stored as a CRDT, or a tail over the seal) is materialised and
|
|
1031
|
+
* read back as content: a preview takes no write, so the engine it needs is
|
|
1032
|
+
* built for the read and freed after it.
|
|
1033
|
+
*/
|
|
1034
|
+
function frozenPreview(content: JoinContent, tailOpBlobs: Uint8Array[]): SealedContent {
|
|
1035
|
+
if (content.kind === 'frozen' && tailOpBlobs.length === 0) return content.content;
|
|
1036
|
+
const engine = engineFrom(content);
|
|
1037
|
+
try {
|
|
1038
|
+
for (const blob of tailOpBlobs) importTailOp(engine, blob);
|
|
1039
|
+
return SealedContent.fromJSON(engineJSON(engine) as SealedContentJson);
|
|
1040
|
+
} finally {
|
|
1041
|
+
engine.free();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export function snapshotFromSealedStore(store: SealedStore): Uint8Array {
|
|
1046
|
+
return snapshotFromSealedContent(store.content);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** What a record-store writer is handed. */
|
|
1050
|
+
export interface RecordStoreSource {
|
|
1051
|
+
schemaVersion: string;
|
|
1052
|
+
header: readonly { tag: string; body: unknown }[];
|
|
1053
|
+
/** Walks every record, in order. */
|
|
1054
|
+
forEachRecord(
|
|
1055
|
+
visit: (key: string, tag: string | null, body: unknown) => void,
|
|
1056
|
+
): void;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Write a record store as a CRDT snapshot through the one conversion every
|
|
1061
|
+
* frozen stream takes.
|
|
1062
|
+
*/
|
|
1063
|
+
export function recordStoreSnapshot(source: RecordStoreSource): Uint8Array {
|
|
1064
|
+
const records: [string, string | null, unknown][] = [];
|
|
1065
|
+
source.forEachRecord((key, tag, body) => {
|
|
1066
|
+
records.push([key, tag, body]);
|
|
1067
|
+
});
|
|
1068
|
+
return snapshotFromSealedContent(
|
|
1069
|
+
SealedContent.fromRecords({
|
|
1070
|
+
schemaVersion: source.schemaVersion,
|
|
1071
|
+
header: source.header,
|
|
1072
|
+
records,
|
|
1073
|
+
}),
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Materialises a line at a version: the content the join names, the tail
|
|
1079
|
+
* above it, and the version the server resolved. `streamSeqs` is the per-stream
|
|
1080
|
+
* seq vector at that version (from the newest checkpoint at or below it),
|
|
1081
|
+
* `{ line_id: seq }`, which lets a preview bring every opened stream to the
|
|
1082
|
+
* same point; absent for a stream line's own timeline and before any
|
|
1083
|
+
* checkpoint recorded one.
|
|
1084
|
+
* @internal
|
|
1085
|
+
*/
|
|
1086
|
+
export type CheckoutFetcher = (
|
|
1087
|
+
docId: string,
|
|
1088
|
+
version: number,
|
|
1089
|
+
branchId: string,
|
|
1090
|
+
) => Promise<{
|
|
1091
|
+
content: JoinContent;
|
|
1092
|
+
tailOpBlobs: Uint8Array[];
|
|
1093
|
+
effectiveVersion: number;
|
|
1094
|
+
streamSeqs?: Record<string, number>;
|
|
1095
|
+
}>;
|
|
1096
|
+
|
|
591
1097
|
export class Document {
|
|
592
|
-
private
|
|
593
|
-
private
|
|
1098
|
+
private _importReport: ImportReport | null = null;
|
|
1099
|
+
private _changed = false;
|
|
1100
|
+
private _changeNotice: ((fire: () => void) => () => void) | null = null;
|
|
1101
|
+
private _stopChangeNotice: (() => void) | null = null;
|
|
1102
|
+
private readonly _changedListeners = new Set<() => void>();
|
|
1103
|
+
// A frozen copy's reload, wired by the transport: re-joins and applies the
|
|
1104
|
+
// result in place. `null` on every other open.
|
|
1105
|
+
private _frozenReloader: (() => Promise<void>) | null = null;
|
|
1106
|
+
// Opens this document again in another mode. Installed by whichever layer
|
|
1107
|
+
// opened it: the session for the document it holds, the client otherwise.
|
|
1108
|
+
private _reopener: ((mode: ReopenMode) => Promise<OpenedDocument>) | null = null;
|
|
1109
|
+
private _reloading: Promise<void> | null = null;
|
|
1110
|
+
// `onStatus` listeners on a frozen copy, which hears status on the same
|
|
1111
|
+
// notice the change report rides.
|
|
1112
|
+
private _statusWatches = 0;
|
|
1113
|
+
private _inputsRuntime: InputsStatusRuntime | null = null;
|
|
1114
|
+
// Built on the first status subscription.
|
|
1115
|
+
private _inputStatusHub: InputStatusHub | null = null;
|
|
1116
|
+
// The `subscribeInputDocuments` followers, which close and park stop so no
|
|
1117
|
+
// retry, handler or kept copy outlives the document.
|
|
1118
|
+
private readonly _inputFollowers = new InputFollowers();
|
|
1119
|
+
|
|
1120
|
+
// The CRDT engine. `null` until something reads it: a Document over frozen
|
|
1121
|
+
// content never does, so it holds no engine at all.
|
|
1122
|
+
private _engine: LoroDoc | null;
|
|
1123
|
+
private _rootKey: string;
|
|
594
1124
|
// Server-minted `doc_id`, or `null` when constructed without one
|
|
595
1125
|
// (test / offline path).
|
|
596
1126
|
private readonly _docId: string | null;
|
|
@@ -622,6 +1152,32 @@ export class Document {
|
|
|
622
1152
|
// and `returnToHead` reject. The live + checkout-on-demand path
|
|
623
1153
|
// leaves this `false`.
|
|
624
1154
|
private readonly _isFrozen: boolean;
|
|
1155
|
+
// The frozen content this Document reads, when the platform holds its
|
|
1156
|
+
// content in that encoding. Its roots serve `map` / `list` / `orderedMap`
|
|
1157
|
+
// and `toJSON` in place of the CRDT's, so an app reads one document
|
|
1158
|
+
// surface whichever way the content is stored. `null` for a CRDT document.
|
|
1159
|
+
private _sealedRoots: SealedRoots | null;
|
|
1160
|
+
// The frozen content at the head, and the content the roots read now: the
|
|
1161
|
+
// head, or a version a checkout swapped in. `null` with `_sealedRoots`.
|
|
1162
|
+
private _frozenHead: SealedContent | null;
|
|
1163
|
+
private _frozenShown: SealedContent | null;
|
|
1164
|
+
// The version a Document over frozen content is showing, or `null` for its
|
|
1165
|
+
// head.
|
|
1166
|
+
private _frozenVersion: number | null = null;
|
|
1167
|
+
// The seq the frozen content was sealed at, which is what says whether a
|
|
1168
|
+
// later join's frozen content is the same content.
|
|
1169
|
+
private _frozenSeal: number | null;
|
|
1170
|
+
// What `map` / `list` / `text` / `orderedMap` hand out: one handle per
|
|
1171
|
+
// root, over whichever storage this Document holds now.
|
|
1172
|
+
private readonly _handles = new StableHandles((err) => this._handleOwner.reportSubscriberThrow(err));
|
|
1173
|
+
// What an engine built for this Document after construction is built with:
|
|
1174
|
+
// the root a CRDT open would root `mutate` at, and the caller's engine
|
|
1175
|
+
// options.
|
|
1176
|
+
private _engineRootKey: string;
|
|
1177
|
+
private readonly _engineOptions: Pick<DocumentOptions, 'peerId' | 'undo'>;
|
|
1178
|
+
// Swaps this Document's frozen content for the CRDT the server now holds,
|
|
1179
|
+
// wired by the transport. Absent, a remote op over frozen content reloads.
|
|
1180
|
+
private _storageUpgrader: (() => void) | null = null;
|
|
625
1181
|
// Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
|
|
626
1182
|
// is a version-pinned open with no live channel): the Document keeps a
|
|
627
1183
|
// live WS subscription and applies incoming remote ops, but local
|
|
@@ -630,8 +1186,11 @@ export class Document {
|
|
|
630
1186
|
// grant, a view-only link), and latched on afterwards by
|
|
631
1187
|
// `_markReadOnly` when the server refuses a write on the same grounds.
|
|
632
1188
|
private _forcedReadOnly: boolean;
|
|
1189
|
+
// Set while `reopen` replaces this copy (see the `reopening` reason).
|
|
1190
|
+
private _reopening = false;
|
|
1191
|
+
private _pendingReopen: PendingReopen | null = null;
|
|
633
1192
|
// Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
|
|
634
|
-
private _readOnlyReason:
|
|
1193
|
+
private _readOnlyReason: ForcedReadOnlyReason;
|
|
635
1194
|
// The UndoManager is constructed eagerly so the first call to
|
|
636
1195
|
// `doc.mutate(...)` lands in the undo stack. Lazy construction
|
|
637
1196
|
// (deferred to first `doc.undo()`) would miss every commit made
|
|
@@ -640,6 +1199,10 @@ export class Document {
|
|
|
640
1199
|
// only; it does NOT fire on snapshot import, so the
|
|
641
1200
|
// cold-load contract is unaffected (imports are not local commits).
|
|
642
1201
|
private _undo: UndoController | null;
|
|
1202
|
+
// The document's one undo stack across its streams. A Document owns the
|
|
1203
|
+
// group it was created with; a stream's own Document joins its document's
|
|
1204
|
+
// group when the stream opens (`_joinUndoGroup`).
|
|
1205
|
+
private _undoGroup: UndoGroup = new UndoGroup();
|
|
643
1206
|
// One subscriber channel. Every event reaches every subscriber, whatever
|
|
644
1207
|
// produced it (local mutate, remote import, undo, redo, checkout); the
|
|
645
1208
|
// app reconciles by value and reads `origin.source` only when it has a
|
|
@@ -657,6 +1220,11 @@ export class Document {
|
|
|
657
1220
|
private readonly _undoStateSubs: Set<UndoStateSubscriber> = new Set();
|
|
658
1221
|
private _lastCanUndo = false;
|
|
659
1222
|
private _lastCanRedo = false;
|
|
1223
|
+
private _replayingUndo = false;
|
|
1224
|
+
// The change a replay produced, held until the replay ends so `subscribe`
|
|
1225
|
+
// fires once per `undo()` / `redo()`. A listener that writes in reaction
|
|
1226
|
+
// would otherwise commit mid-replay, joining the step being replayed.
|
|
1227
|
+
private _replayChange: ChangeSource | null = null;
|
|
660
1228
|
private _unsubLoro: (() => void) | null = null;
|
|
661
1229
|
private _unsubLocal: (() => void) | null = null;
|
|
662
1230
|
// Local commits drive this hook with the resulting op blob; the hook
|
|
@@ -667,23 +1235,7 @@ export class Document {
|
|
|
667
1235
|
private _localOpHook: LocalOpSendHook | null = null;
|
|
668
1236
|
// Materialises a historical version against the server; returns the
|
|
669
1237
|
// snapshot bytes (or null) + tail op blobs + effective version.
|
|
670
|
-
private _checkoutFetcher:
|
|
671
|
-
| ((
|
|
672
|
-
docId: string,
|
|
673
|
-
version: number,
|
|
674
|
-
branchId: string,
|
|
675
|
-
) => Promise<{
|
|
676
|
-
snapshotBytes: Uint8Array | null;
|
|
677
|
-
tailOpBlobs: Uint8Array[];
|
|
678
|
-
effectiveVersion: number;
|
|
679
|
-
// The per-stream seq vector at this version (from the newest
|
|
680
|
-
// checkpoint at or below it): `{ line_id: seq }`. Lets a preview
|
|
681
|
-
// re-materialize every opened stream at the same point in time.
|
|
682
|
-
// Absent for a stream line's own timeline (no checkpoints) and
|
|
683
|
-
// before any checkpoint recorded one.
|
|
684
|
-
streamSeqs?: Record<string, number>;
|
|
685
|
-
}>)
|
|
686
|
-
| null = null;
|
|
1238
|
+
private _checkoutFetcher: CheckoutFetcher | null = null;
|
|
687
1239
|
// The stream seq vector from the active preview, so a stream opened WHILE
|
|
688
1240
|
// a preview is active can open at its historical seq too. Cleared on
|
|
689
1241
|
// `returnToHead`.
|
|
@@ -715,22 +1267,30 @@ export class Document {
|
|
|
715
1267
|
// still hydrating is exactly the one the seed watch is armed for, and
|
|
716
1268
|
// the arm happens while that lock is still on.
|
|
717
1269
|
isCaptureAllowed: (): boolean =>
|
|
718
|
-
this._isLive && this.
|
|
1270
|
+
this._isLive && this._previewVersion === null && !this._forcedReadOnly,
|
|
719
1271
|
isAttached: (): boolean => this._isLive,
|
|
720
|
-
|
|
721
|
-
|
|
1272
|
+
currentVersion: (): number | null => this.currentSeq,
|
|
1273
|
+
onCaptured: (version?: number): void => {
|
|
1274
|
+
this._thumbnailVersion = version ?? this.currentSeq ?? 0;
|
|
722
1275
|
},
|
|
723
1276
|
});
|
|
724
1277
|
private readonly _checkpoints = new CheckpointScheduler({
|
|
725
1278
|
docId: (): string | null => this._docId,
|
|
726
1279
|
isAttached: (): boolean => this._isLive,
|
|
727
1280
|
isAutoAllowed: (): boolean =>
|
|
728
|
-
this._isLive && this.
|
|
1281
|
+
this._isLive && this._previewVersion === null,
|
|
729
1282
|
currentSeq: (): number | null => this.currentSeq,
|
|
730
1283
|
sealUndoStep: (): void => {
|
|
731
|
-
|
|
1284
|
+
this._undoGroup.seal();
|
|
732
1285
|
},
|
|
1286
|
+
hasUnsavedOps: (): boolean => this.savingCount > 0,
|
|
733
1287
|
whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
|
|
1288
|
+
captureFrame: () => this._thumbnails.captureFrame(),
|
|
1289
|
+
uploadFrame: (frame, version): void => {
|
|
1290
|
+
// The debounced capture usually lands first, at the same version.
|
|
1291
|
+
if (this._thumbnailVersion === version) return;
|
|
1292
|
+
void this._thumbnails.uploadFrame(frame, version);
|
|
1293
|
+
},
|
|
734
1294
|
});
|
|
735
1295
|
// Drive push hook. `null` on the offline / test path; `pushToDrive`
|
|
736
1296
|
// then throws with a clear message rather than silently dropping.
|
|
@@ -751,6 +1311,9 @@ export class Document {
|
|
|
751
1311
|
private _sourceBlobFetcher: (() => Promise<DocumentSourceBlob>) | null = null;
|
|
752
1312
|
// Cached doc name. `null` on offline / test docs.
|
|
753
1313
|
private _name: string | null = null;
|
|
1314
|
+
// When the platform will remove this document, for one created without an
|
|
1315
|
+
// account; `null` for every other document.
|
|
1316
|
+
private _expiresAt: Date | null = null;
|
|
754
1317
|
// True once the reader has asked for the version-browsing surface without
|
|
755
1318
|
// checking anything out (the hosted history dialog's browse-in-the-app
|
|
756
1319
|
// outcome). The host's version bar latches on this as well as on a live
|
|
@@ -868,9 +1431,21 @@ export class Document {
|
|
|
868
1431
|
// below (or `documentFromEngine`, for in-repo tooling that brings its own).
|
|
869
1432
|
// Owning construction is what lets this class declare an engine-neutral
|
|
870
1433
|
// surface: app code holds a Document, never the doc underneath it.
|
|
871
|
-
private constructor(loroDoc: LoroDoc, options?: DocumentOptions) {
|
|
872
|
-
this.
|
|
1434
|
+
private constructor(loroDoc: LoroDoc | null, options?: DocumentOptions) {
|
|
1435
|
+
this._undoGroup.setOwner(this, {
|
|
1436
|
+
blocked: (): boolean => this._undoGroupBlocked(),
|
|
1437
|
+
notify: (): void => this._maybeFireUndoState(),
|
|
1438
|
+
// A write to another stream is an edit like one here, so it arms the
|
|
1439
|
+
// same idle checkpoint, which is what seals the step it opened.
|
|
1440
|
+
memberCommitted: (): void => this._checkpoints.scheduleAuto(),
|
|
1441
|
+
});
|
|
1442
|
+
this._engine = loroDoc;
|
|
873
1443
|
this._rootKey = options?.rootKey ?? 'entities';
|
|
1444
|
+
this._engineRootKey = this._rootKey;
|
|
1445
|
+
this._engineOptions = {
|
|
1446
|
+
...(options?.peerId !== undefined ? { peerId: options.peerId } : {}),
|
|
1447
|
+
...(options?.undo !== undefined ? { undo: options.undo } : {}),
|
|
1448
|
+
};
|
|
874
1449
|
this._docId = options?.docId ?? null;
|
|
875
1450
|
this._branchId = options?.branchId ?? null;
|
|
876
1451
|
// A Document constructed with a `version` is a "frozen" cold-link
|
|
@@ -878,14 +1453,20 @@ export class Document {
|
|
|
878
1453
|
// `loroDoc` and there is no live WS channel. The Document is
|
|
879
1454
|
// permanently in checkout against itself; `returnToHead` /
|
|
880
1455
|
// `_checkoutVersion` are not meaningful on this shape.
|
|
1456
|
+
const frozen = options?.frozenContent;
|
|
881
1457
|
const initialVersion = options?.version ?? null;
|
|
882
1458
|
if (initialVersion !== null) {
|
|
883
1459
|
this._isFrozen = true;
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1460
|
+
if (loroDoc !== null) {
|
|
1461
|
+
this._checkout = {
|
|
1462
|
+
previewDoc: loroDoc,
|
|
1463
|
+
previewRoot: loroDoc.getMap(this._rootKey),
|
|
1464
|
+
version: initialVersion,
|
|
1465
|
+
};
|
|
1466
|
+
} else {
|
|
1467
|
+
this._checkout = null;
|
|
1468
|
+
this._frozenVersion = initialVersion;
|
|
1469
|
+
}
|
|
889
1470
|
} else {
|
|
890
1471
|
this._isFrozen = false;
|
|
891
1472
|
this._checkout = null;
|
|
@@ -894,12 +1475,63 @@ export class Document {
|
|
|
894
1475
|
// reader the server will not accept writes from, whether because their
|
|
895
1476
|
// grant is view-only or because the link they came through is. A
|
|
896
1477
|
// version open is already frozen, so it does not also need the flag.
|
|
1478
|
+
// A stored record set answers every read directly, and takes no write: a
|
|
1479
|
+
// record set holds no op log for an edit to become, and making one is the
|
|
1480
|
+
// platform's (see `open-document.ts`, where the join serves a record set
|
|
1481
|
+
// only to a reader). So an app reads one document surface and never
|
|
1482
|
+
// learns which encoding it was handed, and the write it cannot make is
|
|
1483
|
+
// refused by the same read-only gate every other unwritable open uses.
|
|
1484
|
+
this._frozenHead = frozen ?? null;
|
|
1485
|
+
this._frozenShown = frozen ?? null;
|
|
1486
|
+
this._frozenSeal = options?.frozenSeal ?? null;
|
|
1487
|
+
this._sealedRoots =
|
|
1488
|
+
frozen === undefined
|
|
1489
|
+
? null
|
|
1490
|
+
: sealedRoots(
|
|
1491
|
+
() => this._frozenShown!,
|
|
1492
|
+
() => this._handleOwner.assertWritable(),
|
|
1493
|
+
);
|
|
897
1494
|
this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
|
|
898
1495
|
this._readOnlyReason = options?.readOnlyReason ?? 'link';
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1496
|
+
if (loroDoc !== null) {
|
|
1497
|
+
if (options?.peerId !== undefined) loroDoc.setPeerId(options.peerId);
|
|
1498
|
+
// Materialize the root container, so a document nothing has written to
|
|
1499
|
+
// still carries its root in the shape a reader opens it with.
|
|
1500
|
+
loroDoc.getMap(this._rootKey);
|
|
1501
|
+
this._undo = this._newUndoController(loroDoc, options?.undo);
|
|
1502
|
+
} else {
|
|
1503
|
+
this._undo = null;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// The engine, built on first use for a Document constructed without one.
|
|
1508
|
+
private get _doc(): LoroDoc {
|
|
1509
|
+
if (this._engine === null) {
|
|
1510
|
+
this._engine = new LoroDoc();
|
|
1511
|
+
this._engine.getMap(this._rootKey);
|
|
1512
|
+
}
|
|
1513
|
+
return this._engine;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Whether this Document has been closed. @internal */
|
|
1517
|
+
get _isClosed(): boolean {
|
|
1518
|
+
return this._closed;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/** Whether this Document has built a CRDT engine. @internal */
|
|
1522
|
+
_holdsEngine(): boolean {
|
|
1523
|
+
return this._engine !== null;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** The seq this Document's frozen content was sealed at, or `null` for a
|
|
1527
|
+
* CRDT document or a seal the join did not name. @internal */
|
|
1528
|
+
get _frozenSealSeq(): number | null {
|
|
1529
|
+
return this._frozenSeal;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// The version this Document is showing instead of its head, or `null`.
|
|
1533
|
+
private get _previewVersion(): number | null {
|
|
1534
|
+
return this._checkout?.version ?? this._frozenVersion;
|
|
903
1535
|
}
|
|
904
1536
|
|
|
905
1537
|
// ---- Typed container handles (the primary read/write/subscribe surface) --
|
|
@@ -933,15 +1565,31 @@ export class Document {
|
|
|
933
1565
|
return this._doc.peerIdStr;
|
|
934
1566
|
}
|
|
935
1567
|
|
|
1568
|
+
/** Whether this document's reads are answered by a stored record set.
|
|
1569
|
+
* @internal */
|
|
1570
|
+
_holdsRecordSet(): boolean {
|
|
1571
|
+
return this._sealedRoots !== null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
936
1574
|
// Get a handle for a top-level LoroMap at `key`. The container is
|
|
937
1575
|
// materialized in the underlying LoroDoc immediately (Loro's
|
|
938
1576
|
// `getMap` is "get or create"); mutations through the handle do
|
|
939
1577
|
// not need to create the container themselves.
|
|
940
1578
|
map<T = unknown>(key: string): MapHandle<T> {
|
|
941
1579
|
this._assertOpen();
|
|
1580
|
+
assertAppRootName(key);
|
|
1581
|
+
return this._handles.root(`map:${key}`, 'map', () => this._rootMap<T>(key));
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private _rootMap<T>(key: string): MapHandle<T> {
|
|
942
1585
|
const cacheKey = `map:${key}`;
|
|
943
1586
|
const existing = this._rootHandles.get(cacheKey);
|
|
944
1587
|
if (existing) return existing.handle as unknown as MapHandle<T>;
|
|
1588
|
+
if (this._sealedRoots !== null) {
|
|
1589
|
+
const handle = this._sealedRoots.map<unknown>(key);
|
|
1590
|
+
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
1591
|
+
return handle as unknown as MapHandle<T>;
|
|
1592
|
+
}
|
|
945
1593
|
// Source the LoroMap from the active source (live or checkout
|
|
946
1594
|
// preview), same as `toJSON` does. Handles bound to a Document
|
|
947
1595
|
// in checkout see the historical state via the preview doc.
|
|
@@ -972,7 +1620,7 @@ export class Document {
|
|
|
972
1620
|
},
|
|
973
1621
|
sessionToken: (): string => this._pagingSessionToken,
|
|
974
1622
|
stateVersion: (): StateVersion => activeSource().frontiers(),
|
|
975
|
-
});
|
|
1623
|
+
}, key);
|
|
976
1624
|
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
977
1625
|
return handle as unknown as MapHandle<T>;
|
|
978
1626
|
}
|
|
@@ -980,9 +1628,19 @@ export class Document {
|
|
|
980
1628
|
// Get a handle for a top-level LoroMovableList at `key`.
|
|
981
1629
|
list<T = unknown>(key: string): ListHandle<T> {
|
|
982
1630
|
this._assertOpen();
|
|
1631
|
+
assertAppRootName(key);
|
|
1632
|
+
return this._handles.root(`list:${key}`, 'list', () => this._rootList<T>(key));
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
private _rootList<T>(key: string): ListHandle<T> {
|
|
983
1636
|
const cacheKey = `list:${key}`;
|
|
984
1637
|
const existing = this._rootHandles.get(cacheKey);
|
|
985
1638
|
if (existing) return existing.handle as unknown as ListHandle<T>;
|
|
1639
|
+
if (this._sealedRoots !== null) {
|
|
1640
|
+
const handle = this._sealedRoots.list<unknown>(key);
|
|
1641
|
+
this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
|
|
1642
|
+
return handle as unknown as ListHandle<T>;
|
|
1643
|
+
}
|
|
986
1644
|
const source =
|
|
987
1645
|
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
988
1646
|
const loroList = source.getMovableList(key);
|
|
@@ -1013,9 +1671,15 @@ export class Document {
|
|
|
1013
1671
|
*/
|
|
1014
1672
|
text(key: string): TextHandle {
|
|
1015
1673
|
this._assertOpen();
|
|
1674
|
+
assertAppRootName(key);
|
|
1675
|
+
return this._handles.root(`text:${key}`, 'text', () => this._rootText(key));
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
private _rootText(key: string): TextHandle {
|
|
1016
1679
|
const cacheKey = `text:${key}`;
|
|
1017
1680
|
const existing = this._rootHandles.get(cacheKey);
|
|
1018
1681
|
if (existing) return existing.handle as TextHandle;
|
|
1682
|
+
if (this._sealedRoots !== null) return this._sealedRoots.text(key);
|
|
1019
1683
|
const owner = this._handleOwner;
|
|
1020
1684
|
const activeSource = (): LoroDoc =>
|
|
1021
1685
|
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
@@ -1127,12 +1791,25 @@ export class Document {
|
|
|
1127
1791
|
orderKey: string,
|
|
1128
1792
|
): OrderedMapHandle<T> {
|
|
1129
1793
|
this._assertOpen();
|
|
1794
|
+
return this._handles.root(`ordered:${mapKey}\u0000${orderKey}`, 'map', () =>
|
|
1795
|
+
this._rootOrderedMap<T>(mapKey, orderKey),
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
private _rootOrderedMap<T>(mapKey: string, orderKey: string): OrderedMapHandle<T> {
|
|
1130
1800
|
const cacheKey = `${mapKey}\u0000${orderKey}`;
|
|
1131
1801
|
let h = this._orderedMapHandles.get(cacheKey);
|
|
1802
|
+
if (!h && this._sealedRoots !== null) {
|
|
1803
|
+
// Taken from the store rather than composed from this document's own
|
|
1804
|
+
// map + list handles: the composition exists to keep a map and its
|
|
1805
|
+
// order list in step through writes, and a stored record set has none.
|
|
1806
|
+
h = this._sealedRoots.orderedMap<unknown>(mapKey, orderKey);
|
|
1807
|
+
this._orderedMapHandles.set(cacheKey, h);
|
|
1808
|
+
}
|
|
1132
1809
|
if (!h) {
|
|
1133
1810
|
h = createOrderedMapHandle<unknown>(
|
|
1134
|
-
this.
|
|
1135
|
-
this.
|
|
1811
|
+
this._rootMap(mapKey),
|
|
1812
|
+
this._rootList<string>(orderKey),
|
|
1136
1813
|
this._handleOwner,
|
|
1137
1814
|
);
|
|
1138
1815
|
this._orderedMapHandles.set(cacheKey, h);
|
|
@@ -1154,42 +1831,23 @@ export class Document {
|
|
|
1154
1831
|
},
|
|
1155
1832
|
assertWritable: (): void => {
|
|
1156
1833
|
this._assertOpen();
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
'Document is read-only: it is showing a historical version. ' +
|
|
1160
|
-
'Call `doc.returnToHead()` to edit.',
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
if (this._versionBrowsing) {
|
|
1164
|
-
throw new Error(
|
|
1165
|
-
'Document is read-only: the reader is browsing its versions. ' +
|
|
1166
|
-
'Close the version bar to edit.',
|
|
1167
|
-
);
|
|
1168
|
-
}
|
|
1169
|
-
if (this._forcedReadOnly) {
|
|
1170
|
-
throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
|
|
1171
|
-
}
|
|
1172
|
-
if (this._loadFailed) {
|
|
1173
|
-
throw new Error(
|
|
1174
|
-
'Document is read-only: its content could not be prepared ' +
|
|
1175
|
-
'(see `doc.status`). Reopen it to try again.',
|
|
1176
|
-
);
|
|
1177
|
-
}
|
|
1178
|
-
if (this._isLoadLocked()) {
|
|
1179
|
-
throw new Error(
|
|
1180
|
-
'Document is still loading its content; wait for the load to ' +
|
|
1181
|
-
'finish before editing (see `doc.onStatus`).',
|
|
1182
|
-
);
|
|
1183
|
-
}
|
|
1834
|
+
const reason = this._readOnlyBecause;
|
|
1835
|
+
if (reason !== null) throw new Error(READ_ONLY_MESSAGES[reason]);
|
|
1184
1836
|
},
|
|
1185
1837
|
ensureLocalUpdatesSubscribed: (): void => {
|
|
1186
1838
|
this._ensureLocalUpdatesSubscribed();
|
|
1187
1839
|
},
|
|
1188
1840
|
commitMutate: (): void => {
|
|
1189
|
-
this._doc.setNextCommitOrigin(
|
|
1841
|
+
this._doc.setNextCommitOrigin(this._mutateCommitOrigin);
|
|
1190
1842
|
this._doc.commit();
|
|
1191
1843
|
},
|
|
1844
|
+
runMutate: (options: MutateOptions | undefined, fn: () => void): void => {
|
|
1845
|
+
this._runInMutate(options?.undoable !== false, fn);
|
|
1846
|
+
},
|
|
1192
1847
|
originOf: (event: unknown): ChangeSource => changeSourceOf(event),
|
|
1848
|
+
reportSubscriberThrow: (err: unknown): void => {
|
|
1849
|
+
this._reportSubscriberThrow(err);
|
|
1850
|
+
},
|
|
1193
1851
|
};
|
|
1194
1852
|
return this._handleOwnerCache;
|
|
1195
1853
|
}
|
|
@@ -1221,28 +1879,45 @@ export class Document {
|
|
|
1221
1879
|
// and follows a grant the server withdraws mid-session as well as a
|
|
1222
1880
|
// checkout the reader entered.
|
|
1223
1881
|
get readOnly(): boolean {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1882
|
+
return this._readOnlyBecause !== null;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* The refusal message when this reader's access, a view-only link, or this
|
|
1887
|
+
* app's standing keeps them from writing however the document is opened,
|
|
1888
|
+
* else null. `session.edit()` rejects with it rather than reopening.
|
|
1889
|
+
* @internal
|
|
1890
|
+
*/
|
|
1891
|
+
get _editRefusal(): string | null {
|
|
1892
|
+
const reason = this._forcedReadOnly ? this._readOnlyReason : null;
|
|
1893
|
+
if (reason === 'grant' || reason === 'link' || reason === 'app') {
|
|
1894
|
+
return READ_ONLY_MESSAGES[reason];
|
|
1895
|
+
}
|
|
1896
|
+
return null;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* Why this reader cannot write, or null when they can.
|
|
1901
|
+
*
|
|
1902
|
+
* The one place the conditions are listed. `readOnly` asks whether there is
|
|
1903
|
+
* a reason and `mutate` refuses with the reason's own message, so the two
|
|
1904
|
+
* cannot answer differently about the same document, and a new reason
|
|
1905
|
+
* reaches both by being added here.
|
|
1906
|
+
*
|
|
1907
|
+
* Order decides which message a reader sees when several hold at once, and
|
|
1908
|
+
* it runs from the state they are in to the state they were put in: a
|
|
1909
|
+
* checkout and an open version bar are things this reader did and can
|
|
1910
|
+
* undo, so naming those first tells them what to do. A load that has not
|
|
1911
|
+
* finished comes last because it clears on its own.
|
|
1912
|
+
*/
|
|
1913
|
+
private get _readOnlyBecause(): ReadOnlyReason | null {
|
|
1914
|
+
if (this._previewVersion !== null) return 'version';
|
|
1915
|
+
if (this._versionBrowsing) return 'browsing';
|
|
1916
|
+
if (this._forcedReadOnly) return this._readOnlyReason;
|
|
1917
|
+
if (this._reopening) return 'reopening';
|
|
1918
|
+
if (this._loadFailed) return 'load_failed';
|
|
1919
|
+
if (this._isLoadLocked()) return 'loading';
|
|
1920
|
+
return null;
|
|
1246
1921
|
}
|
|
1247
1922
|
|
|
1248
1923
|
// True when content-replacing work (hydration / extraction) is pending
|
|
@@ -1257,7 +1932,7 @@ export class Document {
|
|
|
1257
1932
|
// blocking so hydration stays locked.
|
|
1258
1933
|
private _isLoadLocked(): boolean {
|
|
1259
1934
|
if (this._loadFailed) return true;
|
|
1260
|
-
const s = this._workStatus.
|
|
1935
|
+
const s = this._workStatus.work;
|
|
1261
1936
|
return s?.pending === true && s.blocking !== false;
|
|
1262
1937
|
}
|
|
1263
1938
|
|
|
@@ -1286,8 +1961,8 @@ export class Document {
|
|
|
1286
1961
|
* `doc.readOnly` (true for a preview, a view-only link, or a load lock).
|
|
1287
1962
|
* @internal
|
|
1288
1963
|
*/
|
|
1289
|
-
get
|
|
1290
|
-
return this.
|
|
1964
|
+
get previewVersion(): number | null {
|
|
1965
|
+
return this._previewVersion;
|
|
1291
1966
|
}
|
|
1292
1967
|
|
|
1293
1968
|
/** @internal Whether the host should be showing its version-browsing
|
|
@@ -1314,7 +1989,8 @@ export class Document {
|
|
|
1314
1989
|
* upload pins to it); no app-facing operation takes a seq, since version
|
|
1315
1990
|
* history is hosted chrome the app does not drive with numbers. */
|
|
1316
1991
|
get currentSeq(): number | null {
|
|
1317
|
-
|
|
1992
|
+
const previewing = this._previewVersion;
|
|
1993
|
+
if (previewing !== null) return previewing;
|
|
1318
1994
|
if (this._currentSeqProvider) return this._currentSeqProvider();
|
|
1319
1995
|
return null;
|
|
1320
1996
|
}
|
|
@@ -1334,6 +2010,38 @@ export class Document {
|
|
|
1334
2010
|
return new Document(loroDoc, options);
|
|
1335
2011
|
}
|
|
1336
2012
|
|
|
2013
|
+
/**
|
|
2014
|
+
* Open a Document over frozen content: the document's content as it was
|
|
2015
|
+
* sealed, read with no CRDT engine.
|
|
2016
|
+
*
|
|
2017
|
+
* No CRDT engine is built: every root answers from the content, and the
|
|
2018
|
+
* machinery a Document carries regardless (its id, its subscriber channel,
|
|
2019
|
+
* its close path) runs without one.
|
|
2020
|
+
* @internal
|
|
2021
|
+
*/
|
|
2022
|
+
static openFromSealedContent(content: SealedContent, options?: DocumentOptions): Document {
|
|
2023
|
+
// Rooted at a map the content holds, so a document reading frozen content
|
|
2024
|
+
// registers no empty root of its own.
|
|
2025
|
+
//
|
|
2026
|
+
// Read-only is stated here rather than left to the caller, because it is
|
|
2027
|
+
// a property of the bytes: frozen content holds no op log, so there is
|
|
2028
|
+
// nothing an edit could be written into, and making one is the platform's
|
|
2029
|
+
// (`runRecordSetConversion`). Stating it at the factory is what lets the
|
|
2030
|
+
// refusal come from the document's own gate, which names the reader's
|
|
2031
|
+
// actual limit, instead of from each handle describing the encoding.
|
|
2032
|
+
const rootKey =
|
|
2033
|
+
content.rootNames().find((n) => content.kindOf(n) === 'map') ?? RECORDS_KEY;
|
|
2034
|
+
const doc = new Document(null, {
|
|
2035
|
+
...options,
|
|
2036
|
+
readOnly: true,
|
|
2037
|
+
readOnlyReason: options?.readOnlyReason ?? 'snapshot',
|
|
2038
|
+
rootKey,
|
|
2039
|
+
frozenContent: content,
|
|
2040
|
+
});
|
|
2041
|
+
doc._engineRootKey = options?.rootKey ?? 'entities';
|
|
2042
|
+
return doc;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
1337
2045
|
/**
|
|
1338
2046
|
* Construct an empty Document (empty-snapshot join case, and tests).
|
|
1339
2047
|
* @internal
|
|
@@ -1373,7 +2081,17 @@ export class Document {
|
|
|
1373
2081
|
// subscribers.
|
|
1374
2082
|
return;
|
|
1375
2083
|
}
|
|
1376
|
-
|
|
2084
|
+
// An op exists, so the document is now a CRDT and somebody has edited it.
|
|
2085
|
+
// The op is authored against that CRDT, so imported over frozen content it
|
|
2086
|
+
// would land on an empty LoroDoc and sit causally incomplete. The storage
|
|
2087
|
+
// is upgraded instead: the transport re-joins, and the join's CRDT
|
|
2088
|
+
// replaces the frozen content under the same handles (`_upgradeStorage`).
|
|
2089
|
+
// The op is in what that join serves.
|
|
2090
|
+
if (this._sealedRoots !== null) {
|
|
2091
|
+
this._requestStorageUpgrade();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
importTailOp(this._doc, opBlob);
|
|
1377
2095
|
}
|
|
1378
2096
|
|
|
1379
2097
|
// Apply a snapshot blob over the top of an existing Document.
|
|
@@ -1390,6 +2108,12 @@ export class Document {
|
|
|
1390
2108
|
// arrives and the Loro subscriber cannot be relied on to report it: it is
|
|
1391
2109
|
// attached lazily, so a document whose content lands before the session has
|
|
1392
2110
|
// bound its canvas has nobody subscribed and the import fires nothing.
|
|
2111
|
+
// CRDT bytes over frozen content are a storage upgrade, which keeps the
|
|
2112
|
+
// app's handles (see `_upgradeStorage`).
|
|
2113
|
+
if (this._sealedRoots !== null) {
|
|
2114
|
+
if (!this._upgradeStorage(snapshot, [])) this._reopen();
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
1393
2117
|
this._armSeedIfPictureIsStale();
|
|
1394
2118
|
this._doc.import(snapshot);
|
|
1395
2119
|
// The undo stack pre-snapshot referenced ops the new snapshot
|
|
@@ -1400,7 +2124,112 @@ export class Document {
|
|
|
1400
2124
|
if (this._undo) this._undo.clear();
|
|
1401
2125
|
}
|
|
1402
2126
|
|
|
1403
|
-
|
|
2127
|
+
/** @internal Wire the re-join that upgrades this Document's frozen content
|
|
2128
|
+
* to the CRDT the server now holds. */
|
|
2129
|
+
_setStorageUpgrader(upgrade: () => void): void {
|
|
2130
|
+
this._storageUpgrader = upgrade;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
private _requestStorageUpgrade(): void {
|
|
2134
|
+
const upgrade = this._storageUpgrader;
|
|
2135
|
+
if (upgrade === null) {
|
|
2136
|
+
this._reopen();
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
upgrade();
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* Swap this Document's storage from frozen content to a CRDT, in place.
|
|
2144
|
+
* @internal
|
|
2145
|
+
*
|
|
2146
|
+
* Every handle an app took over the frozen content keeps working, now over
|
|
2147
|
+
* the CRDT, and its subscribers move there as a whole-container resync (keys
|
|
2148
|
+
* the CRDT does not hold arrive as `deleted`, and `onSettled` fires again).
|
|
2149
|
+
* Read-only state is unchanged, because the reader's intent is.
|
|
2150
|
+
*
|
|
2151
|
+
* Returns false, leaving the frozen content in place, when the Document is
|
|
2152
|
+
* showing a version of the frozen content: the preview has no CRDT
|
|
2153
|
+
* counterpart to rebind to, so the caller reloads instead.
|
|
2154
|
+
*/
|
|
2155
|
+
_upgradeStorage(snapshot: Uint8Array | null, tailOps: readonly Uint8Array[]): boolean {
|
|
2156
|
+
if (this._closed) return false;
|
|
2157
|
+
if (this._sealedRoots === null) return true;
|
|
2158
|
+
if (this._frozenVersion !== null) return false;
|
|
2159
|
+
const engine = new LoroDoc();
|
|
2160
|
+
if (snapshot !== null) engine.import(snapshot);
|
|
2161
|
+
for (const op of tailOps) importTailOp(engine, op);
|
|
2162
|
+
if (this._engineOptions.peerId !== undefined) engine.setPeerId(this._engineOptions.peerId);
|
|
2163
|
+
this._handles.swap(() => {
|
|
2164
|
+
this._rootKey = this._engineRootKey;
|
|
2165
|
+
engine.getMap(this._rootKey);
|
|
2166
|
+
this._engine = engine;
|
|
2167
|
+
this._undo = this._newUndoController(engine, this._engineOptions.undo);
|
|
2168
|
+
this._sealedRoots = null;
|
|
2169
|
+
this._frozenHead = null;
|
|
2170
|
+
this._frozenShown = null;
|
|
2171
|
+
this._frozenSeal = null;
|
|
2172
|
+
this._rootHandles.clear();
|
|
2173
|
+
this._orderedMapHandles.clear();
|
|
2174
|
+
});
|
|
2175
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
2176
|
+
return true;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/**
|
|
2180
|
+
* Swap this Document's storage from a CRDT to frozen content, in place: the
|
|
2181
|
+
* counterpart of `_upgradeStorage`, for a frozen copy whose reload is served
|
|
2182
|
+
* the line as stored after the copy was opened on a CRDT.
|
|
2183
|
+
* @internal
|
|
2184
|
+
*
|
|
2185
|
+
* Returns false, leaving the CRDT in place, when the Document is showing a
|
|
2186
|
+
* version: the preview has no frozen counterpart, so the caller reloads.
|
|
2187
|
+
*/
|
|
2188
|
+
_downgradeStorage(content: SealedContent, seal: number | null): boolean {
|
|
2189
|
+
if (this._closed || this._sealedRoots !== null || this._checkout !== null) return false;
|
|
2190
|
+
this._handles.swap(() => {
|
|
2191
|
+
this._frozenHead = content;
|
|
2192
|
+
this._frozenShown = content;
|
|
2193
|
+
this._frozenSeal = seal;
|
|
2194
|
+
this._sealedRoots = sealedRoots(
|
|
2195
|
+
() => this._frozenShown!,
|
|
2196
|
+
() => this._handleOwner.assertWritable(),
|
|
2197
|
+
);
|
|
2198
|
+
this._rootHandles.clear();
|
|
2199
|
+
this._orderedMapHandles.clear();
|
|
2200
|
+
// Nothing reads the engine from here on. It is dropped rather than
|
|
2201
|
+
// freed, because a watcher an input follower attached to it may still
|
|
2202
|
+
// detach.
|
|
2203
|
+
this._unsubLoro?.();
|
|
2204
|
+
this._unsubLoro = null;
|
|
2205
|
+
this._unsubLocal?.();
|
|
2206
|
+
this._unsubLocal = null;
|
|
2207
|
+
this._undo?.free();
|
|
2208
|
+
this._undo = null;
|
|
2209
|
+
this._engine = null;
|
|
2210
|
+
});
|
|
2211
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
2212
|
+
return true;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/**
|
|
2216
|
+
* Replace this Document's frozen content with other frozen content, in
|
|
2217
|
+
* place, re-delivering every handle's subscribers. A join serves a newer
|
|
2218
|
+
* seal when the document was edited and frozen again in between.
|
|
2219
|
+
* @internal
|
|
2220
|
+
*/
|
|
2221
|
+
_replaceSealedContent(content: SealedContent, seal: number | null): boolean {
|
|
2222
|
+
if (this._closed || this._sealedRoots === null) return false;
|
|
2223
|
+
if (this._frozenVersion !== null) return false;
|
|
2224
|
+
this._frozenHead = content;
|
|
2225
|
+
this._frozenShown = content;
|
|
2226
|
+
this._frozenSeal = seal;
|
|
2227
|
+
this._sealedRoots.resync();
|
|
2228
|
+
this._fireSubscribers({ kind: 'change', source: 'remote' });
|
|
2229
|
+
return true;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Revert the most recent local step. Returns `true` if an undo
|
|
1404
2233
|
// step was applied, `false` if the stack is empty. Remote peers'
|
|
1405
2234
|
// edits are not in this stack: Loro's UndoManager is local-peer-
|
|
1406
2235
|
// scoped, which is the user-visible behaviour every collaborative
|
|
@@ -1409,23 +2238,17 @@ export class Document {
|
|
|
1409
2238
|
// The reverted change is itself a local commit, so it ships over
|
|
1410
2239
|
// the WS transport like any other mutation and other peers see the
|
|
1411
2240
|
// state revert.
|
|
2241
|
+
//
|
|
2242
|
+
// The stack is the document's, across its streams: a step is every local
|
|
2243
|
+
// commit between two seals in whichever streams took one, and one `undo()`
|
|
2244
|
+
// reverts it in each of them. A stream that refuses writes takes no part.
|
|
1412
2245
|
undo(): boolean {
|
|
1413
2246
|
this._assertOpen();
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
// An undo produces a local commit exactly as `mutate` does, so it obeys
|
|
1420
|
-
// the same read-only gates: a view-only guest link (`_forcedReadOnly`)
|
|
1421
|
-
// and a pending content-replacing work-status (`_isLoadLocked()`), whose
|
|
1422
|
-
// whole purpose is to keep local ops from racing the incoming bytes.
|
|
1423
|
-
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
1424
|
-
this._ensureLocalUpdatesSubscribed();
|
|
1425
|
-
// Tag the commit the UndoManager is about to make so the resulting
|
|
1426
|
-
// change reads back as `origin.source === 'undo'`, not `'local'`.
|
|
1427
|
-
this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
|
|
1428
|
-
return this._undo!.undo();
|
|
2247
|
+
try {
|
|
2248
|
+
return this._undoGroup.replay('undo');
|
|
2249
|
+
} finally {
|
|
2250
|
+
this._maybeFireUndoState();
|
|
2251
|
+
}
|
|
1429
2252
|
}
|
|
1430
2253
|
|
|
1431
2254
|
// Re-apply the most recently undone local commit. Returns `true`
|
|
@@ -1437,13 +2260,69 @@ export class Document {
|
|
|
1437
2260
|
// composes correctly.
|
|
1438
2261
|
redo(): boolean {
|
|
1439
2262
|
this._assertOpen();
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
2263
|
+
try {
|
|
2264
|
+
return this._undoGroup.replay('redo');
|
|
2265
|
+
} finally {
|
|
2266
|
+
this._maybeFireUndoState();
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// Whether `undo()` / `redo()` must no-op. A replay produces local commits
|
|
2271
|
+
// exactly as `mutate` does, so it obeys every gate `mutate` does. During a
|
|
2272
|
+
// checkout the UndoManager still tracks the live head while reads go
|
|
2273
|
+
// through the preview, so a replay would be a silent live edit. A copy with
|
|
2274
|
+
// no CRDT (frozen content) has no undo stack at all. `undo()` / `redo()`
|
|
2275
|
+
// also no-op inside a `mutate` callback, whose open transaction holds
|
|
2276
|
+
// writes the replay would commit outside the op it ships.
|
|
2277
|
+
private _undoBlocked(): boolean {
|
|
2278
|
+
return this._checkout !== null || this._undo === null || this._readOnlyBecause !== null;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// Tags each commit the step's replay makes, so the resulting change reads
|
|
2282
|
+
// back as `origin.source === 'undo'` / `'redo'`, not `'local'`.
|
|
2283
|
+
//
|
|
2284
|
+
// A step replays as one commit per commit it took, and those ship as ONE op
|
|
2285
|
+
// exported from the version before the replay, so an Undo of a long step is
|
|
2286
|
+
// one write for the server and one import for each peer.
|
|
2287
|
+
/** @internal Replay this stream's own part of the newest step that touched
|
|
2288
|
+
* it (undo) or of the step it most recently undid (redo), leaving the
|
|
2289
|
+
* document's other streams alone. `DocumentStream.undo` / `redo`. */
|
|
2290
|
+
_replayOwnUndoStep(dir: 'undo' | 'redo'): boolean {
|
|
2291
|
+
this._assertOpen();
|
|
2292
|
+
try {
|
|
2293
|
+
if (this._mutateDepth > 0 || this._undoBlocked()) return false;
|
|
2294
|
+
const tag = this._undo!.peek(dir);
|
|
2295
|
+
return tag !== undefined && this._replayUndoStep(dir, tag);
|
|
2296
|
+
} finally {
|
|
2297
|
+
this._maybeFireUndoState();
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/** @internal */
|
|
2302
|
+
_replayUndoStep(dir: 'undo' | 'redo', tag: number): boolean {
|
|
1444
2303
|
this._ensureLocalUpdatesSubscribed();
|
|
1445
|
-
|
|
1446
|
-
|
|
2304
|
+
const origin = dir === 'undo' ? UNDO_COMMIT_ORIGIN : REDO_COMMIT_ORIGIN;
|
|
2305
|
+
const tagCommit = (): void => this._doc.setNextCommitOrigin(origin);
|
|
2306
|
+
const before = this._doc.oplogVersion();
|
|
2307
|
+
const beforeHead = JSON.stringify(this._doc.oplogFrontiers());
|
|
2308
|
+
this._replayingUndo = true;
|
|
2309
|
+
this._replayChange = null;
|
|
2310
|
+
try {
|
|
2311
|
+
return this._undo!.replayStep(dir, tag, tagCommit);
|
|
2312
|
+
} finally {
|
|
2313
|
+
this._replayingUndo = false;
|
|
2314
|
+
// Whatever the replay committed ships, a replay that threw part-way included.
|
|
2315
|
+
if (this._unsubLocal && JSON.stringify(this._doc.oplogFrontiers()) !== beforeHead) {
|
|
2316
|
+
this._shipLocalUpdate(this._doc.export({ mode: 'update', from: before }));
|
|
2317
|
+
}
|
|
2318
|
+
before.free();
|
|
2319
|
+
const source = this._replayChange;
|
|
2320
|
+
this._replayChange = null;
|
|
2321
|
+
if (source !== null) {
|
|
2322
|
+
this._subs.emit({ kind: 'change', source } satisfies SyntheticSubscribeEvent);
|
|
2323
|
+
}
|
|
2324
|
+
this._maybeFireUndoState();
|
|
2325
|
+
}
|
|
1447
2326
|
}
|
|
1448
2327
|
|
|
1449
2328
|
/**
|
|
@@ -1455,33 +2334,74 @@ export class Document {
|
|
|
1455
2334
|
* @internal
|
|
1456
2335
|
*/
|
|
1457
2336
|
_sealUndoStep(): void {
|
|
1458
|
-
|
|
2337
|
+
this._undoGroup.seal();
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/** @internal */
|
|
2341
|
+
_undoController(): UndoController | null {
|
|
2342
|
+
return this._undo;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/** @internal */
|
|
2346
|
+
_undoEligible(): boolean {
|
|
2347
|
+
return !this._closed && !this._undoBlocked();
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/** @internal */
|
|
2351
|
+
_undoMutating(): boolean {
|
|
2352
|
+
return this._mutateDepth > 0;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// A version on screen holds every stream of the document at that moment,
|
|
2356
|
+
// so no stream replays a step while the document shows one, even a stream
|
|
2357
|
+
// whose own preview has not landed yet.
|
|
2358
|
+
private _undoGroupBlocked(): boolean {
|
|
2359
|
+
return (
|
|
2360
|
+
this._closed ||
|
|
2361
|
+
this._checkout !== null ||
|
|
2362
|
+
this._previewVersion !== null ||
|
|
2363
|
+
this._versionBrowsing
|
|
2364
|
+
);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
private _newUndoController(
|
|
2368
|
+
loroDoc: LoroDoc,
|
|
2369
|
+
options: UndoControllerOptions | undefined,
|
|
2370
|
+
): UndoController {
|
|
2371
|
+
return new UndoController(loroDoc, options, this._undoGroup, {
|
|
2372
|
+
onFreshCommit: (): void => this._undoGroup.freshCommit(this),
|
|
2373
|
+
onStackChange: (): void => {
|
|
2374
|
+
if (!this._undoGroup.isOwner(this)) this._undoGroup.changed();
|
|
2375
|
+
},
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
/** @internal Make this stream's undo part of its document's stack. Called
|
|
2380
|
+
* when the stream opens, before it has taken a local commit. */
|
|
2381
|
+
_joinUndoGroup(group: UndoGroup): void {
|
|
2382
|
+
this._undoGroup.delete(this);
|
|
2383
|
+
this._undoGroup = group;
|
|
2384
|
+
group.add(this);
|
|
2385
|
+
this._undo?.useClock(group);
|
|
1459
2386
|
}
|
|
1460
2387
|
|
|
1461
2388
|
// Whether `undo()` would have an effect right now. Feeds the
|
|
1462
2389
|
// commands machinery and the undo-state notifier; apps consume it
|
|
1463
2390
|
// through `subscribeUndoState` snapshots.
|
|
2391
|
+
//
|
|
2392
|
+
// The gates `undo()` enforces, so a subscribeUndoState-bound button
|
|
2393
|
+
// disables rather than inviting an undo the call would refuse: during a
|
|
2394
|
+
// checkout the stacks still track the live head while the UI shows the
|
|
2395
|
+
// preview, and a stream that refuses writes has nothing it may replay.
|
|
1464
2396
|
private canUndo(): boolean {
|
|
1465
2397
|
if (this._closed) return false;
|
|
1466
|
-
|
|
1467
|
-
// history, but the UI is showing the historical preview and
|
|
1468
|
-
// mutate is rejected. Surface that as "can't undo right now" so
|
|
1469
|
-
// subscribeUndoState-bound buttons disable correctly.
|
|
1470
|
-
if (this._checkout !== null) return false;
|
|
1471
|
-
// Fold the same read-only gates `undo()` enforces so a
|
|
1472
|
-
// subscribeUndoState-bound button disables while the doc is a view-only
|
|
1473
|
-
// guest link or load-locked, rather than inviting an undo the call
|
|
1474
|
-
// would refuse.
|
|
1475
|
-
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
1476
|
-
return this._undo!.canUndo();
|
|
2398
|
+
return this._undoGroup.canUndo();
|
|
1477
2399
|
}
|
|
1478
2400
|
|
|
1479
2401
|
// Whether `redo()` would have an effect right now. As above.
|
|
1480
2402
|
private canRedo(): boolean {
|
|
1481
2403
|
if (this._closed) return false;
|
|
1482
|
-
|
|
1483
|
-
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
1484
|
-
return this._undo!.canRedo();
|
|
2404
|
+
return this._undoGroup.canRedo();
|
|
1485
2405
|
}
|
|
1486
2406
|
|
|
1487
2407
|
// The plain-object proxy callers mutate. The mutation runs against
|
|
@@ -1514,10 +2434,11 @@ export class Document {
|
|
|
1514
2434
|
// either.
|
|
1515
2435
|
mutate<E extends object = EntityProxy>(
|
|
1516
2436
|
fn: (d: DocumentMutator<E>) => void,
|
|
2437
|
+
options?: MutateOptions,
|
|
1517
2438
|
): void {
|
|
1518
2439
|
this.map<E>(this._rootKey).mutate((entities) => {
|
|
1519
2440
|
fn({ entities });
|
|
1520
|
-
});
|
|
2441
|
+
}, options);
|
|
1521
2442
|
}
|
|
1522
2443
|
|
|
1523
2444
|
// Install / replace the local-op send hook; `null` detaches it.
|
|
@@ -1551,20 +2472,7 @@ export class Document {
|
|
|
1551
2472
|
// `_checkoutVersion`. A Document without one rejects the checkout
|
|
1552
2473
|
// with a clear error.
|
|
1553
2474
|
/** @internal */
|
|
1554
|
-
setCheckoutFetcher(
|
|
1555
|
-
fetcher:
|
|
1556
|
-
| ((
|
|
1557
|
-
docId: string,
|
|
1558
|
-
version: number,
|
|
1559
|
-
branchId: string,
|
|
1560
|
-
) => Promise<{
|
|
1561
|
-
snapshotBytes: Uint8Array | null;
|
|
1562
|
-
tailOpBlobs: Uint8Array[];
|
|
1563
|
-
effectiveVersion: number;
|
|
1564
|
-
streamSeqs?: Record<string, number>;
|
|
1565
|
-
}>)
|
|
1566
|
-
| null,
|
|
1567
|
-
): void {
|
|
2475
|
+
setCheckoutFetcher(fetcher: CheckoutFetcher | null): void {
|
|
1568
2476
|
this._checkoutFetcher = fetcher;
|
|
1569
2477
|
}
|
|
1570
2478
|
|
|
@@ -1577,7 +2485,7 @@ export class Document {
|
|
|
1577
2485
|
/** @internal Whether this Document is currently showing a historical
|
|
1578
2486
|
* preview rather than its live head. */
|
|
1579
2487
|
_isCheckedOut(): boolean {
|
|
1580
|
-
return this.
|
|
2488
|
+
return this._previewVersion !== null;
|
|
1581
2489
|
}
|
|
1582
2490
|
|
|
1583
2491
|
/** @internal Whether this Document is pinned to the version it was opened
|
|
@@ -1590,7 +2498,7 @@ export class Document {
|
|
|
1590
2498
|
// Switch the Document to a historical version, in place. @internal
|
|
1591
2499
|
// (the hosted `history` command drives this). After the promise
|
|
1592
2500
|
// resolves, `doc.readOnly` is `true`,
|
|
1593
|
-
// `doc.
|
|
2501
|
+
// `doc.previewVersion` is the server's effective version (which may round
|
|
1594
2502
|
// down from the requested one when compaction has TTL'd the exact
|
|
1595
2503
|
// op), and `mutate` throws. `subscribe` fires once after the swap so
|
|
1596
2504
|
// the app's render loop redraws, and the app returns to live through
|
|
@@ -1622,7 +2530,7 @@ export class Document {
|
|
|
1622
2530
|
);
|
|
1623
2531
|
}
|
|
1624
2532
|
const generation = ++this._checkoutGeneration;
|
|
1625
|
-
const {
|
|
2533
|
+
const { content, tailOpBlobs, effectiveVersion, streamSeqs } =
|
|
1626
2534
|
await this._checkoutFetcher(this._docId, version, this._branchId);
|
|
1627
2535
|
if (this._closed || this._suspended) return;
|
|
1628
2536
|
// A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
|
|
@@ -1630,11 +2538,20 @@ export class Document {
|
|
|
1630
2538
|
// than clobbering the live head (or a newer checkout) the user asked for.
|
|
1631
2539
|
if (generation !== this._checkoutGeneration) return;
|
|
1632
2540
|
this._checkoutStreamSeqs = streamSeqs ?? {};
|
|
2541
|
+
// Handles over frozen content read frozen content, so the version is
|
|
2542
|
+
// swapped in as frozen content whatever encoding the server stores it in.
|
|
2543
|
+
if (this._sealedRoots !== null) {
|
|
2544
|
+
this._frozenShown = frozenPreview(content, tailOpBlobs);
|
|
2545
|
+
this._frozenVersion = effectiveVersion;
|
|
2546
|
+
this._sealedRoots.resync();
|
|
2547
|
+
this._fireSubscribers({ kind: 'history', version: effectiveVersion });
|
|
2548
|
+
await this._checkoutOpenStreams(generation);
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
1633
2551
|
// Materialise the preview into a fresh LoroDoc. The previous
|
|
1634
2552
|
// checkout's preview LoroDoc, if any, is freed.
|
|
1635
|
-
const previewDoc =
|
|
1636
|
-
|
|
1637
|
-
for (const blob of tailOpBlobs) previewDoc.import(blob);
|
|
2553
|
+
const previewDoc = engineFrom(content);
|
|
2554
|
+
for (const blob of tailOpBlobs) importTailOp(previewDoc, blob);
|
|
1638
2555
|
const previewRoot = previewDoc.getMap(this._rootKey);
|
|
1639
2556
|
const old = this._checkout;
|
|
1640
2557
|
// Snapshot the handles' keys before the active-source pointer moves (see
|
|
@@ -1660,7 +2577,7 @@ export class Document {
|
|
|
1660
2577
|
// container captured at attach time (the OLD preview's); freeing first
|
|
1661
2578
|
// would run that detach against a freed LoroDoc. This is the same
|
|
1662
2579
|
// teardown-before-free order `close()` documents.
|
|
1663
|
-
if (old && old.previewDoc !== this.
|
|
2580
|
+
if (old && old.previewDoc !== this._engine) {
|
|
1664
2581
|
try {
|
|
1665
2582
|
old.previewDoc.free();
|
|
1666
2583
|
} catch {
|
|
@@ -1729,6 +2646,14 @@ export class Document {
|
|
|
1729
2646
|
// `_checkout` still null, and this is the signal that supersedes it when
|
|
1730
2647
|
// it resolves.
|
|
1731
2648
|
const generation = (this._checkoutGeneration += 1);
|
|
2649
|
+
if (this._sealedRoots !== null && this._frozenVersion !== null) {
|
|
2650
|
+
this._frozenShown = this._frozenHead;
|
|
2651
|
+
this._frozenVersion = null;
|
|
2652
|
+
this._sealedRoots.resync();
|
|
2653
|
+
this._fireSubscribers({ kind: 'return-to-head' });
|
|
2654
|
+
this._returnOpenStreams(generation);
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
1732
2657
|
if (this._checkout === null) return;
|
|
1733
2658
|
const old = this._checkout;
|
|
1734
2659
|
// Symmetric with `_checkoutVersion`: snapshot before the active-source
|
|
@@ -1742,7 +2667,7 @@ export class Document {
|
|
|
1742
2667
|
// Free the preview only after the rebind: `_rebindRootHandles` detaches
|
|
1743
2668
|
// each subscriber against the preview container it was attached to, so
|
|
1744
2669
|
// freeing first would be a use-after-free (see `_checkoutVersion`).
|
|
1745
|
-
if (old.previewDoc !== this.
|
|
2670
|
+
if (old.previewDoc !== this._engine) {
|
|
1746
2671
|
try {
|
|
1747
2672
|
old.previewDoc.free();
|
|
1748
2673
|
} catch {
|
|
@@ -1751,9 +2676,13 @@ export class Document {
|
|
|
1751
2676
|
}
|
|
1752
2677
|
this._fireSubscribers({ kind: 'return-to-head' });
|
|
1753
2678
|
this._maybeFireUndoState();
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
2679
|
+
this._returnOpenStreams(generation);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// Return every opened stream to its live head too, symmetric with the
|
|
2683
|
+
// checkout path. Fire-and-forget (returnToHead is synchronous and has no
|
|
2684
|
+
// fetch): a stream not previewing is a no-op.
|
|
2685
|
+
private _returnOpenStreams(generation: number): void {
|
|
1757
2686
|
this._checkoutStreamSeqs = null;
|
|
1758
2687
|
for (const openPromise of this._streamOpens.values()) {
|
|
1759
2688
|
void openPromise
|
|
@@ -1778,7 +2707,20 @@ export class Document {
|
|
|
1778
2707
|
// and the caller moves the active-source pointer between the two calls.
|
|
1779
2708
|
// Callers must not return early in that window; a snapshot left unconsumed
|
|
1780
2709
|
// would be diffed against a later, unrelated swap.
|
|
2710
|
+
// Detach every cached root handle's live subscriptions. Reached only on a
|
|
2711
|
+
// CRDT document: the teardown, the key snapshot and the rebind below are
|
|
2712
|
+
// `handles.ts`'s own internal protocol, spoken by the handles it produces,
|
|
2713
|
+
// and a stored record set's roots hold no container subscription to detach,
|
|
2714
|
+
// no anchor to re-point and no prior keys to diff against a swap.
|
|
2715
|
+
private _teardownRootHandles(): void {
|
|
2716
|
+
for (const entry of this._rootHandles.values()) {
|
|
2717
|
+
if (entry.kind === 'text') entry.handle._teardown();
|
|
2718
|
+
else teardownRootHandle(entry.handle);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
1781
2722
|
private _snapshotRootHandleKeys(): void {
|
|
2723
|
+
if (this._sealedRoots !== null) return;
|
|
1782
2724
|
for (const entry of this._rootHandles.values()) {
|
|
1783
2725
|
if (entry.kind === 'map') snapshotRootMapKeys(entry.handle);
|
|
1784
2726
|
}
|
|
@@ -1791,6 +2733,7 @@ export class Document {
|
|
|
1791
2733
|
// `getMap` / `getList` are get-or-create, so a container absent in the
|
|
1792
2734
|
// preview resolves to an empty one and its subscribers redraw to empty.
|
|
1793
2735
|
private _rebindRootHandles(source: LoroDoc): void {
|
|
2736
|
+
if (this._sealedRoots !== null) return;
|
|
1794
2737
|
// Two passes, not one per handle: rebind every anchor to the new source
|
|
1795
2738
|
// first, then resync. A composed handle can read a sibling root while
|
|
1796
2739
|
// firing (the ordered map reads its order list during its map redraw), so
|
|
@@ -1980,13 +2923,11 @@ export class Document {
|
|
|
1980
2923
|
// both alongside the publish-over that `pushToDrive` does. Driven by the
|
|
1981
2924
|
// hosted toolbar.
|
|
1982
2925
|
//
|
|
1983
|
-
//
|
|
1984
|
-
//
|
|
1985
|
-
//
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
// returned `newDocId` lets a multi-doc app do its own switch. Resolves
|
|
1989
|
-
// with the outcome, or `null` when the user dismissed the dialog.
|
|
2926
|
+
// Taking the drive's version keeps this document's id: the open copy moves
|
|
2927
|
+
// onto that version when it lands (`onGone('moved')`, which a session
|
|
2928
|
+
// re-opens). A `newDocId` in the result is opened through the same
|
|
2929
|
+
// `openSiblingDoc` path the history fork uses. Resolves with the outcome,
|
|
2930
|
+
// or `null` when the user dismissed the dialog.
|
|
1990
2931
|
// Throws when there is no doc identity or no resolver is attached.
|
|
1991
2932
|
/** @internal */
|
|
1992
2933
|
async resolveDrive(): Promise<DriveResolveResult | null> {
|
|
@@ -2003,20 +2944,8 @@ export class Document {
|
|
|
2003
2944
|
}
|
|
2004
2945
|
|
|
2005
2946
|
// Fetch the file a document was uploaded as: a short-lived presigned GET
|
|
2006
|
-
// plus the file's name / content type / size.
|
|
2007
|
-
//
|
|
2008
|
-
// content into this document through the normal handle surface
|
|
2009
|
-
// (`doc.map('instances').mutate(...)`):
|
|
2010
|
-
//
|
|
2011
|
-
// const src = await doc.sourceBlob();
|
|
2012
|
-
// const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
2013
|
-
// const records = parseMyFormat(bytes); // app's own parser
|
|
2014
|
-
// doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
|
|
2015
|
-
//
|
|
2016
|
-
// A source file is a set of one or more streams: `src.streams` lists
|
|
2017
|
-
// every stream (primary first, so a single-stream file has one entry),
|
|
2018
|
-
// each with its own URL, so a multi-stream format (a glTF with its
|
|
2019
|
-
// `.bin` buffer) fetches only the streams it needs.
|
|
2947
|
+
// plus the file's name / content type / size. The document's content is
|
|
2948
|
+
// what its importer returned for this file, sealed by the platform.
|
|
2020
2949
|
//
|
|
2021
2950
|
// Rejects with a `BrassError` when the document holds no such file, or
|
|
2022
2951
|
// the caller cannot reach it (both 404).
|
|
@@ -2117,23 +3046,24 @@ export class Document {
|
|
|
2117
3046
|
`doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`,
|
|
2118
3047
|
);
|
|
2119
3048
|
}
|
|
2120
|
-
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
3049
|
+
// Every stream the platform writes is a document line and reports
|
|
3050
|
+
// `'crdt'`, whether it is stored as a CRDT or frozen. Any other value
|
|
3051
|
+
// names a kind this version does not know, so it is refused rather
|
|
3052
|
+
// than joined as a line.
|
|
2124
3053
|
if (info.format !== 'crdt') {
|
|
2125
3054
|
throw new Error(
|
|
2126
3055
|
`Stream "${name}" has format "${info.format}", which this ` +
|
|
2127
|
-
'SDK version cannot open
|
|
3056
|
+
'SDK version cannot open.',
|
|
2128
3057
|
);
|
|
2129
3058
|
}
|
|
2130
3059
|
const inner = await runtime.openLine(info.line_id);
|
|
3060
|
+
inner._joinUndoGroup(this._undoGroup);
|
|
2131
3061
|
const stream = new DocumentStream(name, inner, true);
|
|
2132
3062
|
try {
|
|
2133
3063
|
// Opened while a version preview is active: bring the new stream to
|
|
2134
3064
|
// the same point in time rather than showing it live under a
|
|
2135
3065
|
// historical default.
|
|
2136
|
-
if (this.
|
|
3066
|
+
if (this._previewVersion !== null) {
|
|
2137
3067
|
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
|
|
2138
3068
|
}
|
|
2139
3069
|
} catch (err) {
|
|
@@ -2164,6 +3094,9 @@ export class Document {
|
|
|
2164
3094
|
stream._inner._observeSavingCount(() => {
|
|
2165
3095
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
2166
3096
|
});
|
|
3097
|
+
stream._inner._observeConnectionNotice((kind) => {
|
|
3098
|
+
this._showConnectionNotice(kind);
|
|
3099
|
+
});
|
|
2167
3100
|
if (this._suspended) stream._suspend();
|
|
2168
3101
|
},
|
|
2169
3102
|
() => {
|
|
@@ -2251,6 +3184,7 @@ export class Document {
|
|
|
2251
3184
|
// transport about to be torn down and is never persisted.
|
|
2252
3185
|
for (const stream of this._openStreams.values()) {
|
|
2253
3186
|
stream._inner._observeSavingCount(null);
|
|
3187
|
+
stream._inner._observeConnectionNotice(null);
|
|
2254
3188
|
stream._close();
|
|
2255
3189
|
}
|
|
2256
3190
|
// An open still in flight has no Document to close yet, so it closes on
|
|
@@ -2282,17 +3216,20 @@ export class Document {
|
|
|
2282
3216
|
}
|
|
2283
3217
|
}
|
|
2284
3218
|
|
|
2285
|
-
//
|
|
2286
|
-
// history
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3219
|
+
// Mark the end of a user action: seals the undo step at once, and records a
|
|
3220
|
+
// version-history marker covering this call (calls made while a write is
|
|
3221
|
+
// in flight share the next one, written at the latest state). Call it
|
|
3222
|
+
// after every action and do not await it in the action's handler.
|
|
3223
|
+
// Resolves once the marker covering the call is written. While `readOnly`
|
|
3224
|
+
// is true it writes nothing and resolves at once (as `undo` / `redo` do
|
|
3225
|
+
// nothing there).
|
|
3226
|
+
createCheckpoint(): Promise<void> {
|
|
3227
|
+
if (this._isLive && this.readOnly) return Promise.resolve();
|
|
3228
|
+
const written = this._checkpoints.create().then(() => undefined);
|
|
3229
|
+
// Apps call this without awaiting it; a rejection still reaches a caller
|
|
3230
|
+
// that awaits.
|
|
3231
|
+
written.catch(() => {});
|
|
3232
|
+
return written;
|
|
2296
3233
|
}
|
|
2297
3234
|
|
|
2298
3235
|
// Configure the implicit auto-checkpoint debounce. Default is 60s
|
|
@@ -2325,6 +3262,25 @@ export class Document {
|
|
|
2325
3262
|
this._fireSubscribers({ kind: 'commands' });
|
|
2326
3263
|
}
|
|
2327
3264
|
|
|
3265
|
+
/** When Brass will remove this document, or `null` when it is kept. Set
|
|
3266
|
+
* for a document created while signed out; a sign-in claims it and the
|
|
3267
|
+
* claimed document reads `null`. */
|
|
3268
|
+
get expiresAt(): Date | null {
|
|
3269
|
+
return this._expiresAt;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
/** @internal */
|
|
3273
|
+
_setExpiresAt(expiresAt: Date | null): void {
|
|
3274
|
+
const same =
|
|
3275
|
+
(this._expiresAt === null && expiresAt === null) ||
|
|
3276
|
+
(this._expiresAt !== null &&
|
|
3277
|
+
expiresAt !== null &&
|
|
3278
|
+
this._expiresAt.getTime() === expiresAt.getTime());
|
|
3279
|
+
if (same) return;
|
|
3280
|
+
this._expiresAt = expiresAt;
|
|
3281
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
3282
|
+
}
|
|
3283
|
+
|
|
2328
3284
|
/** @internal */
|
|
2329
3285
|
setHostedUiProvider(provider: HostedUiProvider | null): void {
|
|
2330
3286
|
this._commandMenu.setHostedUiProvider(provider);
|
|
@@ -2392,6 +3348,13 @@ export class Document {
|
|
|
2392
3348
|
else console.error('[Brass]', err);
|
|
2393
3349
|
}
|
|
2394
3350
|
|
|
3351
|
+
// The console keeps the original stack for the developer; the app's
|
|
3352
|
+
// `onError` gets a sentence it can show the reader.
|
|
3353
|
+
private _reportSubscriberThrow(err: unknown): void {
|
|
3354
|
+
console.error('[Brass] subscriber threw:', err);
|
|
3355
|
+
if (this._errorReporter) this._errorReporter(appCallbackError(err));
|
|
3356
|
+
}
|
|
3357
|
+
|
|
2395
3358
|
/** @internal Report a failure from the SDK's own chrome (the version
|
|
2396
3359
|
* bar's intents) through the same sink the built-in commands use, so a
|
|
2397
3360
|
* refused checkout reaches the app's `onError` rather than being dropped
|
|
@@ -2416,6 +3379,9 @@ export class Document {
|
|
|
2416
3379
|
// itself, so the registry's total already carries one entry per stream.
|
|
2417
3380
|
trackUnsavedWork(this, this._outstandingByClientId.size);
|
|
2418
3381
|
this._savingCountObserver?.();
|
|
3382
|
+
if (this._workStatus.notice !== null && this.savingCount === 0) {
|
|
3383
|
+
this._workStatus.setNotice(null);
|
|
3384
|
+
}
|
|
2419
3385
|
}
|
|
2420
3386
|
this._ensureLoroSubscribed();
|
|
2421
3387
|
this._subs.emit(event);
|
|
@@ -2537,8 +3503,9 @@ export class Document {
|
|
|
2537
3503
|
// Returns true if the clientOpId was tracked (lets the caller detect
|
|
2538
3504
|
// a stale ack).
|
|
2539
3505
|
/** @internal */
|
|
2540
|
-
resolveOutstandingOp(clientOpId: string): boolean {
|
|
3506
|
+
resolveOutstandingOp(clientOpId: string, seq?: number): boolean {
|
|
2541
3507
|
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
3508
|
+
this._provisionalInputs.acked(clientOpId, seq);
|
|
2542
3509
|
// Release this chunk from the resync-push loop guard once its op is
|
|
2543
3510
|
// acked: the pushed op is now in the log, so the next handshake's
|
|
2544
3511
|
// tail covers it and a fresh push is welcome (it would be a genuine
|
|
@@ -2566,7 +3533,7 @@ export class Document {
|
|
|
2566
3533
|
// grant restored afterwards is refused by the server too until the
|
|
2567
3534
|
// connection recycles, and a reopen is what picks the wider grant up.
|
|
2568
3535
|
/** @internal */
|
|
2569
|
-
_markReadOnly(reason:
|
|
3536
|
+
_markReadOnly(reason: ForcedReadOnlyReason): void {
|
|
2570
3537
|
if (this._forcedReadOnly) return;
|
|
2571
3538
|
this._forcedReadOnly = true;
|
|
2572
3539
|
this._readOnlyReason = reason;
|
|
@@ -2590,10 +3557,189 @@ export class Document {
|
|
|
2590
3557
|
* telling the reader first, because an app that renders nothing for it is
|
|
2591
3558
|
* exactly the case that must not leave someone on a diverged copy.
|
|
2592
3559
|
*/
|
|
2593
|
-
|
|
3560
|
+
_reopen(): void {
|
|
2594
3561
|
this._markGone('moved');
|
|
2595
3562
|
}
|
|
2596
3563
|
|
|
3564
|
+
/** @internal The app registered its importer, so inputs whose open stopped
|
|
3565
|
+
* waiting for it run again. */
|
|
3566
|
+
_importerRegistered(): void {
|
|
3567
|
+
this._inputFollowers.importerRegistered();
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
/**
|
|
3571
|
+
* @internal Typed on `ReadOnlyDocument`. A frozen copy loads the document as
|
|
3572
|
+
* it stands now, in place: the handles stay the same and their subscribers
|
|
3573
|
+
* are told what changed. A live read-only copy already holds the document as
|
|
3574
|
+
* it stands, so there is nothing to load. An editable copy is reopened
|
|
3575
|
+
* (`_reopen`).
|
|
3576
|
+
*/
|
|
3577
|
+
reload(): Promise<void> {
|
|
3578
|
+
this._assertOpen();
|
|
3579
|
+
const reloader = this._frozenReloader;
|
|
3580
|
+
if (reloader === null) {
|
|
3581
|
+
if (!this.readOnly) this._reopen();
|
|
3582
|
+
return Promise.resolve();
|
|
3583
|
+
}
|
|
3584
|
+
// One reload at a time: a call while one runs shares it, since both ask
|
|
3585
|
+
// for the document as it stands once it lands.
|
|
3586
|
+
if (this._reloading !== null) return this._reloading;
|
|
3587
|
+
const run = (async () => {
|
|
3588
|
+
try {
|
|
3589
|
+
// Cleared before the join, so a change landing during it is reported
|
|
3590
|
+
// against the load it may or may not have reached.
|
|
3591
|
+
this._changed = false;
|
|
3592
|
+
this._stopChangeNotice?.();
|
|
3593
|
+
this._stopChangeNotice = null;
|
|
3594
|
+
await reloader();
|
|
3595
|
+
} finally {
|
|
3596
|
+
this._reloading = null;
|
|
3597
|
+
if (!this._closed && (this._changedListeners.size > 0 || this._statusWatches > 0)) {
|
|
3598
|
+
this._watchForChange();
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
})();
|
|
3602
|
+
this._reloading = run;
|
|
3603
|
+
return run;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
/**
|
|
3607
|
+
* Opens this document again in another mode, on the same branch. With no
|
|
3608
|
+
* options the new copy is editable; `{ readOnly: true }` follows other
|
|
3609
|
+
* people's edits with nothing on it that writes; `{ frozen: true }` keeps
|
|
3610
|
+
* what it loaded until `reload()`. Resolves with this document when it is
|
|
3611
|
+
* already in that mode, and otherwise with the new copy, closing this one.
|
|
3612
|
+
*
|
|
3613
|
+
* Rejects with a `BrassError` (status 403), without a request, when the
|
|
3614
|
+
* reader may not edit and the reopen asks for an editable copy. Leaving an
|
|
3615
|
+
* editable copy first waits for its changes to save, and rejects, keeping
|
|
3616
|
+
* this copy open, when they do not.
|
|
3617
|
+
*/
|
|
3618
|
+
reopen(options: { frozen: true; readOnly?: true }): Promise<ReadOnlyDocument>;
|
|
3619
|
+
reopen(options: { readOnly: true; frozen?: false }): Promise<ReadOnlyDocument>;
|
|
3620
|
+
reopen(options?: { readOnly?: false; frozen?: false }): Promise<OpenedDocument>;
|
|
3621
|
+
reopen(options: OpenModeOptions): Promise<OpenedDocument | ReadOnlyDocument>;
|
|
3622
|
+
async reopen(options?: {
|
|
3623
|
+
readOnly?: boolean;
|
|
3624
|
+
frozen?: boolean;
|
|
3625
|
+
}): Promise<OpenedDocument | ReadOnlyDocument> {
|
|
3626
|
+
this._assertOpen();
|
|
3627
|
+
if (options?.frozen === true && options.readOnly === false) {
|
|
3628
|
+
throw new TypeError('reopen: a frozen copy is read-only, so `readOnly: false` cannot apply');
|
|
3629
|
+
}
|
|
3630
|
+
const mode: ReopenMode =
|
|
3631
|
+
options?.frozen === true ? 'frozen' : options?.readOnly === true ? 'read' : 'edit';
|
|
3632
|
+
const pending = this._pendingReopen;
|
|
3633
|
+
if (pending !== null) {
|
|
3634
|
+
if (pending.mode === mode) return pending.promise;
|
|
3635
|
+
// The later mode wins: the earlier call rejects and the copy it opens is
|
|
3636
|
+
// reopened in this mode, so every copy opened is either returned or closed.
|
|
3637
|
+
pending.superseded = true;
|
|
3638
|
+
return this._trackReopen(mode, async () => {
|
|
3639
|
+
const earlier = await pending.result.catch(() => null);
|
|
3640
|
+
if (earlier !== null) {
|
|
3641
|
+
return earlier.reopen(
|
|
3642
|
+
mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' },
|
|
3643
|
+
) as Promise<OpenedDocument>;
|
|
3644
|
+
}
|
|
3645
|
+
const reopener = this._reopenerFor(mode);
|
|
3646
|
+
if (reopener === null) return this as unknown as OpenedDocument;
|
|
3647
|
+
return this._runReopen(mode, reopener);
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
const reopener = this._reopenerFor(mode);
|
|
3651
|
+
if (reopener === null) return this as unknown as OpenedDocument;
|
|
3652
|
+
return this._trackReopen(mode, () => this._runReopen(mode, reopener));
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
// The layer that reopens this copy in `mode`, or `null` when it is already
|
|
3656
|
+
// in that mode. Throws when it cannot be reopened so.
|
|
3657
|
+
private _reopenerFor(mode: ReopenMode): ((mode: ReopenMode) => Promise<OpenedDocument>) | null {
|
|
3658
|
+
// A copy showing a historical version is not at the head in any mode, so
|
|
3659
|
+
// every reopen loads the head.
|
|
3660
|
+
// A copy locked over a refused change shows something the platform does
|
|
3661
|
+
// not, so even a reopen to read builds a new one.
|
|
3662
|
+
const diverged = this._forcedReadOnly && this._readOnlyReason === 'unsaved';
|
|
3663
|
+
if (mode === this._openMode && this._previewVersion === null && !diverged) {
|
|
3664
|
+
return null;
|
|
3665
|
+
}
|
|
3666
|
+
if (mode === 'edit') {
|
|
3667
|
+
const refusal = this._editRefusal;
|
|
3668
|
+
if (refusal !== null) throw new BrassError(refusal, { status: 403 });
|
|
3669
|
+
}
|
|
3670
|
+
const reopener = this._reopener;
|
|
3671
|
+
if (reopener === null) {
|
|
3672
|
+
throw new BrassError('This document cannot be reopened', { status: 0 });
|
|
3673
|
+
}
|
|
3674
|
+
return reopener;
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
private async _runReopen(
|
|
3678
|
+
mode: ReopenMode,
|
|
3679
|
+
reopener: (mode: ReopenMode) => Promise<OpenedDocument>,
|
|
3680
|
+
): Promise<OpenedDocument> {
|
|
3681
|
+
if (this._openMode === 'edit') {
|
|
3682
|
+
// A reopen called from inside a `mutate` callback runs before that
|
|
3683
|
+
// mutation commits, so the wait starts once it has.
|
|
3684
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3685
|
+
if (!(await whenSaved(this))) {
|
|
3686
|
+
throw new BrassError('Changes to this document have not finished saving', {
|
|
3687
|
+
status: 0,
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
return reopener(mode);
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// Records `run` as the reopen in flight, which a second call to the same
|
|
3695
|
+
// mode shares and a call to another mode supersedes. `reopening` holds until
|
|
3696
|
+
// the last overlapping reopen settles.
|
|
3697
|
+
private _trackReopen(
|
|
3698
|
+
mode: ReopenMode,
|
|
3699
|
+
run: () => Promise<OpenedDocument>,
|
|
3700
|
+
): Promise<OpenedDocument> {
|
|
3701
|
+
this._setReopening(true);
|
|
3702
|
+
const entry = { mode, superseded: false } as PendingReopen;
|
|
3703
|
+
this._pendingReopen = entry;
|
|
3704
|
+
entry.result = run().finally(() => {
|
|
3705
|
+
if (this._pendingReopen !== entry) return;
|
|
3706
|
+
this._pendingReopen = null;
|
|
3707
|
+
this._setReopening(false);
|
|
3708
|
+
});
|
|
3709
|
+
entry.promise = entry.result.then((next) => {
|
|
3710
|
+
if (entry.superseded) {
|
|
3711
|
+
throw new BrassError('The reopen was replaced by a later reopen', { status: 0 });
|
|
3712
|
+
}
|
|
3713
|
+
return next;
|
|
3714
|
+
});
|
|
3715
|
+
return entry.promise;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
private _setReopening(reopening: boolean): void {
|
|
3719
|
+
if (this._reopening === reopening) return;
|
|
3720
|
+
this._reopening = reopening;
|
|
3721
|
+
if (!this._isLive) return;
|
|
3722
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
3723
|
+
this._maybeFireUndoState();
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
/** @internal Which of the three open modes this copy is in. */
|
|
3727
|
+
get _openMode(): ReopenMode {
|
|
3728
|
+
if (this._frozenReloader !== null) return 'frozen';
|
|
3729
|
+
return this._forcedReadOnly ? 'read' : 'edit';
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
/** @internal Install what `reopen` calls. */
|
|
3733
|
+
_setReopener(reopener: ((mode: ReopenMode) => Promise<OpenedDocument>) | null): void {
|
|
3734
|
+
this._reopener = reopener;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
/** @internal What `reopen` calls, so a layer that takes over this copy can
|
|
3738
|
+
* fall back to the one it replaced. */
|
|
3739
|
+
get _reopenerFn(): ((mode: ReopenMode) => Promise<OpenedDocument>) | null {
|
|
3740
|
+
return this._reopener;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
2597
3743
|
// Internal: forget an outstanding op the server TERMINALLY rejected (an
|
|
2598
3744
|
// `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
|
|
2599
3745
|
// server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
|
|
@@ -2615,6 +3761,7 @@ export class Document {
|
|
|
2615
3761
|
): boolean {
|
|
2616
3762
|
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
2617
3763
|
this._resyncPushClientOpIds.delete(clientOpId);
|
|
3764
|
+
this._provisionalInputs.failed(clientOpId);
|
|
2618
3765
|
if (wasTracked) {
|
|
2619
3766
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
2620
3767
|
if (opts.report !== false) this._reportError(new Error(message));
|
|
@@ -2675,6 +3822,8 @@ export class Document {
|
|
|
2675
3822
|
|
|
2676
3823
|
private _ensureLocalUpdatesSubscribed(): void {
|
|
2677
3824
|
if (this._unsubLocal) return;
|
|
3825
|
+
// Frozen content takes no write, so there is no local update to send.
|
|
3826
|
+
if (this._sealedRoots !== null) return;
|
|
2678
3827
|
if (!this._localOpHook) {
|
|
2679
3828
|
this._reportTransportUnavailable();
|
|
2680
3829
|
return;
|
|
@@ -2684,25 +3833,30 @@ export class Document {
|
|
|
2684
3833
|
// does NOT trigger this callback, so we don't risk feeding the
|
|
2685
3834
|
// same op back to the server in a loop.
|
|
2686
3835
|
this._unsubLocal = this._doc.subscribeLocalUpdates((bytes: Uint8Array) => {
|
|
2687
|
-
|
|
2688
|
-
if (
|
|
2689
|
-
|
|
2690
|
-
// some builds. Hand the consumer a stable view so the eventual
|
|
2691
|
-
// WS send sees the correct bytes regardless of when the send
|
|
2692
|
-
// actually flushes (the WS client may queue the frame).
|
|
2693
|
-
const copy = new Uint8Array(bytes.byteLength);
|
|
2694
|
-
copy.set(bytes);
|
|
2695
|
-
const chunks = this.splitLocalUpdate(copy);
|
|
2696
|
-
if (chunks === null) {
|
|
2697
|
-
this._shipLocalOp(copy);
|
|
2698
|
-
return;
|
|
2699
|
-
}
|
|
2700
|
-
// In order: each chunk continues the one before it, so a peer applying
|
|
2701
|
-
// them out of sequence holds the tail unapplied until the gap closes.
|
|
2702
|
-
for (const chunk of chunks) this._shipLocalOp(chunk);
|
|
3836
|
+
// A replay's commits ship together once it ends (`_replayUndo`).
|
|
3837
|
+
if (this._replayingUndo) return;
|
|
3838
|
+
this._shipLocalUpdate(bytes);
|
|
2703
3839
|
});
|
|
2704
3840
|
}
|
|
2705
3841
|
|
|
3842
|
+
private _shipLocalUpdate(bytes: Uint8Array): void {
|
|
3843
|
+
if (!this._localOpHook) return;
|
|
3844
|
+
// Copy: Loro reuses its internal buffer across callback calls in
|
|
3845
|
+
// some builds. Hand the consumer a stable view so the eventual
|
|
3846
|
+
// WS send sees the correct bytes regardless of when the send
|
|
3847
|
+
// actually flushes (the WS client may queue the frame).
|
|
3848
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
3849
|
+
copy.set(bytes);
|
|
3850
|
+
const chunks = this.splitLocalUpdate(copy);
|
|
3851
|
+
if (chunks === null) {
|
|
3852
|
+
this._shipLocalOp(copy);
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
// In order: each chunk continues the one before it, so a peer applying
|
|
3856
|
+
// them out of sequence holds the tail unapplied until the gap closes.
|
|
3857
|
+
for (const chunk of chunks) this._shipLocalOp(chunk);
|
|
3858
|
+
}
|
|
3859
|
+
|
|
2706
3860
|
// Hand one op blob to the transport and count it as unsaved, or keep it
|
|
2707
3861
|
// local when the server has evicted this document.
|
|
2708
3862
|
//
|
|
@@ -2723,6 +3877,7 @@ export class Document {
|
|
|
2723
3877
|
if (this._gone.reason !== null) return null;
|
|
2724
3878
|
const clientOpId = hook(copy);
|
|
2725
3879
|
this._outstandingByClientId.set(clientOpId, copy);
|
|
3880
|
+
this._shipCapture?.push(clientOpId);
|
|
2726
3881
|
// The local mutate has already fired `subscribe` (the Loro
|
|
2727
3882
|
// event), but `savingCount` just incremented; fire one more
|
|
2728
3883
|
// synthetic event so a subscribe handler bound on `savingCount`
|
|
@@ -2747,6 +3902,8 @@ export class Document {
|
|
|
2747
3902
|
// it: close set it to null.
|
|
2748
3903
|
if (this._closed) return;
|
|
2749
3904
|
if (this._unsubLoro) return;
|
|
3905
|
+
// Frozen roots deliver their own subscribers; there is no engine to hear.
|
|
3906
|
+
if (this._sealedRoots !== null) return;
|
|
2750
3907
|
this._unsubLoro = this._doc.subscribe((event) => {
|
|
2751
3908
|
// One channel: every event reaches every subscriber, whatever
|
|
2752
3909
|
// produced it. The app reconciles by value and reads the cause off
|
|
@@ -2758,10 +3915,14 @@ export class Document {
|
|
|
2758
3915
|
// build on them, which pins the storage engine through a surface that
|
|
2759
3916
|
// never promised it. The cause survives as `source`, the same
|
|
2760
3917
|
// engine-neutral vocabulary the handle surface reports.
|
|
2761
|
-
this.
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
3918
|
+
if (this._replayingUndo) {
|
|
3919
|
+
this._replayChange = changeSourceOf(event);
|
|
3920
|
+
} else {
|
|
3921
|
+
this._subs.emit({
|
|
3922
|
+
kind: 'change',
|
|
3923
|
+
source: changeSourceOf(event),
|
|
3924
|
+
} satisfies SyntheticSubscribeEvent);
|
|
3925
|
+
}
|
|
2765
3926
|
const isLocalCommit = (event as { by?: unknown }).by === 'local';
|
|
2766
3927
|
this._maybeFireUndoState();
|
|
2767
3928
|
// A local commit (mutate / undo / redo) schedules a debounced
|
|
@@ -2832,7 +3993,11 @@ export class Document {
|
|
|
2832
3993
|
// suspend like every other app-side subscriber.
|
|
2833
3994
|
subscribeUndoState(cb: UndoStateSubscriber): DocumentUnsubscribe {
|
|
2834
3995
|
this._assertOpen();
|
|
2835
|
-
|
|
3996
|
+
// A copy that cannot write never has anything of its own to undo (the
|
|
3997
|
+
// reason it cannot write does not lift while it is open), so it does not
|
|
3998
|
+
// listen to its own engine. A stream of the document can still take
|
|
3999
|
+
// writes, and reports them through the undo group.
|
|
4000
|
+
if (!this._forcedReadOnly) this._ensureLoroSubscribed();
|
|
2836
4001
|
this._undoStateSubs.add(cb);
|
|
2837
4002
|
// Fire immediately with current state so the consumer does
|
|
2838
4003
|
// not need separate initial-paint code. Capture the values
|
|
@@ -2859,7 +4024,13 @@ export class Document {
|
|
|
2859
4024
|
// Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
|
|
2860
4025
|
// Called from `_ensureLoroSubscribed`'s event dispatch.
|
|
2861
4026
|
private _maybeFireUndoState(): void {
|
|
4027
|
+
// A stream's undo state is its document's: whatever moved here (a write
|
|
4028
|
+
// landing, the stream turning read-only) re-reads the document's.
|
|
4029
|
+
if (!this._undoGroup.isOwner(this)) this._undoGroup.changed();
|
|
2862
4030
|
if (this._undoStateSubs.size === 0) return;
|
|
4031
|
+
// A step can replay as several commits, and each fires a Loro event
|
|
4032
|
+
// before the stacks settle; `_replayUndo` fires once at the end.
|
|
4033
|
+
if (this._replayingUndo) return;
|
|
2863
4034
|
const canUndo = this.canUndo();
|
|
2864
4035
|
const canRedo = this.canRedo();
|
|
2865
4036
|
if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
|
|
@@ -2902,31 +4073,264 @@ export class Document {
|
|
|
2902
4073
|
return this._gone.on(cb);
|
|
2903
4074
|
}
|
|
2904
4075
|
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
4076
|
+
/** What the importer reported about the file this document was read
|
|
4077
|
+
* from, as the open found it. `null` on a document no import produced and
|
|
4078
|
+
* on a clean import. */
|
|
4079
|
+
get importReport(): ImportReport | null {
|
|
4080
|
+
return this._importReport;
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/** @internal Seeded from the join. */
|
|
4084
|
+
_setImportReport(report: ImportReport | null): void {
|
|
4085
|
+
this._importReport = report;
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
/** @internal On a frozen open: whether the document has moved since this
|
|
4089
|
+
* copy last loaded. False on every other open, which applies changes as
|
|
4090
|
+
* they come. */
|
|
4091
|
+
get changed(): false {
|
|
4092
|
+
return this._changed as false;
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
/** @internal On a frozen open, fires when the copy learns the document
|
|
4096
|
+
* moved since it last loaded (a content edit, an import landing or
|
|
4097
|
+
* failing, the document being deleted): once per load, and again after a
|
|
4098
|
+
* `reload()` once the document moves past what that load brought. Never
|
|
4099
|
+
* fires on any other open. The document is watched only while a listener is
|
|
4100
|
+
* registered and until the copy is closed. */
|
|
4101
|
+
onChanged(cb: () => void): () => void {
|
|
4102
|
+
this._assertOpen();
|
|
4103
|
+
this._changedListeners.add(cb);
|
|
4104
|
+
if (this._changed) {
|
|
4105
|
+
queueMicrotask(() => {
|
|
4106
|
+
if (!this._closed && this._changed && this._changedListeners.has(cb)) cb();
|
|
4107
|
+
});
|
|
4108
|
+
} else {
|
|
4109
|
+
this._watchForChange();
|
|
4110
|
+
}
|
|
4111
|
+
return () => {
|
|
4112
|
+
this._changedListeners.delete(cb);
|
|
4113
|
+
this._unwatchIfIdle();
|
|
4114
|
+
};
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
private _unwatchIfIdle(): void {
|
|
4118
|
+
if (this._changedListeners.size === 0 && this._statusWatches === 0) {
|
|
4119
|
+
this._stopChangeNotice?.();
|
|
4120
|
+
this._stopChangeNotice = null;
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
private _watchForChange(): void {
|
|
4125
|
+
if (this._stopChangeNotice === null && this._changeNotice !== null) {
|
|
4126
|
+
this._stopChangeNotice = this._changeNotice(() => this._markChanged());
|
|
4127
|
+
}
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
/**
|
|
4131
|
+
* Calls the handlers with the input files of `key`. `loaded` gets every
|
|
4132
|
+
* file on each subscribe, then each new version, on any copy. `changed`
|
|
4133
|
+
* gets the files changed since this document last read them, on a copy
|
|
4134
|
+
* that can edit, each recorded as read off the Undo stack once its handler
|
|
4135
|
+
* resolves. `removed` gets each file that left. Calls for one file never
|
|
4136
|
+
* overlap. Subscriptions to one key share one set of calls, and each
|
|
4137
|
+
* subscription's handlers are called for every input; a `changed` input is
|
|
4138
|
+
* recorded as read once all of them resolve. Returns an unsubscribe.
|
|
4139
|
+
*/
|
|
4140
|
+
subscribeInputDocuments(
|
|
4141
|
+
key: string,
|
|
4142
|
+
handlers: InputDocumentHandlers,
|
|
4143
|
+
options?: InputDocumentOptions,
|
|
4144
|
+
): () => void {
|
|
4145
|
+
this._assertOpen();
|
|
4146
|
+
if (handlers === null || typeof handlers !== 'object') {
|
|
4147
|
+
throw new TypeError('subscribeInputDocuments: pass an object of handlers');
|
|
4148
|
+
}
|
|
4149
|
+
if (!isInputKey(key)) throw new TypeError(`subscribeInputDocuments: "${String(key)}" is not a valid key`);
|
|
4150
|
+
const named = handlers as Record<string, unknown>;
|
|
4151
|
+
if (!['loaded', 'changed', 'removed', 'status', 'settled'].some((name) => typeof named[name] === 'function')) {
|
|
4152
|
+
throw new TypeError('subscribeInputDocuments: handlers must include loaded, changed, removed, status or settled');
|
|
4153
|
+
}
|
|
4154
|
+
const hub = this._inputStatus();
|
|
4155
|
+
return this._inputFollowers.subscribe(
|
|
4156
|
+
{
|
|
4157
|
+
subscribeStatus: (k, cb) => hub.subscribeKey(k, cb),
|
|
4158
|
+
canWrite: () => !this._closed && !this.readOnly,
|
|
4159
|
+
markRead: (status, options) => this.markRead(status, options),
|
|
4160
|
+
open: this._inputsRuntime?.open ?? null,
|
|
4161
|
+
},
|
|
4162
|
+
key,
|
|
4163
|
+
handlers,
|
|
4164
|
+
options,
|
|
4165
|
+
);
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
/**
|
|
4169
|
+
* @internal Calls `cb` with the status of `key` once it is read, and again
|
|
4170
|
+
* whenever it changes: an input file was added, modified or removed, the
|
|
4171
|
+
* key's references changed, or this document's read marks moved (an Undo
|
|
4172
|
+
* of `markRead` included, which reports at once). Returns an unsubscribe.
|
|
4173
|
+
*/
|
|
4174
|
+
subscribeInputStatus(key: string, cb: (status: InputStatus) => void): () => void {
|
|
4175
|
+
this._assertOpen();
|
|
4176
|
+
if (typeof cb !== 'function') throw new TypeError('subscribeInputStatus: pass a callback');
|
|
4177
|
+
if (!isInputKey(key)) throw new TypeError(`subscribeInputStatus: "${String(key)}" is not a valid key`);
|
|
4178
|
+
return this._inputStatus().subscribeKey(key, cb);
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
/** @internal Wired by the SDK's open factory. */
|
|
4182
|
+
_setInputsRuntime(runtime: InputsStatusRuntime | null): void {
|
|
4183
|
+
this._inputsRuntime = runtime;
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
/** @internal The platform moved `key`'s status to `rev`; both null after a
|
|
4187
|
+
* resubscribe, which may have missed a nudge for any key. */
|
|
4188
|
+
_inputStatusNudged(key: string | null, rev: number | null): void {
|
|
4189
|
+
this._inputStatusHub?.nudged(key, rev);
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
private _inputStatus(): InputStatusHub {
|
|
4193
|
+
if (this._inputStatusHub !== null) return this._inputStatusHub;
|
|
4194
|
+
const runtime = this._inputsRuntime;
|
|
4195
|
+
if (runtime === null) {
|
|
4196
|
+
throw new Error('subscribeInputStatus: Document has no server identity. Opened via the SDK factory?');
|
|
4197
|
+
}
|
|
4198
|
+
this._inputStatusHub = new InputStatusHub({
|
|
4199
|
+
runtime,
|
|
4200
|
+
lineId: () => this._branchId,
|
|
4201
|
+
localRoots: () => this._inputsLocalRoots(),
|
|
4202
|
+
watchLocalRoots: (cb) => this._watchInputsRoots(cb),
|
|
4203
|
+
});
|
|
4204
|
+
return this._inputStatusHub;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
// The reserved roots' plain values. A root nobody has written is read as
|
|
4208
|
+
// empty without asking the engine for it, since asking creates it.
|
|
4209
|
+
private _inputsLocalRoots(): InputsLocalRoots | null {
|
|
4210
|
+
if (this._sealedRoots !== null || this._closed) return null;
|
|
4211
|
+
const engine = this._doc;
|
|
4212
|
+
const present = engine.getShallowValue() as Record<string, unknown>;
|
|
4213
|
+
return {
|
|
4214
|
+
refs: INPUTS_REFS_ROOT in present ? engine.getMap(INPUTS_REFS_ROOT).toJSON() : {},
|
|
4215
|
+
read: INPUTS_READ_ROOT in present ? engine.getMap(INPUTS_READ_ROOT).toJSON() : {},
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
private _watchInputsRoots(cb: () => void): () => void {
|
|
4220
|
+
if (this._sealedRoots !== null || this._closed) return () => {};
|
|
4221
|
+
const targets = new Set([`cid:root-${INPUTS_REFS_ROOT}:Map`, `cid:root-${INPUTS_READ_ROOT}:Map`]);
|
|
4222
|
+
return this._doc.subscribe((batch) => {
|
|
4223
|
+
if (batch.events.some((event) => targets.has(String(event.target)))) cb();
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4227
|
+
/** @internal How a frozen copy watches its document: starts watching,
|
|
4228
|
+
* calls `fire` when the document moves, and returns the stop. */
|
|
4229
|
+
_setChangeNotice(start: ((fire: () => void) => () => void) | null): void {
|
|
4230
|
+
this._changeNotice = start;
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
/** @internal Stops a frozen copy's notice once it has reported a move and
|
|
4234
|
+
* nobody is waiting on pending work. */
|
|
4235
|
+
_settleChangeNotice(): void {
|
|
4236
|
+
if (!this._changed) return;
|
|
4237
|
+
if (this._statusWatches > 0 && this._workStatus.work?.pending === true) return;
|
|
4238
|
+
this._stopChangeNotice?.();
|
|
4239
|
+
this._stopChangeNotice = null;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
/** @internal Wire a frozen copy's reload: re-join, apply the result in
|
|
4243
|
+
* place, and re-point the change notice at what that join brought. */
|
|
4244
|
+
_setFrozenReloader(reload: () => Promise<void>): void {
|
|
4245
|
+
this._frozenReloader = reload;
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
// The notice is spent on its first frame, so the watch stops here: the topic
|
|
4249
|
+
// carries every op of the document, and a copy that already knows it moved
|
|
4250
|
+
// has no use for the rest until it reloads. A copy whose work is still
|
|
4251
|
+
// pending keeps it while an `onStatus` listener waits, because the import
|
|
4252
|
+
// landing moves the document before its terminal status arrives.
|
|
4253
|
+
private _markChanged(): void {
|
|
4254
|
+
if (this._changed || this._closed) return;
|
|
4255
|
+
this._changed = true;
|
|
4256
|
+
this._settleChangeNotice();
|
|
4257
|
+
const listeners = [...this._changedListeners];
|
|
4258
|
+
for (const cb of listeners) {
|
|
4259
|
+
try {
|
|
4260
|
+
cb();
|
|
4261
|
+
} catch (err) {
|
|
4262
|
+
console.error('Brass: an onChanged listener threw', err);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
/**
|
|
4268
|
+
* The work in flight on this document (a new version importing, a drive
|
|
4269
|
+
* sync), or `null` when there is none. Render `message` verbatim, draw a
|
|
4270
|
+
* bar from `progress` when present, and show a waiting state while
|
|
4271
|
+
* `pending`. Later phases arrive through `onStatus`.
|
|
4272
|
+
*/
|
|
2911
4273
|
get status(): DocumentWorkStatus | null {
|
|
2912
4274
|
return this._workStatus.status;
|
|
2913
4275
|
}
|
|
2914
4276
|
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
//
|
|
2925
|
-
//
|
|
4277
|
+
// The platform's work status alone, without the SDK's own notice over it.
|
|
4278
|
+
// Every SDK-side decision about whether work is pending reads this, since
|
|
4279
|
+
// the notice describes this client's connection rather than the platform's
|
|
4280
|
+
// work.
|
|
4281
|
+
/** @internal */
|
|
4282
|
+
get _workStatusNow(): DocumentWorkStatus | null {
|
|
4283
|
+
return this._workStatus.work;
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
// Tell the reader their edits are waiting to save, through `status`, while
|
|
4287
|
+
// they wait: on a connection that has not opened (`NEVER_OPENED_NOTICE_MS`),
|
|
4288
|
+
// or on one that leaves an edit unanswered (`UNANSWERED_OP_NOTICE_AFTER`).
|
|
4289
|
+
// The doc transport decides when; the notice clears itself once nothing is
|
|
4290
|
+
// waiting (`_fireSubscribers`). A stream raises it on the document that
|
|
4291
|
+
// opened it as well, since the app renders that document's status.
|
|
4292
|
+
/** @internal */
|
|
4293
|
+
_showConnectionNotice(kind: keyof typeof SAVE_NOTICES = 'never_opened'): void {
|
|
4294
|
+
if (this._closed || this.savingCount === 0) return;
|
|
4295
|
+
this._workStatus.setNotice(SAVE_NOTICES[kind]);
|
|
4296
|
+
this._connectionNoticeObserver?.(kind);
|
|
4297
|
+
}
|
|
4298
|
+
|
|
4299
|
+
private _connectionNoticeObserver: ((kind: keyof typeof SAVE_NOTICES) => void) | null = null;
|
|
4300
|
+
|
|
4301
|
+
/** @internal */
|
|
4302
|
+
_observeConnectionNotice(observer: ((kind: keyof typeof SAVE_NOTICES) => void) | null): void {
|
|
4303
|
+
this._connectionNoticeObserver = observer;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
/**
|
|
4307
|
+
* Calls `cb` with every work-status update, and once on attach when a
|
|
4308
|
+
* status is already known. `pending: false` marks the work finished, as a
|
|
4309
|
+
* success or as a failure whose `message` the app shows. Returns an
|
|
4310
|
+
* unsubscribe.
|
|
4311
|
+
*
|
|
4312
|
+
* Do not parse `message` or infer phases from it: its wording changes
|
|
4313
|
+
* without an SDK release.
|
|
4314
|
+
*/
|
|
2926
4315
|
onStatus(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
|
|
2927
4316
|
// See `onGone`: guard the cross-session listener leak.
|
|
2928
4317
|
this._assertOpen();
|
|
2929
|
-
|
|
4318
|
+
const stop = this._workStatus.on(cb);
|
|
4319
|
+
// A frozen copy hears the work that was pending when it loaded on its
|
|
4320
|
+
// change notice, which runs only while somebody listens. A live copy's
|
|
4321
|
+
// transport always delivers status.
|
|
4322
|
+
if (this._changeNotice === null || this._workStatus.work?.pending !== true) return stop;
|
|
4323
|
+
this._statusWatches += 1;
|
|
4324
|
+
if (!this._changed) this._watchForChange();
|
|
4325
|
+
let stopped = false;
|
|
4326
|
+
return () => {
|
|
4327
|
+
stop();
|
|
4328
|
+
if (stopped) return;
|
|
4329
|
+
stopped = true;
|
|
4330
|
+
this._statusWatches -= 1;
|
|
4331
|
+
this._unwatchIfIdle();
|
|
4332
|
+
this._settleChangeNotice();
|
|
4333
|
+
};
|
|
2930
4334
|
}
|
|
2931
4335
|
|
|
2932
4336
|
// Internal: store the latest status and fire listeners. Not
|
|
@@ -2935,7 +4339,7 @@ export class Document {
|
|
|
2935
4339
|
// final state.
|
|
2936
4340
|
/** @internal */
|
|
2937
4341
|
_emitStatus(
|
|
2938
|
-
|
|
4342
|
+
incoming: DocumentWorkStatus,
|
|
2939
4343
|
opts?: {
|
|
2940
4344
|
// The caller imported the document's content immediately before this
|
|
2941
4345
|
// emit, so the work delivered what it promised whatever the terminal
|
|
@@ -2946,6 +4350,16 @@ export class Document {
|
|
|
2946
4350
|
// A late reload/reconcile closure that resolved after `close()` must not
|
|
2947
4351
|
// re-emit onto a torn-down doc (its undo stack is freed below in `close`).
|
|
2948
4352
|
if (this._closed) return;
|
|
4353
|
+
// An import in flight holds the document against a settled status from
|
|
4354
|
+
// anywhere else (a late frame for the work that stored the file), because
|
|
4355
|
+
// an edit made before the import lands leaves the file unimportable. A
|
|
4356
|
+
// join reporting the import done clears `_awaitsImport`, which releases
|
|
4357
|
+
// it for a client waiting on another's import.
|
|
4358
|
+
const status =
|
|
4359
|
+
this._importHold !== null && this._awaitsImport && incoming.pending !== true
|
|
4360
|
+
? this._importHold
|
|
4361
|
+
: incoming;
|
|
4362
|
+
const wasReadOnly = this.readOnly;
|
|
2949
4363
|
// Terminal work that still reports itself as locking editing is inbound
|
|
2950
4364
|
// work that ended without content: the platform clears the status when
|
|
2951
4365
|
// hydration / extraction succeeds, so what it reports terminally is a
|
|
@@ -2962,6 +4376,10 @@ export class Document {
|
|
|
2962
4376
|
// Refresh the undo-state notifier here so a subscribeUndoState-bound
|
|
2963
4377
|
// button tracks the lock, not only CRDT edits.
|
|
2964
4378
|
this._maybeFireUndoState();
|
|
4379
|
+
// An app re-reads `readOnly` in its `subscribe` handler, so a status that
|
|
4380
|
+
// locks or releases a document it has already bound has to reach that
|
|
4381
|
+
// handler, or its edit controls stay as they were.
|
|
4382
|
+
if (this.readOnly !== wasReadOnly) this._fireSubscribers({ kind: 'commands' });
|
|
2965
4383
|
}
|
|
2966
4384
|
|
|
2967
4385
|
// The current drive sync state of this document, or `null` when the
|
|
@@ -3064,9 +4482,243 @@ export class Document {
|
|
|
3064
4482
|
this._resyncPushClientOpIds.clear();
|
|
3065
4483
|
this._fireSubscribers({ kind: 'saving-count' });
|
|
3066
4484
|
}
|
|
4485
|
+
this._provisionalInputs.clear();
|
|
3067
4486
|
this._gone.mark(reason);
|
|
3068
4487
|
}
|
|
3069
4488
|
|
|
4489
|
+
// --- References and read marks -------------------------------------------
|
|
4490
|
+
|
|
4491
|
+
// How many handle `mutate` calls are running, and how many of those asked
|
|
4492
|
+
// to stay off the Undo stack. A commit inside any of them carries the
|
|
4493
|
+
// non-undoable origin, so a write the app asked to keep off the stack does
|
|
4494
|
+
// not reach it through a batch boundary or a nested handle.
|
|
4495
|
+
private _mutateDepth = 0;
|
|
4496
|
+
private _nonUndoableDepth = 0;
|
|
4497
|
+
// `dependsOn` / `markRead` writes waiting for the running mutate to finish.
|
|
4498
|
+
// A mutate can commit several times (`mutate-batching.ts`), and a write
|
|
4499
|
+
// left in the open transaction rides whichever commit comes next, so a
|
|
4500
|
+
// read mark could arrive before the results it acknowledges. Each runs as
|
|
4501
|
+
// a commit of its own after the mutate's last one.
|
|
4502
|
+
private _inputsQueue: InputsWrite[] = [];
|
|
4503
|
+
// The client op ids `_shipLocalOp` mints while a reference commit runs.
|
|
4504
|
+
private _shipCapture: string[] | null = null;
|
|
4505
|
+
private readonly _provisionalInputs = new ProvisionalInputsTracker();
|
|
4506
|
+
|
|
4507
|
+
/**
|
|
4508
|
+
* @internal The document's `updated_at` as this copy read it when it
|
|
4509
|
+
* opened. `markRead` records a document at this value. `null` for a
|
|
4510
|
+
* document not opened from the platform.
|
|
4511
|
+
*/
|
|
4512
|
+
get updatedAt(): string | null {
|
|
4513
|
+
return joinStampOf(this) ?? null;
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
/** @internal Where a key's provisional entry is posted once the op
|
|
4517
|
+
* carrying its change is acknowledged. */
|
|
4518
|
+
_setProvisionalInputsPoster(poster: ProvisionalInputsPoster | null): void {
|
|
4519
|
+
this._provisionalInputs.setPoster(poster);
|
|
4520
|
+
// A post's answer names the key's revision after it, which reads the same
|
|
4521
|
+
// as a nudge: a status behind it fetches the change.
|
|
4522
|
+
this._provisionalInputs.onAnswer(
|
|
4523
|
+
poster === null ? null : (answer) => this._inputStatusNudged(answer.key, answer.rev),
|
|
4524
|
+
);
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
/** @internal Run a handle's mutate, so its commits carry the origin it asked
|
|
4528
|
+
* for and the reference writes queued inside it commit after it. */
|
|
4529
|
+
_runInMutate(undoable: boolean, fn: () => void): void {
|
|
4530
|
+
// A nested mutate that changes the origin first commits what the enclosing
|
|
4531
|
+
// one has written so far, and commits its own writes before handing back,
|
|
4532
|
+
// so each write reaches the Undo stack per the mutate that made it.
|
|
4533
|
+
const switchesOrigin = !undoable && this._nonUndoableDepth === 0 && this._mutateDepth > 0;
|
|
4534
|
+
if (switchesOrigin) this._commitPendingMutate();
|
|
4535
|
+
this._mutateDepth += 1;
|
|
4536
|
+
if (!undoable) this._nonUndoableDepth += 1;
|
|
4537
|
+
let threw = true;
|
|
4538
|
+
try {
|
|
4539
|
+
fn();
|
|
4540
|
+
threw = false;
|
|
4541
|
+
} finally {
|
|
4542
|
+
if (switchesOrigin) this._commitPendingMutate();
|
|
4543
|
+
this._mutateDepth -= 1;
|
|
4544
|
+
if (!undoable) this._nonUndoableDepth -= 1;
|
|
4545
|
+
if (this._mutateDepth === 0) {
|
|
4546
|
+
const queued = this._inputsQueue;
|
|
4547
|
+
this._inputsQueue = [];
|
|
4548
|
+
// A mutate that threw left its results partly written, so the marks
|
|
4549
|
+
// that acknowledge them are not written either.
|
|
4550
|
+
if (!threw) this._flushInputs(queued);
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
private _commitPendingMutate(): void {
|
|
4556
|
+
if (this._doc.getPendingTxnLength() > 0) this._handleOwner.commitMutate();
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
private get _mutateCommitOrigin(): string {
|
|
4560
|
+
return this._nonUndoableDepth > 0 ? NON_UNDOABLE_COMMIT_ORIGIN : MUTATE_COMMIT_ORIGIN;
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
/**
|
|
4564
|
+
* Records the documents `key` depends on, replacing what it held. `null`
|
|
4565
|
+
* removes the key and its read marks. Writes only what differs from this
|
|
4566
|
+
* copy, so a reference another reader adds or removes at the same time
|
|
4567
|
+
* keeps their change. Undoable, as part of the current step, unless called
|
|
4568
|
+
* inside `mutate(fn, { undoable: false })`.
|
|
4569
|
+
*/
|
|
4570
|
+
dependsOn(key: string, refs: readonly InputReference[] | null): void {
|
|
4571
|
+
this._handleOwner.assertWritable();
|
|
4572
|
+
if (!isInputKey(key)) throw new TypeError(`dependsOn: "${String(key)}" is not a valid key`);
|
|
4573
|
+
if (refs !== null && !Array.isArray(refs)) {
|
|
4574
|
+
throw new TypeError('dependsOn: refs must be an array of references or null');
|
|
4575
|
+
}
|
|
4576
|
+
const parsed: WireReference[] = [];
|
|
4577
|
+
for (const ref of refs ?? []) {
|
|
4578
|
+
const one = parseInputReference(ref);
|
|
4579
|
+
if (one === null) throw new TypeError('dependsOn: refs holds a value that is not a reference');
|
|
4580
|
+
if (one.extent === 'document' && this._docId !== null && one.doc_id === this._docId) {
|
|
4581
|
+
throw new Error('dependsOn: a document cannot reference itself');
|
|
4582
|
+
}
|
|
4583
|
+
parsed.push(one);
|
|
4584
|
+
}
|
|
4585
|
+
const refusal = this._inputsRuntime?.refusesReferences;
|
|
4586
|
+
if (refusal !== undefined && parsed.length > 0) throw new BrassError(refusal);
|
|
4587
|
+
const next = refs === null ? null : parsed;
|
|
4588
|
+
this._queueInputs(this._nonUndoableDepth === 0, () => {
|
|
4589
|
+
const refsMap = this._doc.getMap(INPUTS_REFS_ROOT);
|
|
4590
|
+
let changed = applyRootWrites(refsMap, inputRefsWrites(rootView(refsMap), key, next));
|
|
4591
|
+
if (next === null) {
|
|
4592
|
+
const readMap = this._doc.getMap(INPUTS_READ_ROOT);
|
|
4593
|
+
changed = applyRootWrites(readMap, inputReadClearWrites(rootView(readMap), key)) || changed;
|
|
4594
|
+
}
|
|
4595
|
+
return changed ? key : null;
|
|
4596
|
+
});
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4599
|
+
/**
|
|
4600
|
+
* @internal Records that this reader read files for the key `status` reports, and
|
|
4601
|
+
* acknowledged the removed files that status lists. With no `files`, every
|
|
4602
|
+
* file the status lists as added or modified is recorded at the version the
|
|
4603
|
+
* status reports. Otherwise each entry of `files` is recorded: an opened
|
|
4604
|
+
* copy at the `updatedAt` it was opened at (left out while its import is
|
|
4605
|
+
* pending, so it stays changed), and an entry of the status's own `files`
|
|
4606
|
+
* or `changed` at the version the status reports. Joins the current Undo
|
|
4607
|
+
* step unless `undoable` is false.
|
|
4608
|
+
*/
|
|
4609
|
+
markRead(status: MarkReadStatus, options?: MarkReadOptions): void {
|
|
4610
|
+
this._handleOwner.assertWritable();
|
|
4611
|
+
const key = status?.key;
|
|
4612
|
+
if (!isInputKey(key)) throw new TypeError('markRead: pass the status `subscribeInputStatus` reported');
|
|
4613
|
+
// A list of files passed where the options go would otherwise read as no
|
|
4614
|
+
// files, and record every changed one.
|
|
4615
|
+
if (Array.isArray(options)) throw new TypeError('markRead: pass the files as `{ files }`');
|
|
4616
|
+
const files = options?.files;
|
|
4617
|
+
if (files !== undefined && !Array.isArray(files)) {
|
|
4618
|
+
throw new TypeError('markRead: `files` must be an array');
|
|
4619
|
+
}
|
|
4620
|
+
// The version the status reports for each file, which is what an entry
|
|
4621
|
+
// taken from the status is recorded at: an edit that lands after the
|
|
4622
|
+
// status stays changed.
|
|
4623
|
+
const reported = new Map<string, string>();
|
|
4624
|
+
for (const file of status.inputs ?? []) reported.set(file.doc_id, file.updated_at);
|
|
4625
|
+
const items: ReadonlyArray<MarkReadHandle | MarkReadEntry> =
|
|
4626
|
+
files ?? status.changed.filter((c) => c.change !== 'removed');
|
|
4627
|
+
const reads: Array<{ docId: string; stamp: number }> = [];
|
|
4628
|
+
for (const item of items) {
|
|
4629
|
+
let docId: string | null;
|
|
4630
|
+
let version: string | null;
|
|
4631
|
+
if ('doc_id' in item) {
|
|
4632
|
+
// A removed file is acknowledged from `status.changed` below.
|
|
4633
|
+
if ((item as { change?: string }).change === 'removed') continue;
|
|
4634
|
+
docId = item.doc_id;
|
|
4635
|
+
version = reported.get(docId) ?? null;
|
|
4636
|
+
if (version === null) {
|
|
4637
|
+
throw new TypeError(`markRead: the status reports no version for ${docId}`);
|
|
4638
|
+
}
|
|
4639
|
+
} else {
|
|
4640
|
+
if (item.status?.pending === true) continue;
|
|
4641
|
+
docId = item.id;
|
|
4642
|
+
version = item.updatedAt;
|
|
4643
|
+
}
|
|
4644
|
+
const stamp = typeof version === 'string' ? inputStampFromUpdatedAt(version) : null;
|
|
4645
|
+
if (!isInputDocId(docId) || stamp === null) {
|
|
4646
|
+
throw new TypeError('markRead: each file must be a document opened from Brass or an entry of the status');
|
|
4647
|
+
}
|
|
4648
|
+
reads.push({ docId, stamp });
|
|
4649
|
+
}
|
|
4650
|
+
const removed = status.changed
|
|
4651
|
+
.filter((c) => c.change === 'removed')
|
|
4652
|
+
.filter((c) => isInputDocId(c.doc_id))
|
|
4653
|
+
.map((c) => ({ docId: c.doc_id, leftAt: c.updated_at ?? null }));
|
|
4654
|
+
this._queueInputs(options?.undoable !== false, () => {
|
|
4655
|
+
const readMap = this._doc.getMap(INPUTS_READ_ROOT);
|
|
4656
|
+
const view = rootView(readMap);
|
|
4657
|
+
let changed = false;
|
|
4658
|
+
const write = (docId: string, mark: InputReadMark): void => {
|
|
4659
|
+
const writes = inputReadMarkWrites(view, key, docId, mark);
|
|
4660
|
+
for (const k of writes.delete) delete view[k];
|
|
4661
|
+
Object.assign(view, writes.set);
|
|
4662
|
+
changed = applyRootWrites(readMap, writes) || changed;
|
|
4663
|
+
};
|
|
4664
|
+
for (const { docId, stamp } of reads) {
|
|
4665
|
+
write(docId, inputReadMarkFor(latestReadMark(view, key, docId), stamp));
|
|
4666
|
+
}
|
|
4667
|
+
for (const { docId, leftAt } of removed) {
|
|
4668
|
+
write(docId, inputRemovalMark(latestReadMark(view, key, docId), leftAt));
|
|
4669
|
+
}
|
|
4670
|
+
return changed ? key : null;
|
|
4671
|
+
});
|
|
4672
|
+
}
|
|
4673
|
+
|
|
4674
|
+
private _queueInputs(undoable: boolean, apply: () => string | null): void {
|
|
4675
|
+
const entry: InputsWrite = { undoable, apply };
|
|
4676
|
+
if (this._mutateDepth > 0) {
|
|
4677
|
+
this._inputsQueue.push(entry);
|
|
4678
|
+
return;
|
|
4679
|
+
}
|
|
4680
|
+
this._flushInputs([entry]);
|
|
4681
|
+
}
|
|
4682
|
+
|
|
4683
|
+
// Commit queued reference writes in call order, one commit per run of
|
|
4684
|
+
// writes that share an origin, and wait to post each key they changed.
|
|
4685
|
+
private _flushInputs(entries: readonly InputsWrite[]): void {
|
|
4686
|
+
if (entries.length === 0 || this._readOnlyBecause !== null) return;
|
|
4687
|
+
this._ensureLocalUpdatesSubscribed();
|
|
4688
|
+
let i = 0;
|
|
4689
|
+
while (i < entries.length) {
|
|
4690
|
+
const undoable = entries[i]!.undoable;
|
|
4691
|
+
const keys = new Set<string>();
|
|
4692
|
+
for (; i < entries.length && entries[i]!.undoable === undoable; i += 1) {
|
|
4693
|
+
const key = entries[i]!.apply();
|
|
4694
|
+
if (key !== null) keys.add(key);
|
|
4695
|
+
}
|
|
4696
|
+
if (keys.size === 0) continue;
|
|
4697
|
+
this._shipCapture = [];
|
|
4698
|
+
let shipped: string[];
|
|
4699
|
+
try {
|
|
4700
|
+
this._doc.setNextCommitOrigin(undoable ? MUTATE_COMMIT_ORIGIN : NON_UNDOABLE_COMMIT_ORIGIN);
|
|
4701
|
+
this._doc.commit();
|
|
4702
|
+
} finally {
|
|
4703
|
+
shipped = this._shipCapture;
|
|
4704
|
+
this._shipCapture = null;
|
|
4705
|
+
}
|
|
4706
|
+
if (shipped.length === 0) continue;
|
|
4707
|
+
const state = decodeInputsRoots(
|
|
4708
|
+
this._doc.getMap(INPUTS_REFS_ROOT).toJSON(),
|
|
4709
|
+
this._doc.getMap(INPUTS_READ_ROOT).toJSON(),
|
|
4710
|
+
);
|
|
4711
|
+
for (const key of keys) {
|
|
4712
|
+
this._provisionalInputs.register(
|
|
4713
|
+
key,
|
|
4714
|
+
this._branchId,
|
|
4715
|
+
state.get(key) ?? { refs: [], read: {} },
|
|
4716
|
+
shipped,
|
|
4717
|
+
);
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
|
|
3070
4722
|
/**
|
|
3071
4723
|
* The top-level collections of the stream this document is open at, keyed
|
|
3072
4724
|
* as it keys them, each naming the kind of handle that reads it.
|
|
@@ -3086,17 +4738,19 @@ export class Document {
|
|
|
3086
4738
|
*/
|
|
3087
4739
|
roots(): Record<string, RootKind> {
|
|
3088
4740
|
this._assertNotClosed();
|
|
4741
|
+
if (this._sealedRoots !== null) return this._sealedRoots.roots();
|
|
3089
4742
|
const source =
|
|
3090
4743
|
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
3091
4744
|
const found: [string, RootKind][] = [];
|
|
3092
4745
|
for (const [key, containerId] of Object.entries(source.getShallowValue())) {
|
|
3093
4746
|
const kind = rootKindOf(containerId);
|
|
3094
|
-
if (kind === null) continue;
|
|
4747
|
+
if (kind === null || isReservedRootName(key)) continue;
|
|
3095
4748
|
// Reading the container's own count, rather than its value, is what
|
|
3096
4749
|
// keeps this off the projection: an empty collection is skipped without
|
|
3097
4750
|
// anything being decoded.
|
|
3098
4751
|
const container = source.getContainerById(containerId);
|
|
3099
4752
|
if (container === undefined || rootIsEmpty(container)) continue;
|
|
4753
|
+
if (holdsOnlyMarkers(key, container)) continue;
|
|
3100
4754
|
found.push([key, kind]);
|
|
3101
4755
|
}
|
|
3102
4756
|
// Loro reports roots in registration order, which is an accident of who
|
|
@@ -3106,26 +4760,23 @@ export class Document {
|
|
|
3106
4760
|
return Object.fromEntries(found);
|
|
3107
4761
|
}
|
|
3108
4762
|
|
|
3109
|
-
// Plain-JS projection of the current state
|
|
4763
|
+
// Plain-JS projection of the current state, leaving out a root that holds
|
|
4764
|
+
// nothing. A CRDT cannot store an empty collection, and the root a Document
|
|
4765
|
+
// registers on open is empty until written, so a projection that kept empty
|
|
4766
|
+
// roots would change shape when the storage swaps in place under a reader.
|
|
4767
|
+
// Every other root stays whatever its kind: the sealed-copy builder reads
|
|
4768
|
+
// this projection to refuse a kind the frozen form cannot carry.
|
|
3110
4769
|
toJSON(): Record<string, unknown> {
|
|
3111
4770
|
this._assertNotClosed();
|
|
3112
4771
|
// When in checkout, read from the preview LoroDoc so the app
|
|
3113
4772
|
// renders the historical state. Returning to head clears
|
|
3114
4773
|
// `_checkout` and reads go back to the live `_doc`.
|
|
3115
|
-
const
|
|
3116
|
-
this.
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
// renderer read this surface, and a store that crossed the paging
|
|
3122
|
-
// threshold has to reach them as records. Every root is asked, because a
|
|
3123
|
-
// root no handle was ever taken on is paged by the same rule as one that
|
|
3124
|
-
// was; a root the marker does not name comes back as loro produced it.
|
|
3125
|
-
for (const [rootKey, value] of Object.entries(json)) {
|
|
3126
|
-
if (!isRootLookup(value)) continue;
|
|
3127
|
-
if (!readRootPaged(source, rootKey)) continue;
|
|
3128
|
-
json[rootKey] = flattenPagedLookup(value, true);
|
|
4774
|
+
const json =
|
|
4775
|
+
this._sealedRoots !== null
|
|
4776
|
+
? this._sealedRoots.toJSON()
|
|
4777
|
+
: engineJSON(this._checkout !== null ? this._checkout.previewDoc : this._doc);
|
|
4778
|
+
for (const [key, value] of Object.entries(json)) {
|
|
4779
|
+
if (projectsEmpty(value)) delete json[key];
|
|
3129
4780
|
}
|
|
3130
4781
|
return json;
|
|
3131
4782
|
}
|
|
@@ -3155,6 +4806,7 @@ export class Document {
|
|
|
3155
4806
|
maybeResyncPush(
|
|
3156
4807
|
persistedSnapshotVersionVector: Uint8Array,
|
|
3157
4808
|
durableTailBlobs: Uint8Array[],
|
|
4809
|
+
deliveringBlobs: readonly Uint8Array[] = [],
|
|
3158
4810
|
): ResyncPushOutcome {
|
|
3159
4811
|
// `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
|
|
3160
4812
|
// closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
|
|
@@ -3168,6 +4820,7 @@ export class Document {
|
|
|
3168
4820
|
const chunks = this.computeResyncPush(
|
|
3169
4821
|
persistedSnapshotVersionVector,
|
|
3170
4822
|
durableTailBlobs,
|
|
4823
|
+
deliveringBlobs,
|
|
3171
4824
|
);
|
|
3172
4825
|
if (chunks === 'covered') return 'covered';
|
|
3173
4826
|
if (chunks === 'undecodable') return 'undecodable';
|
|
@@ -3183,14 +4836,15 @@ export class Document {
|
|
|
3183
4836
|
// this client holds CRDT knowledge the durable server state lacks,
|
|
3184
4837
|
// and if so return the update blob that fills exactly those holes.
|
|
3185
4838
|
//
|
|
3186
|
-
//
|
|
3187
|
-
//
|
|
3188
|
-
// log even though no snapshot has absorbed
|
|
3189
|
-
//
|
|
3190
|
-
//
|
|
3191
|
-
//
|
|
3192
|
-
//
|
|
3193
|
-
//
|
|
4839
|
+
// What the server holds is the snapshot (every author's ops from zero to
|
|
4840
|
+
// its version vector) plus each tail op it served, range by range: a tail
|
|
4841
|
+
// op is durable in the log even though no snapshot has absorbed it. Holding
|
|
4842
|
+
// the tail as ranges rather than folding it into one counter per author is
|
|
4843
|
+
// what sees a hole: a log that lost an author's op and stored that author's
|
|
4844
|
+
// later ones can apply none of them, so the op that fills the hole is
|
|
4845
|
+
// exactly what has to go back. Leaving the tail out altogether would flag
|
|
4846
|
+
// the whole tail as missing and push it back on every join, which grows the
|
|
4847
|
+
// log and loops. `decodeImportBlobMeta` reads each blob's range from its
|
|
3194
4848
|
// header without a full import, so this stays cheap.
|
|
3195
4849
|
//
|
|
3196
4850
|
// Returns the chunks to resync-push, `'covered'` when the durable state
|
|
@@ -3201,10 +4855,11 @@ export class Document {
|
|
|
3201
4855
|
// nothing about whether the doc has diverged, so a caller healing a known
|
|
3202
4856
|
// divergence must keep asking.
|
|
3203
4857
|
//
|
|
3204
|
-
// The common case is one chunk:
|
|
3205
|
-
//
|
|
3206
|
-
//
|
|
3207
|
-
//
|
|
4858
|
+
// The common case is one chunk: when each author is missing only its
|
|
4859
|
+
// newest ops, an `update` export carries exactly those, and a realistic
|
|
4860
|
+
// gap is far under the per-frame cap. A hole below ops the server holds
|
|
4861
|
+
// ships as its own `updates-in-range` span, one range per author per blob,
|
|
4862
|
+
// so a blob never re-sends what the server holds. When the missing slice exceeds the cap (a BIM-sized edit set:
|
|
3208
4863
|
// a geometry import or bulk parametric change discarded then healed, or
|
|
3209
4864
|
// a large offline divergence on reconnect), it is split into multiple
|
|
3210
4865
|
// chunks via `updates-in-range` spans, each under the cap, so even a
|
|
@@ -3231,7 +4886,7 @@ export class Document {
|
|
|
3231
4886
|
* @internal
|
|
3232
4887
|
*/
|
|
3233
4888
|
_snapshotHasUnseenAuthor(persistedSnapshotVersionVector: Uint8Array): boolean {
|
|
3234
|
-
if (this._closed) return false;
|
|
4889
|
+
if (this._closed || this._sealedRoots !== null) return false;
|
|
3235
4890
|
let durable: VersionVector;
|
|
3236
4891
|
try {
|
|
3237
4892
|
durable = VersionVector.decode(persistedSnapshotVersionVector);
|
|
@@ -3251,14 +4906,19 @@ export class Document {
|
|
|
3251
4906
|
computeResyncPush(
|
|
3252
4907
|
persistedSnapshotVersionVector: Uint8Array,
|
|
3253
4908
|
durableTailBlobs: Uint8Array[],
|
|
4909
|
+
deliveringBlobs: readonly Uint8Array[] = [],
|
|
3254
4910
|
): Uint8Array[] | 'covered' | 'undecodable' {
|
|
3255
|
-
|
|
4911
|
+
// Frozen content holds no local ops, so the durable frontier covers it.
|
|
4912
|
+
if (this._sealedRoots !== null) return 'covered';
|
|
4913
|
+
let held: HeldRanges;
|
|
3256
4914
|
try {
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
4915
|
+
// An op the transport has only just sent is on its way rather than
|
|
4916
|
+
// missing; counting it as held keeps the handshake from writing a
|
|
4917
|
+
// second copy of it beside the transport's own send.
|
|
4918
|
+
held = durableHoldings(persistedSnapshotVersionVector, [
|
|
4919
|
+
...durableTailBlobs,
|
|
4920
|
+
...deliveringBlobs,
|
|
4921
|
+
]);
|
|
3262
4922
|
} catch (err) {
|
|
3263
4923
|
console.warn(
|
|
3264
4924
|
'[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob',
|
|
@@ -3267,37 +4927,27 @@ export class Document {
|
|
|
3267
4927
|
return 'undecodable';
|
|
3268
4928
|
}
|
|
3269
4929
|
const local = this._doc.version();
|
|
3270
|
-
const
|
|
3271
|
-
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
if (
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
local: VersionVector,
|
|
3292
|
-
durable: VersionVector,
|
|
3293
|
-
): { peer: PeerID; from: number; to: number }[] {
|
|
3294
|
-
const durableMap = durable.toJSON();
|
|
3295
|
-
const ranges: { peer: PeerID; from: number; to: number }[] = [];
|
|
3296
|
-
for (const [peer, to] of local.toJSON()) {
|
|
3297
|
-
const from = durableMap.get(peer) ?? 0;
|
|
3298
|
-
if (to > from) ranges.push({ peer, from, to });
|
|
3299
|
-
}
|
|
3300
|
-
return ranges;
|
|
4930
|
+
const missing = rangesMissingFrom(local, held);
|
|
4931
|
+
if (missing.length === 0) return 'covered';
|
|
4932
|
+
// Common case: each author is missing only its newest ops, which is an
|
|
4933
|
+
// `update` export from a version vector, and the slice fits in one frame.
|
|
4934
|
+
const tails = new Map(missing.map((r) => [r.peer, r]));
|
|
4935
|
+
const localMap = local.toJSON();
|
|
4936
|
+
if (
|
|
4937
|
+
tails.size === missing.length &&
|
|
4938
|
+
missing.every((r) => r.to === localMap.get(r.peer))
|
|
4939
|
+
) {
|
|
4940
|
+
const from = new Map(localMap);
|
|
4941
|
+
for (const r of missing) from.set(r.peer, r.from);
|
|
4942
|
+
const full = this._doc.export({
|
|
4943
|
+
mode: 'update',
|
|
4944
|
+
from: VersionVector.parseJSON(from),
|
|
4945
|
+
});
|
|
4946
|
+
if (full.byteLength <= RESYNC_PUSH_MAX_BYTES) return [full];
|
|
4947
|
+
}
|
|
4948
|
+
return oneRangePerAuthor(missing).flatMap((group) =>
|
|
4949
|
+
this.chunkRanges(group, true),
|
|
4950
|
+
);
|
|
3301
4951
|
}
|
|
3302
4952
|
|
|
3303
4953
|
// Recursively export the given ranges as one `updates-in-range` blob,
|
|
@@ -3339,13 +4989,6 @@ export class Document {
|
|
|
3339
4989
|
];
|
|
3340
4990
|
}
|
|
3341
4991
|
|
|
3342
|
-
private chunkMissingSlice(
|
|
3343
|
-
local: VersionVector,
|
|
3344
|
-
durable: VersionVector,
|
|
3345
|
-
): Uint8Array[] {
|
|
3346
|
-
return this.chunkRanges(this.missingRanges(local, durable), true);
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
4992
|
// A local commit re-exported as several op blobs, or null to ship it whole.
|
|
3350
4993
|
//
|
|
3351
4994
|
// `mutate` bounds what one commit carries by committing as it goes, but a
|
|
@@ -3424,17 +5067,19 @@ export class Document {
|
|
|
3424
5067
|
if (this._closed || this._suspended) return;
|
|
3425
5068
|
this._suspended = true;
|
|
3426
5069
|
this._setStreamsSuspended(true);
|
|
3427
|
-
//
|
|
3428
|
-
//
|
|
3429
|
-
//
|
|
3430
|
-
|
|
3431
|
-
this._checkpoints.cancelAuto();
|
|
5070
|
+
// Calls still waiting for a marker get one now rather than after the
|
|
5071
|
+
// spacing, and a pending auto-checkpoint is cancelled rather than left to
|
|
5072
|
+
// fire against a parked Document.
|
|
5073
|
+
this._checkpoints.detach();
|
|
3432
5074
|
this._subs.clear();
|
|
3433
5075
|
this._undoStateSubs.clear();
|
|
3434
5076
|
this._gone.clearListeners();
|
|
3435
5077
|
this._workStatus.clearListeners();
|
|
3436
5078
|
this._driveStatus.clearListeners();
|
|
3437
5079
|
this._drivePublishTarget.clearListeners();
|
|
5080
|
+
this._inputFollowers.stopAll();
|
|
5081
|
+
this._inputStatusHub?.dispose();
|
|
5082
|
+
this._inputStatusHub = null;
|
|
3438
5083
|
// Presence subscribes its OWN WS topic and keeps it alive across a park
|
|
3439
5084
|
// (resume does not re-create the controller), so drop its app-side
|
|
3440
5085
|
// change listeners here the same way as the sets above: a parked doc
|
|
@@ -3449,9 +5094,11 @@ export class Document {
|
|
|
3449
5094
|
// so a subscription an app registered before `close()` would keep firing
|
|
3450
5095
|
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
3451
5096
|
// fresh handles are minted on resume + next access.
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
5097
|
+
// The handles over the ones torn down go with them, so a resumed open is
|
|
5098
|
+
// handed fresh handles like any other.
|
|
5099
|
+
if (this._sealedRoots === null) {
|
|
5100
|
+
this._teardownRootHandles();
|
|
5101
|
+
this._handles.clear();
|
|
3455
5102
|
}
|
|
3456
5103
|
this._rootHandles.clear();
|
|
3457
5104
|
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
@@ -3494,8 +5141,26 @@ export class Document {
|
|
|
3494
5141
|
return this._openEpoch;
|
|
3495
5142
|
}
|
|
3496
5143
|
|
|
5144
|
+
/** @internal Whether the latest join reported the document as its stored
|
|
5145
|
+
* file with no import run yet. Set by the open layer on every join, so an
|
|
5146
|
+
* importer can hold the document before its claim goes out. */
|
|
5147
|
+
_awaitsImport = false;
|
|
5148
|
+
|
|
5149
|
+
/** @internal The status an importer holds the document under, or null. */
|
|
5150
|
+
_importHold: DocumentWorkStatus | null = null;
|
|
5151
|
+
|
|
5152
|
+
/** @internal Why this client stopped waiting to import the document, or
|
|
5153
|
+
* null. */
|
|
5154
|
+
_importAbandoned: string | null = null;
|
|
5155
|
+
|
|
3497
5156
|
close(): void {
|
|
3498
5157
|
if (this._closed) return;
|
|
5158
|
+
// Before anything is torn down: the final marker reads the seq and the
|
|
5159
|
+
// unacked-op count this close is about to drop.
|
|
5160
|
+
this._checkpoints.detach();
|
|
5161
|
+
this._stopChangeNotice?.();
|
|
5162
|
+
this._stopChangeNotice = null;
|
|
5163
|
+
this._changedListeners.clear();
|
|
3499
5164
|
this._closeStreams();
|
|
3500
5165
|
this._closed = true;
|
|
3501
5166
|
this._suspended = false;
|
|
@@ -3511,6 +5176,10 @@ export class Document {
|
|
|
3511
5176
|
this._undo.free();
|
|
3512
5177
|
this._undo = null;
|
|
3513
5178
|
}
|
|
5179
|
+
if (!this._undoGroup.isOwner(this)) {
|
|
5180
|
+
this._undoGroup.delete(this);
|
|
5181
|
+
this._undoGroup.changed();
|
|
5182
|
+
}
|
|
3514
5183
|
this._thumbnails.destroy();
|
|
3515
5184
|
this._checkpoints.cancelAuto();
|
|
3516
5185
|
this._localOpHook = null;
|
|
@@ -3528,13 +5197,13 @@ export class Document {
|
|
|
3528
5197
|
this._workStatus.clearListeners();
|
|
3529
5198
|
this._driveStatus.clearListeners();
|
|
3530
5199
|
this._drivePublishTarget.clearListeners();
|
|
5200
|
+
this._inputFollowers.stopAll();
|
|
5201
|
+
this._inputStatusHub?.dispose();
|
|
5202
|
+
this._inputStatusHub = null;
|
|
3531
5203
|
// Detach handle subscriptions before freeing the doc (below): a handle's
|
|
3532
5204
|
// per-container Loro subscription outlives the cache reference, and
|
|
3533
5205
|
// `_doc.free()` must not run with subscriptions still attached.
|
|
3534
|
-
|
|
3535
|
-
if (entry.kind === 'text') entry.handle._teardown();
|
|
3536
|
-
else teardownRootHandle(entry.handle);
|
|
3537
|
-
}
|
|
5206
|
+
if (this._sealedRoots === null) this._teardownRootHandles();
|
|
3538
5207
|
this._rootHandles.clear();
|
|
3539
5208
|
this._orderedMapHandles.clear();
|
|
3540
5209
|
// Free a live historical-checkout preview: it is a distinct LoroDoc
|
|
@@ -3543,7 +5212,7 @@ export class Document {
|
|
|
3543
5212
|
// mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
|
|
3544
5213
|
// has `previewDoc === _doc`, where `_doc.free()` below already covers it
|
|
3545
5214
|
// and an unguarded free would double-free.
|
|
3546
|
-
if (this._checkout && this._checkout.previewDoc !== this.
|
|
5215
|
+
if (this._checkout && this._checkout.previewDoc !== this._engine) {
|
|
3547
5216
|
try {
|
|
3548
5217
|
this._checkout.previewDoc.free();
|
|
3549
5218
|
} catch {
|
|
@@ -3554,6 +5223,6 @@ export class Document {
|
|
|
3554
5223
|
// Loro's WASM has a `free()` to drop the heap-allocated doc state.
|
|
3555
5224
|
// Calling it after `close()` invalidates the doc, which is what we
|
|
3556
5225
|
// want; future mutate/subscribe calls throw above.
|
|
3557
|
-
this.
|
|
5226
|
+
this._engine?.free();
|
|
3558
5227
|
}
|
|
3559
5228
|
}
|