@brass-build/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/src/documents.ts
ADDED
|
@@ -0,0 +1,2426 @@
|
|
|
1
|
+
// SDK surface: `Brass.Document` plain-object proxy over Loro.
|
|
2
|
+
//
|
|
3
|
+
// Two load-bearing Loro gotchas:
|
|
4
|
+
// 1. `LoroDoc.subscribe(fn)` fires synchronously on every import,
|
|
5
|
+
// including the initial snapshot import (8.5 s on a 50k snapshot).
|
|
6
|
+
// So lazy-attach the Loro subscriber only on first `doc.subscribe`
|
|
7
|
+
// (see `_ensureLoroSubscribed`).
|
|
8
|
+
// 2. The `getOwnPropertyDescriptor` trap participates in
|
|
9
|
+
// `Object.keys`; doing `loroMap.keys().includes(key)` there makes
|
|
10
|
+
// `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
LoroDoc,
|
|
14
|
+
LoroMap,
|
|
15
|
+
type PeerID,
|
|
16
|
+
VersionVector,
|
|
17
|
+
decodeImportBlobMeta,
|
|
18
|
+
} from 'loro-crdt';
|
|
19
|
+
|
|
20
|
+
import { MutateBatcher } from './mutate-batching.js';
|
|
21
|
+
import {
|
|
22
|
+
UndoController,
|
|
23
|
+
type UndoControllerOptions,
|
|
24
|
+
} from './undo.js';
|
|
25
|
+
import {
|
|
26
|
+
createOrderedMapHandle,
|
|
27
|
+
createRootListHandle,
|
|
28
|
+
createRootMapHandle,
|
|
29
|
+
rebindRootListAnchor,
|
|
30
|
+
rebindRootMapAnchor,
|
|
31
|
+
resyncRootHandle,
|
|
32
|
+
teardownRootHandle,
|
|
33
|
+
wrapLoroMapProxy,
|
|
34
|
+
type ChangeSource,
|
|
35
|
+
type HandleOwner,
|
|
36
|
+
type ListHandle,
|
|
37
|
+
type MapHandle,
|
|
38
|
+
type OrderedMapHandle,
|
|
39
|
+
} from './handles.js';
|
|
40
|
+
import {
|
|
41
|
+
CURRENT_PAGING_SCHEME,
|
|
42
|
+
isPagedByPageCount,
|
|
43
|
+
META_KEY,
|
|
44
|
+
PAGE_COUNT_KEY,
|
|
45
|
+
PAGING_SCHEME_KEY,
|
|
46
|
+
RECORDS_KEY,
|
|
47
|
+
} from './record-paging.js';
|
|
48
|
+
import type {
|
|
49
|
+
BranchSummary,
|
|
50
|
+
DocumentCheckpoint,
|
|
51
|
+
DocumentSourceBlob,
|
|
52
|
+
DocumentStreamInfo,
|
|
53
|
+
PushToDriveInput,
|
|
54
|
+
PushToDriveResult,
|
|
55
|
+
} from './documents-namespace.js';
|
|
56
|
+
import type { DriveResolveResult } from './hosted-ui.js';
|
|
57
|
+
import {
|
|
58
|
+
GoneSignal,
|
|
59
|
+
WorkStatusSignal,
|
|
60
|
+
DriveStatusSignal,
|
|
61
|
+
type DocumentGoneListener,
|
|
62
|
+
type DocumentGoneReason,
|
|
63
|
+
type DocumentGoneUnsubscribe,
|
|
64
|
+
type DocumentStatusListener,
|
|
65
|
+
type DocumentStatusUnsubscribe,
|
|
66
|
+
type DocumentWorkStatus,
|
|
67
|
+
type DocumentDriveStatus,
|
|
68
|
+
type DocumentDriveStatusListener,
|
|
69
|
+
type DocumentDriveStatusUnsubscribe,
|
|
70
|
+
type DocumentDriveTrackingState,
|
|
71
|
+
type DriveMirrorDirection,
|
|
72
|
+
} from './document-signals.js';
|
|
73
|
+
import {
|
|
74
|
+
ThumbnailCapture,
|
|
75
|
+
type CanvasSource,
|
|
76
|
+
type ThumbnailUploader,
|
|
77
|
+
} from './document-thumbnail-capture.js';
|
|
78
|
+
import {
|
|
79
|
+
CheckpointScheduler,
|
|
80
|
+
type CheckpointCreator,
|
|
81
|
+
} from './document-checkpoints.js';
|
|
82
|
+
import {
|
|
83
|
+
BranchController,
|
|
84
|
+
type BranchProvider,
|
|
85
|
+
} from './document-branches.js';
|
|
86
|
+
import {
|
|
87
|
+
CommandMenu,
|
|
88
|
+
type Command,
|
|
89
|
+
type CommandGroup,
|
|
90
|
+
type CommandSurface,
|
|
91
|
+
type HostedUiProvider,
|
|
92
|
+
} from './document-commands.js';
|
|
93
|
+
import { DocumentStream, type StreamRuntime } from './document-streams.js';
|
|
94
|
+
import { createEmitter } from './emitter.js';
|
|
95
|
+
|
|
96
|
+
// The stream name `doc.stream()` resolves to the document's own default
|
|
97
|
+
// state; mirrors the server's reserved name.
|
|
98
|
+
export const DEFAULT_STREAM_NAME = 'default';
|
|
99
|
+
|
|
100
|
+
export type { BranchProvider };
|
|
101
|
+
|
|
102
|
+
export type {
|
|
103
|
+
Command,
|
|
104
|
+
CommandGroup,
|
|
105
|
+
CommandSurface,
|
|
106
|
+
HostedUiProvider,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type { CanvasSource };
|
|
110
|
+
|
|
111
|
+
export type {
|
|
112
|
+
DocumentGoneListener,
|
|
113
|
+
DocumentGoneReason,
|
|
114
|
+
DocumentGoneUnsubscribe,
|
|
115
|
+
DocumentStatusListener,
|
|
116
|
+
DocumentStatusUnsubscribe,
|
|
117
|
+
DocumentWorkStatus,
|
|
118
|
+
DocumentDriveStatus,
|
|
119
|
+
DocumentDriveStatusListener,
|
|
120
|
+
DocumentDriveStatusUnsubscribe,
|
|
121
|
+
DocumentDriveTrackingState,
|
|
122
|
+
DriveMirrorDirection,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The proxy callback receives an object with the root map (`entities`)
|
|
126
|
+
// surfaced as a plain JS proxy. Mutating it records Loro ops; the SDK
|
|
127
|
+
// commits them on return from `fn`. `E` is the app's entity shape,
|
|
128
|
+
// defaulting to the untyped `EntityProxy`:
|
|
129
|
+
//
|
|
130
|
+
// doc.mutate<MyEntity>((d) => {
|
|
131
|
+
// d.entities[id] = myTypedEntity; // no cast needed
|
|
132
|
+
// });
|
|
133
|
+
//
|
|
134
|
+
// The `extends object` constraint (not `Record<string, unknown>`) lets
|
|
135
|
+
// closed interfaces through: TS does not infer an index signature for a
|
|
136
|
+
// closed interface, so the stricter constraint would force a cast at
|
|
137
|
+
// every mutate site.
|
|
138
|
+
export interface DocumentMutator<E extends object = EntityProxy> {
|
|
139
|
+
entities: Record<string, E>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// What the entity proxy presents by default: any nested plain object
|
|
143
|
+
// shape backed by a LoroMap / LoroList container tree. `Record<string,
|
|
144
|
+
// unknown>` because Loro's value type is dynamic; typed apps pass their
|
|
145
|
+
// shape to `DocumentMutator<E>` / `doc.mutate<E>` instead.
|
|
146
|
+
export type EntityProxy = Record<string, unknown>;
|
|
147
|
+
|
|
148
|
+
export type DocumentSubscriber = (event: unknown) => void;
|
|
149
|
+
export type DocumentUnsubscribe = () => void;
|
|
150
|
+
|
|
151
|
+
// Discriminated event the SDK fires through `doc.subscribe` for
|
|
152
|
+
// non-CRDT changes (checkout, return-to-head, saving count). Apps can
|
|
153
|
+
// ignore the event arg and re-read in the handler, or narrow on
|
|
154
|
+
// `event.kind`. CRDT-origin events keep their Loro shape (also
|
|
155
|
+
// delivered through the same `subscribe`).
|
|
156
|
+
export type SyntheticSubscribeEvent =
|
|
157
|
+
| { kind: 'checkout'; version: number }
|
|
158
|
+
| { kind: 'return-to-head' }
|
|
159
|
+
// A bare re-read tick: the document's branch state settled or changed
|
|
160
|
+
// (the list loaded on open, or a switch/create landed). Carries no
|
|
161
|
+
// payload; the SDK's own chrome (the toolbar branch chip, the page
|
|
162
|
+
// title) re-reads the current branch name, and an app never needs the
|
|
163
|
+
// branch model, management is hosted.
|
|
164
|
+
| { kind: 'branches' }
|
|
165
|
+
| { kind: 'saving-count' }
|
|
166
|
+
| { kind: 'commands' };
|
|
167
|
+
|
|
168
|
+
// Snapshot of the Document's undo/redo state, pushed to
|
|
169
|
+
// `Document.subscribeUndoState` subscribers on every flip.
|
|
170
|
+
export interface UndoState {
|
|
171
|
+
canUndo: boolean;
|
|
172
|
+
canRedo: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type UndoStateSubscriber = (state: UndoState) => void;
|
|
176
|
+
|
|
177
|
+
// Commit-origin tags stamped on every locally-produced commit via Loro's
|
|
178
|
+
// `setNextCommitOrigin`, so `origin.source` can be recovered off the
|
|
179
|
+
// event. Stamping the commit (not a JS-side flag) survives a
|
|
180
|
+
// microtask-deferred subscribe callback: the tag travels with the event.
|
|
181
|
+
// `mutate()` carries `MUTATE`; `undo()`/`redo()` stamp their own tag
|
|
182
|
+
// before driving the UndoManager so they read back as `'undo'`/`'redo'`
|
|
183
|
+
// not `'local'`. A remote op (`by === 'import'`) and a checkout
|
|
184
|
+
// (`by === 'checkout'`) need no tag.
|
|
185
|
+
const MUTATE_COMMIT_ORIGIN = 'brass.mutate';
|
|
186
|
+
const UNDO_COMMIT_ORIGIN = 'brass.undo';
|
|
187
|
+
const REDO_COMMIT_ORIGIN = 'brass.redo';
|
|
188
|
+
|
|
189
|
+
// Classify a Loro event by what produced it. The render path never calls
|
|
190
|
+
// this (it reconciles by value); it backs a change's `origin.source` for
|
|
191
|
+
// attribution and the SDK's own local-vs-remote side-effect gating
|
|
192
|
+
// (thumbnail capture, auto-checkpoint).
|
|
193
|
+
function changeSourceOf(event: unknown): ChangeSource {
|
|
194
|
+
const by = (event as { by?: unknown }).by;
|
|
195
|
+
if (by === 'import') return 'remote';
|
|
196
|
+
if (by === 'checkout') return 'checkout';
|
|
197
|
+
// by === 'local': disambiguate via the commit-origin tag.
|
|
198
|
+
const origin = (event as { origin?: unknown }).origin;
|
|
199
|
+
if (origin === UNDO_COMMIT_ORIGIN) return 'undo';
|
|
200
|
+
if (origin === REDO_COMMIT_ORIGIN) return 'redo';
|
|
201
|
+
return 'local';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Per-chunk cap on a resync-push blob (durability handshake). Each push
|
|
205
|
+
// ships as one op frame, and the transport carries 128 KB; the blob is
|
|
206
|
+
// base64-encoded (~1.33x) inside a JSON envelope, so the raw blob must
|
|
207
|
+
// stay well under that. 64 KB base64s to ~85 KB. A larger missing slice
|
|
208
|
+
// is split into multiple chunks (see `computeResyncPush`), each capped.
|
|
209
|
+
const RESYNC_PUSH_MAX_BYTES = 64 * 1024;
|
|
210
|
+
|
|
211
|
+
// Hard ceiling for a single irreducible op (one Loro op, unsplittable).
|
|
212
|
+
// Sized so the base64 frame still clears the 128 KB limit (90 KB base64s
|
|
213
|
+
// to ~120 KB plus envelope). An op this large cannot occur on a doc
|
|
214
|
+
// whose normal edits send at all (same frame limit), so this only guards
|
|
215
|
+
// a pathological op; it is skipped with a warning rather than retried
|
|
216
|
+
// forever, while the doc's other chunks still heal.
|
|
217
|
+
const RESYNC_PUSH_HARD_MAX_BYTES = 90 * 1024;
|
|
218
|
+
|
|
219
|
+
// Element-wise max of two Loro version vectors. `VersionVector` exposes
|
|
220
|
+
// no public join, so round-trip through its `Map<PeerID, number>` JSON
|
|
221
|
+
// form. Counters are exclusive ends, so the larger is more advanced.
|
|
222
|
+
// Used by the durability handshake to fold the durable tail into the
|
|
223
|
+
// snapshot's version vector.
|
|
224
|
+
function mergeVersionVectors(
|
|
225
|
+
a: VersionVector,
|
|
226
|
+
b: VersionVector,
|
|
227
|
+
): VersionVector {
|
|
228
|
+
const merged = a.toJSON();
|
|
229
|
+
for (const [peer, counter] of b.toJSON()) {
|
|
230
|
+
const current = merged.get(peer);
|
|
231
|
+
if (current === undefined || counter > current) merged.set(peer, counter);
|
|
232
|
+
}
|
|
233
|
+
return VersionVector.parseJSON(merged);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Split half-open per-peer counter ranges into two halves by total op
|
|
237
|
+
// count, splitting at most one range across the boundary. `total` must
|
|
238
|
+
// be >= 2 so both halves are non-empty (the caller handles total === 1
|
|
239
|
+
// as the irreducible single-op case). Order is preserved.
|
|
240
|
+
function splitRangesByLength(
|
|
241
|
+
ranges: { peer: PeerID; from: number; to: number }[],
|
|
242
|
+
total: number,
|
|
243
|
+
): [
|
|
244
|
+
{ peer: PeerID; from: number; to: number }[],
|
|
245
|
+
{ peer: PeerID; from: number; to: number }[],
|
|
246
|
+
] {
|
|
247
|
+
const half = Math.floor(total / 2);
|
|
248
|
+
const first: { peer: PeerID; from: number; to: number }[] = [];
|
|
249
|
+
const second: { peer: PeerID; from: number; to: number }[] = [];
|
|
250
|
+
let acc = 0;
|
|
251
|
+
for (const r of ranges) {
|
|
252
|
+
const len = r.to - r.from;
|
|
253
|
+
if (acc >= half) {
|
|
254
|
+
second.push(r);
|
|
255
|
+
} else if (acc + len <= half) {
|
|
256
|
+
first.push(r);
|
|
257
|
+
acc += len;
|
|
258
|
+
} else {
|
|
259
|
+
const splitAt = r.from + (half - acc);
|
|
260
|
+
first.push({ peer: r.peer, from: r.from, to: splitAt });
|
|
261
|
+
second.push({ peer: r.peer, from: splitAt, to: r.to });
|
|
262
|
+
acc = half;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return [first, second];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The record store's page count off a doc's `meta` map, or 0 when
|
|
269
|
+
// unpaged. `handles.ts` reads it to resolve which page a key hashes to
|
|
270
|
+
// AND to decide whether the store is paged at all (on every root-map
|
|
271
|
+
// read / edit). Read by PATH, not `getMap(META_KEY)`: `getMap` is
|
|
272
|
+
// get-or-create and would materialize an empty `meta` root on every
|
|
273
|
+
// unpaged document, polluting its wire shape; `getByPath` is a pure read
|
|
274
|
+
// returning undefined when `meta` does not exist.
|
|
275
|
+
function readStorePageCount(source: LoroDoc): number {
|
|
276
|
+
const pc = source.getByPath(`${META_KEY}/${PAGE_COUNT_KEY}`);
|
|
277
|
+
// Same "is paged" predicate as the formats/core readers, so the SDK
|
|
278
|
+
// handle path and the store readers cannot disagree on a malformed
|
|
279
|
+
// count (e.g. a non-finite value must read as unpaged everywhere).
|
|
280
|
+
return isPagedByPageCount(pc) ? (pc as number) : 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The paging scheme a paged store was written under, off the same `meta` map
|
|
284
|
+
// as the page count (read by path, not `getMap`, for the same reason: a pure
|
|
285
|
+
// read that never materializes an empty `meta` root). A paged store predating
|
|
286
|
+
// the stamp reports the current scheme, so it stays editable; a store written
|
|
287
|
+
// under a newer scheme reports that number, and the edit path refuses it
|
|
288
|
+
// rather than hashing a key into the wrong page.
|
|
289
|
+
function readStorePagingScheme(source: LoroDoc): number {
|
|
290
|
+
const s = source.getByPath(`${META_KEY}/${PAGING_SCHEME_KEY}`);
|
|
291
|
+
return typeof s === 'number' && s > 0 ? s : CURRENT_PAGING_SCHEME;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface DocumentOptions {
|
|
295
|
+
// Root key the Document wraps. Defaults to `"entities"` to match the
|
|
296
|
+
// wire protocol's top-level shape.
|
|
297
|
+
rootKey?: string;
|
|
298
|
+
// The server-minted `doc_id`. Set by the SDK's open / create paths so
|
|
299
|
+
// callers read `doc.id` to track the active doc. `null` on
|
|
300
|
+
// direct-construction (`Document.create()` / `openFromSnapshot`),
|
|
301
|
+
// which only happens in tests and offline-only flows.
|
|
302
|
+
docId?: string;
|
|
303
|
+
// The branch this Document is open on. Surfaced as `doc.branchId` and
|
|
304
|
+
// passed to `createBranch` as the fork source. `null` on the
|
|
305
|
+
// offline / test path.
|
|
306
|
+
branchId?: string;
|
|
307
|
+
// True when opened at a historical version. A read-only Document
|
|
308
|
+
// throws from `mutate` and carries no live WS transport.
|
|
309
|
+
readOnly?: boolean;
|
|
310
|
+
// The historical version materialized at, when opened via `{ version
|
|
311
|
+
// }`; `null` for a live (head) open. The server's *effective* version,
|
|
312
|
+
// which may round down when compaction has TTL'd the exact op.
|
|
313
|
+
version?: number;
|
|
314
|
+
// Per-document undo/redo controller config (max retained steps).
|
|
315
|
+
undo?: UndoControllerOptions;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Hook called on every locally-generated Loro update.
|
|
319
|
+
// `Brass.openDocumentById` installs it to ship op blobs over WS; without
|
|
320
|
+
// it, `mutate` still mutates local CRDT state (offline / tests /
|
|
321
|
+
// migration) but nothing leaves the process. The returned `clientOpId`
|
|
322
|
+
// correlates the eventual `op_ack` with the originating mutation (for
|
|
323
|
+
// optimistic-UI pending markers).
|
|
324
|
+
type LocalOpSendHook = (opBlob: Uint8Array) => string;
|
|
325
|
+
|
|
326
|
+
// Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
|
|
327
|
+
// toJSON / close. Construction is cheap: the Loro subscriber attaches
|
|
328
|
+
// lazily (see `_ensureLoroSubscribed`).
|
|
329
|
+
export class Document {
|
|
330
|
+
private readonly _doc: LoroDoc;
|
|
331
|
+
private readonly _rootKey: string;
|
|
332
|
+
// Server-minted `doc_id`, or `null` when constructed without one
|
|
333
|
+
// (test / offline path).
|
|
334
|
+
private readonly _docId: string | null;
|
|
335
|
+
private readonly _branchId: string | null;
|
|
336
|
+
// Active checkout state. `null` means the Document is on the live
|
|
337
|
+
// head of `_branchId`: edits are accepted, the WS stream applies
|
|
338
|
+
// remote ops directly to `_doc`. A non-null state means a historical
|
|
339
|
+
// version is being previewed: `_checkout.previewDoc` holds the
|
|
340
|
+
// materialised state at that version, reads go through it, and
|
|
341
|
+
// `mutate` throws. The LIVE `_doc` keeps receiving ops in the
|
|
342
|
+
// background; returning to head just clears the checkout state and
|
|
343
|
+
// the live state is already current.
|
|
344
|
+
private _checkout: {
|
|
345
|
+
previewDoc: LoroDoc;
|
|
346
|
+
previewRoot: LoroMap;
|
|
347
|
+
version: number;
|
|
348
|
+
} | null;
|
|
349
|
+
// Bumped by every `_checkoutVersion` (before its await) and every
|
|
350
|
+
// `returnToHead`. A `_checkoutVersion` reads its token before awaiting the
|
|
351
|
+
// fetch and bails after if the token moved, so a return-to-live or a newer
|
|
352
|
+
// checkout issued during the fetch window wins instead of being overwritten
|
|
353
|
+
// when the stale fetch resolves. Without it, `returnToHead` sees
|
|
354
|
+
// `_checkout === null` mid-fetch, no-ops, and the resolving checkout strands
|
|
355
|
+
// the doc in the historical preview the user just left.
|
|
356
|
+
private _checkoutGeneration = 0;
|
|
357
|
+
// True for Documents that have no live WS channel: the cold-link
|
|
358
|
+
// `Brass.openDocumentById(id, { version })` shape. Such Documents
|
|
359
|
+
// are frozen at the constructor-time version; `_checkoutVersion`
|
|
360
|
+
// and `returnToHead` reject. The live + checkout-on-demand path
|
|
361
|
+
// leaves this `false`.
|
|
362
|
+
private readonly _isFrozen: boolean;
|
|
363
|
+
// Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
|
|
364
|
+
// is a version-pinned open with no live channel). The anonymous
|
|
365
|
+
// link-guest open sets this: the Document keeps a live WS subscription
|
|
366
|
+
// and applies incoming remote ops, but local `mutate` is rejected, so a
|
|
367
|
+
// guest sees edits stream in while being unable to write.
|
|
368
|
+
private readonly _forcedReadOnly: boolean;
|
|
369
|
+
private readonly _root: LoroMap;
|
|
370
|
+
private readonly _entities: EntityProxy;
|
|
371
|
+
// The UndoManager is constructed eagerly so the first call to
|
|
372
|
+
// `doc.mutate(...)` lands in the undo stack. Lazy construction
|
|
373
|
+
// (deferred to first `doc.undo()`) would miss every commit made
|
|
374
|
+
// before that point, which is not the behaviour callers expect.
|
|
375
|
+
// The UndoManager attaches its own `subscribeLocalUpdates` listener
|
|
376
|
+
// only; it does NOT fire on snapshot import, so the
|
|
377
|
+
// cold-load contract is unaffected (imports are not local commits).
|
|
378
|
+
private _undo: UndoController | null;
|
|
379
|
+
// One subscriber channel. Every event reaches every subscriber, whatever
|
|
380
|
+
// produced it (local mutate, remote import, undo, redo, checkout); the
|
|
381
|
+
// app reconciles by value and reads `origin.source` only when it has a
|
|
382
|
+
// non-render reason to know the cause. A throwing subscriber is swallowed
|
|
383
|
+
// so it cannot strand the others or the WS router.
|
|
384
|
+
private readonly _subs = createEmitter<unknown>({ onError: () => {} });
|
|
385
|
+
// Subscribers registered via `subscribeUndoState`. Fired after
|
|
386
|
+
// every underlying Loro event when `canUndo()` or `canRedo()`
|
|
387
|
+
// changed value vs the last fire, so UI bound to undo/redo
|
|
388
|
+
// enabled state stays current without the consumer caring
|
|
389
|
+
// whether the originating event was a local mutate, undo,
|
|
390
|
+
// redo, or remote import.
|
|
391
|
+
private readonly _undoStateSubs: Set<UndoStateSubscriber> = new Set();
|
|
392
|
+
private _lastCanUndo = false;
|
|
393
|
+
private _lastCanRedo = false;
|
|
394
|
+
private _unsubLoro: (() => void) | null = null;
|
|
395
|
+
private _unsubLocal: (() => void) | null = null;
|
|
396
|
+
// Local commits drive this hook with the resulting op blob; the hook
|
|
397
|
+
// returns a `clientOpId` tracked for `op_ack` correlation. In-flight
|
|
398
|
+
// op bytes (keyed by `clientOpId`) live in `_outstandingByClientId`;
|
|
399
|
+
// `applyRemoteOp` checks them to short-circuit self-originated
|
|
400
|
+
// round-trips.
|
|
401
|
+
private _localOpHook: LocalOpSendHook | null = null;
|
|
402
|
+
// Materialises a historical version against the server; returns the
|
|
403
|
+
// snapshot bytes (or null) + tail op blobs + effective version.
|
|
404
|
+
private _checkoutFetcher:
|
|
405
|
+
| ((
|
|
406
|
+
docId: string,
|
|
407
|
+
version: number,
|
|
408
|
+
branchId: string,
|
|
409
|
+
) => Promise<{
|
|
410
|
+
snapshotBytes: Uint8Array | null;
|
|
411
|
+
tailOpBlobs: Uint8Array[];
|
|
412
|
+
effectiveVersion: number;
|
|
413
|
+
// The per-stream seq vector at this version (from the newest
|
|
414
|
+
// checkpoint at or below it): `{ line_id: seq }`. Lets a preview
|
|
415
|
+
// re-materialize every opened stream at the same point in time.
|
|
416
|
+
// Absent for a stream line's own timeline (no checkpoints) and
|
|
417
|
+
// before any checkpoint recorded one.
|
|
418
|
+
streamSeqs?: Record<string, number>;
|
|
419
|
+
}>)
|
|
420
|
+
| null = null;
|
|
421
|
+
// The stream seq vector from the active preview, so a stream opened WHILE
|
|
422
|
+
// a preview is active can open at its historical seq too. Cleared on
|
|
423
|
+
// `returnToHead`.
|
|
424
|
+
private _checkoutStreamSeqs: Record<string, number> | null = null;
|
|
425
|
+
// Transport-provided cursor for the branch's live head seq. A
|
|
426
|
+
// provider (not a stored number) so the Document never has to be
|
|
427
|
+
// notified of every op. `null` on the offline / test path; a
|
|
428
|
+
// read-only historical open reports its frozen `version` instead.
|
|
429
|
+
private _currentSeqProvider: (() => number) | null = null;
|
|
430
|
+
private readonly _branchCtl = new BranchController({
|
|
431
|
+
docId: (): string | null => this._docId,
|
|
432
|
+
branchId: (): string | null => this._branchId,
|
|
433
|
+
isActive: (): boolean => this._isLive,
|
|
434
|
+
onBranchesChanged: (): void => {
|
|
435
|
+
this._fireSubscribers({ kind: 'branches' });
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
private readonly _thumbnails = new ThumbnailCapture({
|
|
439
|
+
isCaptureAllowed: (): boolean =>
|
|
440
|
+
this._isLive && this._checkout === null,
|
|
441
|
+
isAttached: (): boolean => this._isLive,
|
|
442
|
+
});
|
|
443
|
+
private readonly _checkpoints = new CheckpointScheduler({
|
|
444
|
+
docId: (): string | null => this._docId,
|
|
445
|
+
isAttached: (): boolean => this._isLive,
|
|
446
|
+
isAutoAllowed: (): boolean =>
|
|
447
|
+
this._isLive && this._checkout === null,
|
|
448
|
+
currentSeq: (): number | null => this.currentSeq,
|
|
449
|
+
sealUndoStep: (): void => {
|
|
450
|
+
if (this._undo) this._undo.sealStep();
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
// Drive push hook. `null` on the offline / test path; `pushToDrive`
|
|
454
|
+
// then throws with a clear message rather than silently dropping.
|
|
455
|
+
private _drivePusher:
|
|
456
|
+
| ((input: PushToDriveInput) => Promise<PushToDriveResult>)
|
|
457
|
+
| null = null;
|
|
458
|
+
// Drive divergence-resolution hook. Mirrors `_drivePusher`: `null` on
|
|
459
|
+
// the offline / test path, in which case `resolveDrive` throws with a
|
|
460
|
+
// clear message. Wired by `client.ts` to open the hosted resolve
|
|
461
|
+
// surface and route any new mirror doc through `openSiblingDoc`.
|
|
462
|
+
private _driveResolver: (() => Promise<DriveResolveResult | null>) | null =
|
|
463
|
+
null;
|
|
464
|
+
// Source-blob fetch hook. When supplied, `doc.sourceBlob()` routes
|
|
465
|
+
// through it (the namespace's `GET .../source-blob`). When absent
|
|
466
|
+
// (tests / offline), `sourceBlob` throws with a clear message rather
|
|
467
|
+
// than silently dropping. Wired by `client.ts` from
|
|
468
|
+
// `documents.sourceBlob`.
|
|
469
|
+
private _sourceBlobFetcher: (() => Promise<DocumentSourceBlob>) | null = null;
|
|
470
|
+
// Cached doc name. `null` on offline / test docs.
|
|
471
|
+
private _name: string | null = null;
|
|
472
|
+
// True once the reader has asked for the version-browsing surface without
|
|
473
|
+
// checking anything out (the hosted history dialog's browse-in-the-app
|
|
474
|
+
// outcome). The host's version bar latches on this as well as on a live
|
|
475
|
+
// checkout, so browsing can start at the live head.
|
|
476
|
+
private _versionBrowsing = false;
|
|
477
|
+
private readonly _commandMenu = new CommandMenu({
|
|
478
|
+
docId: (): string | null => this._docId,
|
|
479
|
+
branchId: (): string | null => this._branchId,
|
|
480
|
+
readOnly: (): boolean => this.readOnly,
|
|
481
|
+
isLive: (): boolean => this._isLive,
|
|
482
|
+
canUndo: (): boolean => this.canUndo(),
|
|
483
|
+
canRedo: (): boolean => this.canRedo(),
|
|
484
|
+
undo: (): void => {
|
|
485
|
+
this.undo();
|
|
486
|
+
},
|
|
487
|
+
redo: (): void => {
|
|
488
|
+
this.redo();
|
|
489
|
+
},
|
|
490
|
+
checkoutVersion: (version): Promise<void> => this._checkoutVersion(version),
|
|
491
|
+
browseVersions: (): void => {
|
|
492
|
+
this._setVersionBrowsing(true);
|
|
493
|
+
},
|
|
494
|
+
branches: (): readonly BranchSummary[] => this._branchCtl.branches,
|
|
495
|
+
hasBranchProvider: (): boolean => this._branchCtl.hasProvider,
|
|
496
|
+
createBranch: (input): Promise<BranchSummary> =>
|
|
497
|
+
this._branchCtl.create(input),
|
|
498
|
+
refreshBranches: (): Promise<void> => this._refreshBranches(),
|
|
499
|
+
setName: (name): void => this._setName(name),
|
|
500
|
+
onCommandsChanged: (): void => {
|
|
501
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
// The clientOpId of every local op that has not yet been acked. The
|
|
505
|
+
// open-document layer drains this on `op_ack`. Tracking is keyed by
|
|
506
|
+
// clientOpId, not op-blob bytes, because clientOpId is what the
|
|
507
|
+
// server echoes back; the blob is opaque to Brass.
|
|
508
|
+
private readonly _outstandingByClientId: Map<string, Uint8Array> = new Map();
|
|
509
|
+
// client_op_ids of the in-flight resync push (durability handshake). A
|
|
510
|
+
// push can be a single op or several chunks for a large divergence, so
|
|
511
|
+
// this is a set. The loop guard: while any chunk is unacked, the
|
|
512
|
+
// handshake skips a fresh push so a flurry of reconnects cannot re-push
|
|
513
|
+
// the same missing slice. Each id is removed by `resolveOutstandingOp`
|
|
514
|
+
// when its ack lands; the guard reopens once the set drains.
|
|
515
|
+
private readonly _resyncPushClientOpIds = new Set<string>();
|
|
516
|
+
// Async-error sink, shared with the built-in commands. `_failOutstandingOp`
|
|
517
|
+
// surfaces a terminally-rejected op here so a "could not be saved" outcome
|
|
518
|
+
// reaches the app instead of being a silent forever-retry.
|
|
519
|
+
private _errorReporter: ((err: Error) => void) | null = null;
|
|
520
|
+
private _closed = false;
|
|
521
|
+
// Set true when the Document is parked in the SDK's per-(docId, branchId)
|
|
522
|
+
// cache after an app-level `close()`. The underlying LoroDoc, UndoController,
|
|
523
|
+
// and WS subscription stay alive so the next `openDocumentById` for the
|
|
524
|
+
// same key returns this same instance with its undo stack intact. The
|
|
525
|
+
// user-facing API gates as if the Doc were closed: `mutate` throws, no
|
|
526
|
+
// events fire to app-side subscribers (`_subs`
|
|
527
|
+
// and the gone / work-status signal listeners are cleared on suspend so
|
|
528
|
+
// a stale handle from a prior session does not receive events from the
|
|
529
|
+
// next session). `_resume()`
|
|
530
|
+
// flips the flag back when the SDK hands the Doc to a new open() call.
|
|
531
|
+
private _suspended = false;
|
|
532
|
+
// Live = neither closed nor parked. This is the public-surface gate;
|
|
533
|
+
// internal ingress (remote ops, status emits) deliberately checks
|
|
534
|
+
// `_closed` alone, because a parked doc keeps applying remote state.
|
|
535
|
+
private get _isLive(): boolean {
|
|
536
|
+
return !this._closed && !this._suspended;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private _assertOpen(): void {
|
|
540
|
+
if (!this._isLive) throw new Error('Document is closed');
|
|
541
|
+
}
|
|
542
|
+
// Server-pushed lifecycle signals: `_gone` is fire-once-then-cache
|
|
543
|
+
// for `doc_gone` eviction; `_workStatus` is latest-retained,
|
|
544
|
+
// re-emit-per-phase for `doc_status`.
|
|
545
|
+
private readonly _gone = new GoneSignal();
|
|
546
|
+
private readonly _workStatus = new WorkStatusSignal();
|
|
547
|
+
// Drive sync state, latest-retained. `null` until the open factory
|
|
548
|
+
// seeds it (drive-linked docs only) and on docs with no drive link.
|
|
549
|
+
private readonly _driveStatus = new DriveStatusSignal();
|
|
550
|
+
|
|
551
|
+
// Document-scoped presence. `null` until the WS transport is wired
|
|
552
|
+
// on, and on the offline / test path where there is no transport.
|
|
553
|
+
presence: import('./presence.js').PresenceClient | null = null;
|
|
554
|
+
|
|
555
|
+
constructor(loroDoc: LoroDoc, options?: DocumentOptions) {
|
|
556
|
+
this._doc = loroDoc;
|
|
557
|
+
this._rootKey = options?.rootKey ?? 'entities';
|
|
558
|
+
this._docId = options?.docId ?? null;
|
|
559
|
+
this._branchId = options?.branchId ?? null;
|
|
560
|
+
// A Document constructed with a `version` is a "frozen" cold-link
|
|
561
|
+
// open: the SDK materialised the historical state directly into
|
|
562
|
+
// `loroDoc` and there is no live WS channel. The Document is
|
|
563
|
+
// permanently in checkout against itself; `returnToHead` /
|
|
564
|
+
// `_checkoutVersion` are not meaningful on this shape.
|
|
565
|
+
const initialVersion = options?.version ?? null;
|
|
566
|
+
if (initialVersion !== null) {
|
|
567
|
+
this._isFrozen = true;
|
|
568
|
+
this._checkout = {
|
|
569
|
+
previewDoc: loroDoc,
|
|
570
|
+
previewRoot: loroDoc.getMap(this._rootKey),
|
|
571
|
+
version: initialVersion,
|
|
572
|
+
};
|
|
573
|
+
} else {
|
|
574
|
+
this._isFrozen = false;
|
|
575
|
+
this._checkout = null;
|
|
576
|
+
}
|
|
577
|
+
// `readOnly` on a live (non-version) open is a forced read-only: the
|
|
578
|
+
// guest link view. A version open is already frozen, so it does not
|
|
579
|
+
// also need the forced flag.
|
|
580
|
+
this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
|
|
581
|
+
this._root = loroDoc.getMap(this._rootKey);
|
|
582
|
+
this._entities = wrapLoroMapProxy(this._root) as EntityProxy;
|
|
583
|
+
this._undo = new UndoController(this._doc, options?.undo);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ---- Typed container handles (the primary read/write/subscribe surface) --
|
|
587
|
+
//
|
|
588
|
+
// Handles scope reads, writes, and subscriptions to one container in
|
|
589
|
+
// the underlying CRDT doc. The README leads with this surface; the
|
|
590
|
+
// doc-wide `mutate` / `subscribe` / `toJSON` methods below stay as a
|
|
591
|
+
// simpler-but-slower fallback for tiny docs and one-liner integrations.
|
|
592
|
+
//
|
|
593
|
+
// Identity: handles are cached per (key, kind), so
|
|
594
|
+
// `doc.map('entities') === doc.map('entities')` and bindings to the
|
|
595
|
+
// handle compare stable across renders.
|
|
596
|
+
|
|
597
|
+
// Cached root handles, keyed by kind + key so `doc.map('x')` and
|
|
598
|
+
// `doc.list('x')` coexist. One map, not one per kind, so a version checkout
|
|
599
|
+
// rebinds and each suspend / close tears down every root handle in a single
|
|
600
|
+
// traversal.
|
|
601
|
+
private readonly _rootHandles = new Map<
|
|
602
|
+
string,
|
|
603
|
+
| { kind: 'map'; key: string; handle: MapHandle<unknown> }
|
|
604
|
+
| { kind: 'list'; key: string; handle: ListHandle<unknown> }
|
|
605
|
+
>();
|
|
606
|
+
|
|
607
|
+
// Get a handle for a top-level LoroMap at `key`. The container is
|
|
608
|
+
// materialized in the underlying LoroDoc immediately (Loro's
|
|
609
|
+
// `getMap` is "get or create"); mutations through the handle do
|
|
610
|
+
// not need to create the container themselves.
|
|
611
|
+
map<T = unknown>(key: string): MapHandle<T> {
|
|
612
|
+
this._assertOpen();
|
|
613
|
+
const cacheKey = `map:${key}`;
|
|
614
|
+
const existing = this._rootHandles.get(cacheKey);
|
|
615
|
+
if (existing) return existing.handle as unknown as MapHandle<T>;
|
|
616
|
+
// Source the LoroMap from the active source (live or checkout
|
|
617
|
+
// preview), same as `toJSON` does. Handles bound to a Document
|
|
618
|
+
// in checkout see the historical state via the preview doc.
|
|
619
|
+
const source =
|
|
620
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
621
|
+
const loroMap = source.getMap(key);
|
|
622
|
+
// The page-count provider lets a handle over a large record store's
|
|
623
|
+
// `instances` map present its page containers transparently as a
|
|
624
|
+
// flat record map. It resolves the ACTIVE source at call time (not
|
|
625
|
+
// the one captured at handle creation): a checkout rebinds the
|
|
626
|
+
// handle's anchor to the preview doc, and the page count must come
|
|
627
|
+
// from that same doc or keyed reads hash into the wrong page (and a
|
|
628
|
+
// closure pinned to a preview doc would read a freed LoroDoc after
|
|
629
|
+
// returnToHead).
|
|
630
|
+
//
|
|
631
|
+
// Only the record store's paged root (`instances`) may report a page
|
|
632
|
+
// count. `readStorePageCount` reads the document-global
|
|
633
|
+
// `meta.page_count`, so handing it to every root map would make the
|
|
634
|
+
// sibling record-store roots (`meta`, `header`) and any app-owned
|
|
635
|
+
// root map read as paged once `instances` pages, routing their
|
|
636
|
+
// `get`/`has`/`mutate` through the page-hash path against a map that
|
|
637
|
+
// holds no page containers (`get` returns undefined for present keys;
|
|
638
|
+
// `mutate` writes a stray page container into a plain map). A non-
|
|
639
|
+
// record-store key gets a null provider and stays unpaged.
|
|
640
|
+
const handle = createRootMapHandle<unknown>(
|
|
641
|
+
loroMap,
|
|
642
|
+
this._handleOwner,
|
|
643
|
+
key === RECORDS_KEY
|
|
644
|
+
? (): number =>
|
|
645
|
+
readStorePageCount(
|
|
646
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc,
|
|
647
|
+
)
|
|
648
|
+
: undefined,
|
|
649
|
+
key === RECORDS_KEY
|
|
650
|
+
? (): number =>
|
|
651
|
+
readStorePagingScheme(
|
|
652
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc,
|
|
653
|
+
)
|
|
654
|
+
: undefined,
|
|
655
|
+
);
|
|
656
|
+
this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
|
|
657
|
+
return handle as unknown as MapHandle<T>;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Get a handle for a top-level LoroList at `key`.
|
|
661
|
+
list<T = unknown>(key: string): ListHandle<T> {
|
|
662
|
+
this._assertOpen();
|
|
663
|
+
const cacheKey = `list:${key}`;
|
|
664
|
+
const existing = this._rootHandles.get(cacheKey);
|
|
665
|
+
if (existing) return existing.handle as unknown as ListHandle<T>;
|
|
666
|
+
const source =
|
|
667
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
668
|
+
const loroList = source.getList(key);
|
|
669
|
+
const handle = createRootListHandle<unknown>(loroList, this._handleOwner);
|
|
670
|
+
this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
|
|
671
|
+
return handle as unknown as ListHandle<T>;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Get an ordered-map handle binding a keyed map at `mapKey` to a sibling
|
|
675
|
+
// order list at `orderKey`: adds append to the order list, deletes prune
|
|
676
|
+
// it, both in one commit, so an app cannot leave the two out of step.
|
|
677
|
+
// The record store every imported document carries is reached as
|
|
678
|
+
// `doc.orderedMap('instances', 'instance_order')`; the primitive itself
|
|
679
|
+
// is format-neutral (two root containers, no record-store knowledge).
|
|
680
|
+
// Built over the same `map(mapKey)` / `list(orderKey)` handles, so the
|
|
681
|
+
// paged-store presentation and subscriptions carry through.
|
|
682
|
+
private readonly _orderedMapHandles = new Map<
|
|
683
|
+
string,
|
|
684
|
+
OrderedMapHandle<unknown>
|
|
685
|
+
>();
|
|
686
|
+
orderedMap<T = unknown>(
|
|
687
|
+
mapKey: string,
|
|
688
|
+
orderKey: string,
|
|
689
|
+
): OrderedMapHandle<T> {
|
|
690
|
+
this._assertOpen();
|
|
691
|
+
const cacheKey = `${mapKey}\u0000${orderKey}`;
|
|
692
|
+
let h = this._orderedMapHandles.get(cacheKey);
|
|
693
|
+
if (!h) {
|
|
694
|
+
h = createOrderedMapHandle<unknown>(
|
|
695
|
+
this.map(mapKey),
|
|
696
|
+
this.list<string>(orderKey),
|
|
697
|
+
this._handleOwner,
|
|
698
|
+
);
|
|
699
|
+
this._orderedMapHandles.set(cacheKey, h);
|
|
700
|
+
}
|
|
701
|
+
return h as unknown as OrderedMapHandle<T>;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Stable handle-owner closure: handles call back into this to
|
|
705
|
+
// participate in the standard commit + origin machinery. The bind
|
|
706
|
+
// happens once on first access; later mutations through any handle
|
|
707
|
+
// route through the same closure so the local-op hook, undo stack,
|
|
708
|
+
// and subscribe local-echo filter all fire correctly.
|
|
709
|
+
private _handleOwnerCache: HandleOwner | null = null;
|
|
710
|
+
private get _handleOwner(): HandleOwner {
|
|
711
|
+
if (this._handleOwnerCache) return this._handleOwnerCache;
|
|
712
|
+
this._handleOwnerCache = {
|
|
713
|
+
assertWritable: (): void => {
|
|
714
|
+
this._assertOpen();
|
|
715
|
+
if (this._checkout !== null) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
'Document is read-only: it is showing a historical version. ' +
|
|
718
|
+
'Call `doc.returnToHead()` to edit.',
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (this._forcedReadOnly) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
'Document is read-only: it was opened via a view-only share link.',
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (this._isLoadLocked()) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
'Document is still loading its content; wait for the load to ' +
|
|
729
|
+
'finish before editing (see `doc.onStatus`).',
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
ensureLocalUpdatesSubscribed: (): void => {
|
|
734
|
+
this._ensureLocalUpdatesSubscribed();
|
|
735
|
+
},
|
|
736
|
+
commitMutate: (): void => {
|
|
737
|
+
this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
|
|
738
|
+
this._doc.commit();
|
|
739
|
+
},
|
|
740
|
+
originOf: (event: unknown): ChangeSource => changeSourceOf(event),
|
|
741
|
+
};
|
|
742
|
+
return this._handleOwnerCache;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Server-minted `doc_id`. Always a string for a Document returned
|
|
746
|
+
// from `Brass.openDocumentById` / `Brass.createDocument`; `null`
|
|
747
|
+
// only on the direct-construction (test / offline) path.
|
|
748
|
+
get id(): string | null {
|
|
749
|
+
return this._docId;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The branch this Document is open on, fixed for its lifetime (switching
|
|
754
|
+
* branches opens a new Document). `null` only on the offline / test path.
|
|
755
|
+
* The SDK reads it to scope WS / presence topics and thumbnail /
|
|
756
|
+
* checkpoint writes to the branch.
|
|
757
|
+
* @internal
|
|
758
|
+
*/
|
|
759
|
+
get branchId(): string | null {
|
|
760
|
+
return this._branchId;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// True when the Document is currently showing a historical version
|
|
764
|
+
// (a checkout): `mutate` throws and edit affordances should be
|
|
765
|
+
// disabled. Reactive: flips on `_checkoutVersion(v)` /
|
|
766
|
+
// `returnToHead()`. `subscribe` fires when this changes, so apps
|
|
767
|
+
// bind it directly inside the subscribe handler.
|
|
768
|
+
get readOnly(): boolean {
|
|
769
|
+
// A doc that is still loading its first content (a drive-linked
|
|
770
|
+
// stub hydrating on first open) is read-only until the bytes
|
|
771
|
+
// arrive: editing it would write ops onto an empty doc that the
|
|
772
|
+
// incoming snapshot is about to replace. The SDK reloads the
|
|
773
|
+
// content automatically when the work finishes (see open-document's
|
|
774
|
+
// `reloadAfterHydration`); until then mutation is rejected. This is
|
|
775
|
+
// also what stops an app's "seed an empty doc" path from clobbering
|
|
776
|
+
// a doc that only looks empty because it has not loaded yet.
|
|
777
|
+
return (
|
|
778
|
+
this._forcedReadOnly || this._checkout !== null || this._isLoadLocked()
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// True when content-replacing work (hydration / extraction) is pending
|
|
783
|
+
// and editing must be locked so incoming bytes do not clobber local
|
|
784
|
+
// ops. A pending PUSH is outbound (it writes the doc out to the drive,
|
|
785
|
+
// nothing replaces local content), so the server marks it
|
|
786
|
+
// `blocking: false` and the doc stays editable while the importer
|
|
787
|
+
// runs. An older server omits `blocking`; absence is treated as
|
|
788
|
+
// blocking so hydration stays locked.
|
|
789
|
+
private _isLoadLocked(): boolean {
|
|
790
|
+
const s = this._workStatus.status;
|
|
791
|
+
return s?.pending === true && s.blocking !== false;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* The historical version this Document shows, or `null` for a live
|
|
796
|
+
* (head) open. Reactive alongside `readOnly`. The number is the
|
|
797
|
+
* server's effective version, which can round down from the
|
|
798
|
+
* requested version when compaction has TTL'd the exact op.
|
|
799
|
+
*
|
|
800
|
+
* An app that needs to know the doc is not editable reads the public
|
|
801
|
+
* `doc.readOnly` (true for a preview, a view-only link, or a load lock).
|
|
802
|
+
* @internal
|
|
803
|
+
*/
|
|
804
|
+
get version(): number | null {
|
|
805
|
+
return this._checkout?.version ?? null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** @internal Whether the host should be showing its version-browsing
|
|
809
|
+
* surface for this document. True from a browse-in-the-app outcome and
|
|
810
|
+
* independent of `version`, so browsing can start at the live head; the
|
|
811
|
+
* host clears it when the reader closes that surface. */
|
|
812
|
+
get versionBrowsing(): boolean {
|
|
813
|
+
return this._versionBrowsing;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** @internal Set by the host (on close) and by the browse-in-the-app
|
|
817
|
+
* command. Fires the `commands` signal so a host rendering off
|
|
818
|
+
* subscription re-reads it. */
|
|
819
|
+
_setVersionBrowsing(on: boolean): void {
|
|
820
|
+
if (this._versionBrowsing === on) return;
|
|
821
|
+
this._versionBrowsing = on;
|
|
822
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** @internal The version this Document currently shows: the branch's live
|
|
826
|
+
* head seq for a head open (advanced by the WS op stream), or the frozen
|
|
827
|
+
* checkout version when a historical version is being previewed. The
|
|
828
|
+
* SDK's own anchors read it (a checkpoint seals at it, a thumbnail
|
|
829
|
+
* upload pins to it); no app-facing operation takes a seq, since version
|
|
830
|
+
* history is hosted chrome the app does not drive with numbers. */
|
|
831
|
+
get currentSeq(): number | null {
|
|
832
|
+
if (this._checkout !== null) return this._checkout.version;
|
|
833
|
+
if (this._currentSeqProvider) return this._currentSeqProvider();
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Open a Document from a Loro snapshot (the SDK join path). Attaches
|
|
839
|
+
* the Loro subscriber lazily, AFTER import: subscribing at
|
|
840
|
+
* construction costs ~8 s on a 50k snapshot.
|
|
841
|
+
* @internal
|
|
842
|
+
*/
|
|
843
|
+
static openFromSnapshot(
|
|
844
|
+
snapshot: Uint8Array,
|
|
845
|
+
options?: DocumentOptions,
|
|
846
|
+
): Document {
|
|
847
|
+
const loroDoc = new LoroDoc();
|
|
848
|
+
loroDoc.import(snapshot);
|
|
849
|
+
return new Document(loroDoc, options);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Construct an empty Document (empty-snapshot join case, and tests).
|
|
854
|
+
* @internal
|
|
855
|
+
*/
|
|
856
|
+
static create(options?: DocumentOptions): Document {
|
|
857
|
+
return new Document(new LoroDoc(), options);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Apply a remote op blob produced by another peer (delivered via the
|
|
861
|
+
// WS `doc_op` server frame). Idempotent: Loro's import
|
|
862
|
+
// path dedupes by lamport, so re-applying a self-originated op is
|
|
863
|
+
// a no-op in terms of state. We still avoid the import work entirely
|
|
864
|
+
// when the caller can prove the op originated locally (matched by
|
|
865
|
+
// `clientOpId`); that saves the WASM round-trip on every echo and
|
|
866
|
+
// avoids firing the user-side `subscribe(cb)` listeners twice for
|
|
867
|
+
// the same mutation (optimistic mutate already fired them).
|
|
868
|
+
/** @internal */
|
|
869
|
+
applyRemoteOp(opBlob: Uint8Array, opts?: { clientOpId?: string }): void {
|
|
870
|
+
// A closed Document has freed its LoroDoc; a late transport closure (a
|
|
871
|
+
// reconnect/resync/reload that resolved after `close()`) must not import
|
|
872
|
+
// into freed WASM. Guard on `_closed` only, NOT `_suspended`: a parked
|
|
873
|
+
// doc keeps its LoroDoc alive and still applies remote ops.
|
|
874
|
+
if (this._closed) return;
|
|
875
|
+
if (opts?.clientOpId && this._outstandingByClientId.has(opts.clientOpId)) {
|
|
876
|
+
// Echo of a local op: state is already up-to-date from the
|
|
877
|
+
// optimistic mutation; skip re-import to avoid double-fire on
|
|
878
|
+
// subscribers.
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
this._doc.import(opBlob);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Apply a snapshot blob over the top of an existing Document.
|
|
885
|
+
// Internal: the SDK calls this when the server delivers a fresh
|
|
886
|
+
// snapshot mid-session (e.g. after a compaction cycle). Persistence
|
|
887
|
+
// is server-authoritative, so there is no app-facing snapshot import.
|
|
888
|
+
/** @internal */
|
|
889
|
+
_applySnapshot(snapshot: Uint8Array): void {
|
|
890
|
+
// See `applyRemoteOp`: refuse the import on a closed (freed) doc so a late
|
|
891
|
+
// reload/resync closure can't touch freed WASM. `_closed` only; a parked
|
|
892
|
+
// doc still imports.
|
|
893
|
+
if (this._closed) return;
|
|
894
|
+
this._doc.import(snapshot);
|
|
895
|
+
// The undo stack pre-snapshot referenced ops the new snapshot
|
|
896
|
+
// supersedes. Drop both stacks so undo doesn't try to revert
|
|
897
|
+
// into a state that no longer exists; the user's mental model
|
|
898
|
+
// here is "we just loaded fresh state", and silently retaining
|
|
899
|
+
// a now-broken redo entry would be the worst of both worlds.
|
|
900
|
+
if (this._undo) this._undo.clear();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Revert the most recent local commit. Returns `true` if an undo
|
|
904
|
+
// step was applied, `false` if the stack is empty. Remote peers'
|
|
905
|
+
// edits are not in this stack: Loro's UndoManager is local-peer-
|
|
906
|
+
// scoped, which is the user-visible behaviour every collaborative
|
|
907
|
+
// editor lands on (you don't get to undo your collaborator's typing).
|
|
908
|
+
//
|
|
909
|
+
// The reverted change is itself a local commit, so it ships over
|
|
910
|
+
// the WS transport like any other mutation and other peers see the
|
|
911
|
+
// state revert.
|
|
912
|
+
undo(): boolean {
|
|
913
|
+
this._assertOpen();
|
|
914
|
+
// During a checkout the UndoManager still tracks the live head, but reads
|
|
915
|
+
// go through the preview. Undoing would commit against the live `_doc` and
|
|
916
|
+
// ship over the WS transport while the previewing user sees nothing, a
|
|
917
|
+
// silent live edit. Match `canUndo()` (false during a checkout) and no-op.
|
|
918
|
+
if (this._checkout !== null) return false;
|
|
919
|
+
// An undo produces a local commit exactly as `mutate` does, so it obeys
|
|
920
|
+
// the same read-only gates: a view-only guest link (`_forcedReadOnly`)
|
|
921
|
+
// and a pending content-replacing work-status (`_isLoadLocked()`), whose
|
|
922
|
+
// whole purpose is to keep local ops from racing the incoming bytes.
|
|
923
|
+
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
924
|
+
this._ensureLocalUpdatesSubscribed();
|
|
925
|
+
// Tag the commit the UndoManager is about to make so the resulting
|
|
926
|
+
// change reads back as `origin.source === 'undo'`, not `'local'`.
|
|
927
|
+
this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
|
|
928
|
+
return this._undo!.undo();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Re-apply the most recently undone local commit. Returns `true`
|
|
932
|
+
// if a redo step was applied, `false` if the stack is empty. The
|
|
933
|
+
// redo stack invalidates the moment a new local commit lands (the
|
|
934
|
+
// standard "fresh edit clears the redo branch" behaviour); remote
|
|
935
|
+
// edits do not clear redo, but Loro's UndoManager rebases redo
|
|
936
|
+
// entries against intervening remote state so the redone op
|
|
937
|
+
// composes correctly.
|
|
938
|
+
redo(): boolean {
|
|
939
|
+
this._assertOpen();
|
|
940
|
+
// No-op during a checkout, same reasoning as `undo()`.
|
|
941
|
+
if (this._checkout !== null) return false;
|
|
942
|
+
// Same read-only gates as `undo()` / `mutate()`.
|
|
943
|
+
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
944
|
+
this._ensureLocalUpdatesSubscribed();
|
|
945
|
+
this._doc.setNextCommitOrigin(REDO_COMMIT_ORIGIN);
|
|
946
|
+
return this._undo!.redo();
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Seal the open undo group locally, so the NEXT local commit starts a
|
|
951
|
+
* fresh undo step. The doc-level path seals through checkpoints
|
|
952
|
+
* (`createCheckpoint` / the auto-checkpoint debounce); this is the
|
|
953
|
+
* boundary primitive a stream scope exposes directly, since streams
|
|
954
|
+
* carry no checkpoint machinery of their own.
|
|
955
|
+
* @internal
|
|
956
|
+
*/
|
|
957
|
+
_sealUndoStep(): void {
|
|
958
|
+
if (this._undo) this._undo.sealStep();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Whether `undo()` would have an effect right now. Feeds the
|
|
962
|
+
// commands machinery and the undo-state notifier; apps consume it
|
|
963
|
+
// through `subscribeUndoState` snapshots.
|
|
964
|
+
private canUndo(): boolean {
|
|
965
|
+
if (this._closed) return false;
|
|
966
|
+
// During a checkout, the undo stack still tracks the live doc's
|
|
967
|
+
// history, but the UI is showing the historical preview and
|
|
968
|
+
// mutate is rejected. Surface that as "can't undo right now" so
|
|
969
|
+
// subscribeUndoState-bound buttons disable correctly.
|
|
970
|
+
if (this._checkout !== null) return false;
|
|
971
|
+
// Fold the same read-only gates `undo()` enforces so a
|
|
972
|
+
// subscribeUndoState-bound button disables while the doc is a view-only
|
|
973
|
+
// guest link or load-locked, rather than inviting an undo the call
|
|
974
|
+
// would refuse.
|
|
975
|
+
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
976
|
+
return this._undo!.canUndo();
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Whether `redo()` would have an effect right now. As above.
|
|
980
|
+
private canRedo(): boolean {
|
|
981
|
+
if (this._closed) return false;
|
|
982
|
+
if (this._checkout !== null) return false;
|
|
983
|
+
if (this._forcedReadOnly || this._isLoadLocked()) return false;
|
|
984
|
+
return this._undo!.canRedo();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// The plain-object proxy callers mutate. The mutation runs against
|
|
988
|
+
// the local LoroDoc immediately (optimistic UI), then commit + the
|
|
989
|
+
// `subscribeLocalUpdates` hook publish the resulting op blob over
|
|
990
|
+
// the WS transport when a `LocalOpSendHook` is installed.
|
|
991
|
+
//
|
|
992
|
+
// If no hook is installed (offline / migration script / unit tests),
|
|
993
|
+
// mutate still works but the op is not sent anywhere. This is the
|
|
994
|
+
// intentional escape hatch: `Brass.openDocumentById` wires the hook;
|
|
995
|
+
// `Document.create()` and direct construction stay
|
|
996
|
+
// hook-free so the class is usable without taking the WS dependency.
|
|
997
|
+
// `E` defaults to `EntityProxy` (the untyped Record<string, unknown>)
|
|
998
|
+
// for backwards-compat with existing callers. Apps with a typed
|
|
999
|
+
// entity shape pass it as a type argument:
|
|
1000
|
+
//
|
|
1001
|
+
// doc.mutate<MyEntity>((d) => {
|
|
1002
|
+
// d.entities[id] = myTypedEntity;
|
|
1003
|
+
// });
|
|
1004
|
+
//
|
|
1005
|
+
// The mutator's `entities` field has the same proxy under the hood
|
|
1006
|
+
// regardless of `E`; the generic exists purely to widen the type at
|
|
1007
|
+
// the assignment site so closed interfaces don't need an
|
|
1008
|
+
// `as Record<string, unknown>` cast.
|
|
1009
|
+
mutate<E extends object = EntityProxy>(
|
|
1010
|
+
fn: (d: DocumentMutator<E>) => void,
|
|
1011
|
+
): void {
|
|
1012
|
+
this._assertOpen();
|
|
1013
|
+
if (this._checkout !== null) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
'Document is read-only: it is showing a historical version. ' +
|
|
1016
|
+
'Call `doc.returnToHead()` to edit.',
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
// A view-only share-link (guest) open is a live head held read-only:
|
|
1020
|
+
// remote ops still stream in, but a local edit must be refused. The
|
|
1021
|
+
// `_checkout` guard above only covers the version-pinned case, so this
|
|
1022
|
+
// is a separate branch.
|
|
1023
|
+
if (this._forcedReadOnly) {
|
|
1024
|
+
throw new Error(
|
|
1025
|
+
'Document is read-only: it was opened via a view-only share link.',
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (this._isLoadLocked()) {
|
|
1029
|
+
throw new Error(
|
|
1030
|
+
'Document is still loading its content; wait for the load to ' +
|
|
1031
|
+
'finish before editing (see `doc.onStatus`).',
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
this._ensureLocalUpdatesSubscribed();
|
|
1035
|
+
// A fresh proxy for the call, because the batcher rides it: the
|
|
1036
|
+
// long-lived `this._entities` must stay batcher-free for the reads
|
|
1037
|
+
// that go through it outside a mutation.
|
|
1038
|
+
const batcher = new MutateBatcher(() => {
|
|
1039
|
+
this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
|
|
1040
|
+
this._doc.commit();
|
|
1041
|
+
});
|
|
1042
|
+
fn({
|
|
1043
|
+
entities: wrapLoroMapProxy(this._root, batcher) as unknown as Record<
|
|
1044
|
+
string,
|
|
1045
|
+
E
|
|
1046
|
+
>,
|
|
1047
|
+
});
|
|
1048
|
+
// Stamp the commit so `subscribe`'s default path can identify
|
|
1049
|
+
// and suppress the local-mutate echo. `setNextCommitOrigin`
|
|
1050
|
+
// applies to the very next commit, and `commit()` consumes it.
|
|
1051
|
+
this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
|
|
1052
|
+
this._doc.commit();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Install / replace the local-op send hook; `null` detaches it.
|
|
1056
|
+
//
|
|
1057
|
+
// The hook is invoked synchronously from Loro's
|
|
1058
|
+
// `subscribeLocalUpdates` callback, which fires immediately after
|
|
1059
|
+
// `commit()` returns. The hook must be safe to call multiple times
|
|
1060
|
+
// per `mutate()` call (a single mutation can produce multiple update
|
|
1061
|
+
// batches if Loro splits them); each call gets its own clientOpId so
|
|
1062
|
+
// the outstanding-op map stays correct.
|
|
1063
|
+
/** @internal */
|
|
1064
|
+
setLocalOpHook(hook: LocalOpSendHook | null): void {
|
|
1065
|
+
this._localOpHook = hook;
|
|
1066
|
+
// Detach the underlying Loro subscriber when the hook is removed
|
|
1067
|
+
// so a hook-free Document does not pay the per-commit callback
|
|
1068
|
+
// cost. `_ensureLocalUpdatesSubscribed` re-attaches lazily.
|
|
1069
|
+
if (!hook && this._unsubLocal) {
|
|
1070
|
+
this._unsubLocal();
|
|
1071
|
+
this._unsubLocal = null;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Install / replace the transport's live-head-seq provider; `null`
|
|
1076
|
+
// detaches it.
|
|
1077
|
+
/** @internal */
|
|
1078
|
+
setCurrentSeqProvider(provider: (() => number) | null): void {
|
|
1079
|
+
this._currentSeqProvider = provider;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Install the historical-materialise fetcher used by
|
|
1083
|
+
// `_checkoutVersion`. A Document without one rejects the checkout
|
|
1084
|
+
// with a clear error.
|
|
1085
|
+
/** @internal */
|
|
1086
|
+
setCheckoutFetcher(
|
|
1087
|
+
fetcher:
|
|
1088
|
+
| ((
|
|
1089
|
+
docId: string,
|
|
1090
|
+
version: number,
|
|
1091
|
+
branchId: string,
|
|
1092
|
+
) => Promise<{
|
|
1093
|
+
snapshotBytes: Uint8Array | null;
|
|
1094
|
+
tailOpBlobs: Uint8Array[];
|
|
1095
|
+
effectiveVersion: number;
|
|
1096
|
+
streamSeqs?: Record<string, number>;
|
|
1097
|
+
}>)
|
|
1098
|
+
| null,
|
|
1099
|
+
): void {
|
|
1100
|
+
this._checkoutFetcher = fetcher;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** @internal Whether a historical-materialise fetcher is installed, so a
|
|
1104
|
+
* stream can tell whether it can preview (a guest line has none). */
|
|
1105
|
+
_hasCheckoutFetcher(): boolean {
|
|
1106
|
+
return this._checkoutFetcher !== null;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** @internal Whether this Document is currently showing a historical
|
|
1110
|
+
* preview rather than its live head. */
|
|
1111
|
+
_isCheckedOut(): boolean {
|
|
1112
|
+
return this._checkout !== null;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Switch the Document to a historical version, in place. @internal
|
|
1116
|
+
// (the hosted `history` command drives this). After the promise
|
|
1117
|
+
// resolves, `doc.readOnly` is `true`,
|
|
1118
|
+
// `doc.version` is the server's effective version (which may round
|
|
1119
|
+
// down from the requested one when compaction has TTL'd the exact
|
|
1120
|
+
// op), and `mutate` throws. `subscribe` fires once after the swap so
|
|
1121
|
+
// the app's render loop redraws, and the app returns to live through
|
|
1122
|
+
// the public `returnToHead()`.
|
|
1123
|
+
//
|
|
1124
|
+
// The live WS stream keeps applying remote ops to the underlying
|
|
1125
|
+
// live Document in the background. `returnToHead()` clears the
|
|
1126
|
+
// checkout in O(1); the live state is already current.
|
|
1127
|
+
async _checkoutVersion(version: number): Promise<void> {
|
|
1128
|
+
this._assertOpen();
|
|
1129
|
+
if (this._isFrozen) {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
'_checkoutVersion: this Document was opened at a fixed version ' +
|
|
1132
|
+
'and has no live channel.',
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (!Number.isInteger(version) || version < 0) {
|
|
1136
|
+
throw new Error(`_checkoutVersion: invalid version ${version}`);
|
|
1137
|
+
}
|
|
1138
|
+
if (this._docId === null) {
|
|
1139
|
+
throw new Error('_checkoutVersion: Document has no server identity');
|
|
1140
|
+
}
|
|
1141
|
+
if (this._branchId === null) {
|
|
1142
|
+
throw new Error('_checkoutVersion: Document has no branch');
|
|
1143
|
+
}
|
|
1144
|
+
if (!this._checkoutFetcher) {
|
|
1145
|
+
throw new Error(
|
|
1146
|
+
'_checkoutVersion: no fetcher attached. Opened via the SDK factory?',
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const generation = ++this._checkoutGeneration;
|
|
1150
|
+
const { snapshotBytes, tailOpBlobs, effectiveVersion, streamSeqs } =
|
|
1151
|
+
await this._checkoutFetcher(this._docId, version, this._branchId);
|
|
1152
|
+
if (this._closed || this._suspended) return;
|
|
1153
|
+
// A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
|
|
1154
|
+
// was in flight. Honour the latest intent: drop this stale result rather
|
|
1155
|
+
// than clobbering the live head (or a newer checkout) the user asked for.
|
|
1156
|
+
if (generation !== this._checkoutGeneration) return;
|
|
1157
|
+
this._checkoutStreamSeqs = streamSeqs ?? {};
|
|
1158
|
+
// Materialise the preview into a fresh LoroDoc. The previous
|
|
1159
|
+
// checkout's preview LoroDoc, if any, is freed.
|
|
1160
|
+
const previewDoc = new LoroDoc();
|
|
1161
|
+
if (snapshotBytes !== null) previewDoc.import(snapshotBytes);
|
|
1162
|
+
for (const blob of tailOpBlobs) previewDoc.import(blob);
|
|
1163
|
+
const previewRoot = previewDoc.getMap(this._rootKey);
|
|
1164
|
+
const old = this._checkout;
|
|
1165
|
+
this._checkout = {
|
|
1166
|
+
previewDoc,
|
|
1167
|
+
previewRoot,
|
|
1168
|
+
version: effectiveVersion,
|
|
1169
|
+
};
|
|
1170
|
+
// Re-point the handles the app already holds at the preview doc and
|
|
1171
|
+
// redraw them: the active source changed from the live doc to the
|
|
1172
|
+
// preview, and an app renders off its `doc.map(key).subscribe(...)` /
|
|
1173
|
+
// `doc.orderedMap(...)` handle (the primary read surface). Rebinding
|
|
1174
|
+
// re-fires those subscribers with the historical container so the model
|
|
1175
|
+
// updates in place, the same way a remote op or undo would. `doc.map` /
|
|
1176
|
+
// `doc.list` reads follow the anchor, so they read the preview too.
|
|
1177
|
+
this._rebindRootHandles(previewDoc);
|
|
1178
|
+
// Free the previous preview only AFTER the rebind. `_rebindRootHandles`
|
|
1179
|
+
// runs each active subscriber's `detach()`, which unsubscribes against the
|
|
1180
|
+
// container captured at attach time (the OLD preview's); freeing first
|
|
1181
|
+
// would run that detach against a freed LoroDoc. This is the same
|
|
1182
|
+
// teardown-before-free order `close()` documents.
|
|
1183
|
+
if (old && old.previewDoc !== this._doc) {
|
|
1184
|
+
try {
|
|
1185
|
+
old.previewDoc.free();
|
|
1186
|
+
} catch {
|
|
1187
|
+
// ignore; already freed
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this._fireSubscribers({ kind: 'checkout', version: effectiveVersion });
|
|
1191
|
+
this._maybeFireUndoState();
|
|
1192
|
+
// Preview every opened stream at the same point in time, so a
|
|
1193
|
+
// partitioned document's history shows a consistent historical state
|
|
1194
|
+
// across streams rather than a historical default over live streams.
|
|
1195
|
+
await this._checkoutOpenStreams();
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Re-materialize each opened non-default stream at its seq recorded by the
|
|
1199
|
+
// active preview's checkpoint. A stream with no recorded seq at this
|
|
1200
|
+
// version (opened after the checkpoint, or before any checkpoint recorded
|
|
1201
|
+
// one) returns to its own head, so it shows its earliest-known rather than
|
|
1202
|
+
// a mismatched live state. Best-effort per stream: a stream whose inner
|
|
1203
|
+
// has no checkout fetcher (a guest line, which has no token to fetch
|
|
1204
|
+
// at-version) stays live. Failures are swallowed so one stream cannot
|
|
1205
|
+
// break the doc-level preview.
|
|
1206
|
+
private async _checkoutOpenStreams(): Promise<void> {
|
|
1207
|
+
const seqs = this._checkoutStreamSeqs ?? {};
|
|
1208
|
+
for (const [name, openPromise] of this._streamOpens) {
|
|
1209
|
+
try {
|
|
1210
|
+
const stream = await openPromise;
|
|
1211
|
+
const lineId = this._streamManifest?.get(name)?.line_id;
|
|
1212
|
+
const seq = lineId !== undefined ? seqs[lineId] : undefined;
|
|
1213
|
+
await stream._previewAt(seq);
|
|
1214
|
+
} catch {
|
|
1215
|
+
// A stream that failed to open or preview is left as-is.
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Return to the live head. After this resolves, `readOnly` is
|
|
1222
|
+
* `false`, `version` is `null`, `toJSON()` reads the live state,
|
|
1223
|
+
* and `mutate` is accepted again. The live state has been kept
|
|
1224
|
+
* current by the WS transport while the checkout was active, so
|
|
1225
|
+
* there is no fetch involved. `subscribe` fires once.
|
|
1226
|
+
*
|
|
1227
|
+
* Kept as a method (not `_`-prefixed) only so the version-preview banner
|
|
1228
|
+
* (`mountToolbar` / `mountHeader` / `mountVersionPreview`), which lives in
|
|
1229
|
+
* the same package, calls it by name.
|
|
1230
|
+
* @internal
|
|
1231
|
+
*/
|
|
1232
|
+
returnToHead(): void {
|
|
1233
|
+
this._assertOpen();
|
|
1234
|
+
if (this._isFrozen) {
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
'returnToHead: this Document was opened at a fixed version ' +
|
|
1237
|
+
'and has no live channel to return to.',
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
// Bump first, unconditionally: a checkout fetch may be in flight with
|
|
1241
|
+
// `_checkout` still null, and this is the signal that supersedes it when
|
|
1242
|
+
// it resolves.
|
|
1243
|
+
this._checkoutGeneration += 1;
|
|
1244
|
+
if (this._checkout === null) return;
|
|
1245
|
+
const old = this._checkout;
|
|
1246
|
+
this._checkout = null;
|
|
1247
|
+
// Re-point the app's handles back at the live doc and redraw them,
|
|
1248
|
+
// symmetrically with the checkout path, so the model returns to the live
|
|
1249
|
+
// state in place.
|
|
1250
|
+
this._rebindRootHandles(this._doc);
|
|
1251
|
+
// Free the preview only after the rebind: `_rebindRootHandles` detaches
|
|
1252
|
+
// each subscriber against the preview container it was attached to, so
|
|
1253
|
+
// freeing first would be a use-after-free (see `_checkoutVersion`).
|
|
1254
|
+
if (old.previewDoc !== this._doc) {
|
|
1255
|
+
try {
|
|
1256
|
+
old.previewDoc.free();
|
|
1257
|
+
} catch {
|
|
1258
|
+
// ignore
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
this._fireSubscribers({ kind: 'return-to-head' });
|
|
1262
|
+
this._maybeFireUndoState();
|
|
1263
|
+
// Return every opened stream to its live head too, symmetric with the
|
|
1264
|
+
// checkout path. Fire-and-forget (returnToHead is synchronous and has no
|
|
1265
|
+
// fetch): a stream not previewing is a no-op.
|
|
1266
|
+
this._checkoutStreamSeqs = null;
|
|
1267
|
+
for (const openPromise of this._streamOpens.values()) {
|
|
1268
|
+
void openPromise
|
|
1269
|
+
.then((stream) => stream._previewAt(undefined))
|
|
1270
|
+
.catch(() => {
|
|
1271
|
+
// A stream that failed to open has nothing to return.
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Re-point every cached root handle at the corresponding container in
|
|
1277
|
+
// `source` (the live doc or a checkout preview) and resync its subscribers.
|
|
1278
|
+
// The app holds these same handle objects, so this is what makes a version
|
|
1279
|
+
// checkout / return redraw the model without the app re-acquiring handles.
|
|
1280
|
+
// `getMap` / `getList` are get-or-create, so a container absent in the
|
|
1281
|
+
// preview resolves to an empty one and its subscribers redraw to empty.
|
|
1282
|
+
private _rebindRootHandles(source: LoroDoc): void {
|
|
1283
|
+
// Two passes, not one per handle: rebind every anchor to the new source
|
|
1284
|
+
// first, then resync. A composed handle can read a sibling root while
|
|
1285
|
+
// firing (the ordered map reads its order list during its map redraw), so
|
|
1286
|
+
// every anchor must already point at the new source before any handle
|
|
1287
|
+
// fires, or the redraw reads a half-swapped map/order pair.
|
|
1288
|
+
for (const entry of this._rootHandles.values()) {
|
|
1289
|
+
if (entry.kind === 'map') {
|
|
1290
|
+
rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
|
|
1291
|
+
} else {
|
|
1292
|
+
rebindRootListAnchor(entry.handle, source.getList(entry.key));
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
for (const entry of this._rootHandles.values()) {
|
|
1296
|
+
resyncRootHandle(entry.handle);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Fork a new branch from a historical version and switch to it. Drives
|
|
1302
|
+
* the hosted version-preview bar's "Branch from here".
|
|
1303
|
+
* @internal
|
|
1304
|
+
*/
|
|
1305
|
+
_forkFromVersion(fromVersion: number, name?: string): Promise<void> {
|
|
1306
|
+
return this._commandMenu.forkFromVersion(fromVersion, name);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ---------------------------------------------------------------------
|
|
1310
|
+
// Branches
|
|
1311
|
+
// ---------------------------------------------------------------------
|
|
1312
|
+
|
|
1313
|
+
// Internal: the current branch's display name when the doc is on a
|
|
1314
|
+
// NON-default branch, else null. The SDK's own chrome (the toolbar
|
|
1315
|
+
// branch chip, the page title) reads this to tell the user they are on
|
|
1316
|
+
// a branch. @internal
|
|
1317
|
+
_currentBranchName(): string | null {
|
|
1318
|
+
const branchId = this._branchId;
|
|
1319
|
+
const defaultBranchId = this._branchCtl.defaultBranchId;
|
|
1320
|
+
if (!branchId || !defaultBranchId || branchId === defaultBranchId) {
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
return (
|
|
1324
|
+
this._branchCtl.branches.find((b) => b.branch_id === branchId)?.name ??
|
|
1325
|
+
null
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Internal: the doc's default-branch id once the list has loaded, else
|
|
1330
|
+
// null. The session's URL sync reads this to keep `?branch=` off the
|
|
1331
|
+
// URL for the default branch. Not app-facing.
|
|
1332
|
+
/** @internal */
|
|
1333
|
+
_defaultBranchId(): string | null {
|
|
1334
|
+
return this._branchCtl.defaultBranchId;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Internal: set the branches list. Fires `doc.subscribe` if the
|
|
1338
|
+
// value changed.
|
|
1339
|
+
/** @internal */
|
|
1340
|
+
_setBranches(
|
|
1341
|
+
branches: readonly BranchSummary[],
|
|
1342
|
+
defaultBranchId: string | null,
|
|
1343
|
+
): void {
|
|
1344
|
+
this._branchCtl.set(branches, defaultBranchId);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Install the branch provider (HTTP capability); `null` detaches it.
|
|
1348
|
+
// Wired by the SDK factory; the hosted branch chrome reaches the wire
|
|
1349
|
+
// through this controller.
|
|
1350
|
+
/** @internal */
|
|
1351
|
+
setBranchProvider(provider: BranchProvider | null): void {
|
|
1352
|
+
this._branchCtl.setProvider(provider);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Internal: re-fetch the branches list. Called by the factory on open
|
|
1356
|
+
// (to populate the name the chip renders) and by the hosted branch
|
|
1357
|
+
// dialog after a mutation. Not app-facing.
|
|
1358
|
+
/** @internal */
|
|
1359
|
+
async _refreshBranches(): Promise<void> {
|
|
1360
|
+
return this._branchCtl.refresh();
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ---------------------------------------------------------------------
|
|
1364
|
+
// Thumbnail auto-capture
|
|
1365
|
+
// ---------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
// Register a canvas the SDK screenshots as the document's
|
|
1368
|
+
// thumbnail. Capture is debounced after each local edit; remote
|
|
1369
|
+
// edits and read-only / checkout state do not trigger a capture
|
|
1370
|
+
// (a viewer or a passive collaborator never re-uploads someone
|
|
1371
|
+
// else's frame). Pass the canvas element directly, or a getter
|
|
1372
|
+
// for apps that build / rebuild it lazily.
|
|
1373
|
+
//
|
|
1374
|
+
// Returns an unregister handle. Re-registering replaces; the
|
|
1375
|
+
// previous canvas is forgotten.
|
|
1376
|
+
useCanvas(source: CanvasSource): () => void {
|
|
1377
|
+
// Arm the lazy Loro subscriber so local-commit events reach the
|
|
1378
|
+
// capture collaborator's `schedule()`. Idempotent.
|
|
1379
|
+
this._ensureLoroSubscribed();
|
|
1380
|
+
return this._thumbnails.useCanvas(source);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Install the thumbnail upload hook; `null` detaches.
|
|
1384
|
+
/** @internal */
|
|
1385
|
+
setThumbnailUploader(uploader: ThumbnailUploader | null): void {
|
|
1386
|
+
this._thumbnails.setUploader(uploader);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Install the checkpoint-create hook; `null` detaches.
|
|
1390
|
+
/** @internal */
|
|
1391
|
+
setCheckpointCreator(creator: CheckpointCreator | null): void {
|
|
1392
|
+
this._checkpoints.setCreator(creator);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Install the drive-push hook; `null` detaches.
|
|
1396
|
+
/** @internal */
|
|
1397
|
+
setDrivePusher(
|
|
1398
|
+
pusher:
|
|
1399
|
+
| ((input: PushToDriveInput) => Promise<PushToDriveResult>)
|
|
1400
|
+
| null,
|
|
1401
|
+
): void {
|
|
1402
|
+
this._drivePusher = pusher;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Install the drive-resolve hook; `null` detaches. Mirrors
|
|
1406
|
+
// `setDrivePusher`.
|
|
1407
|
+
/** @internal */
|
|
1408
|
+
setDriveResolver(
|
|
1409
|
+
resolver: (() => Promise<DriveResolveResult | null>) | null,
|
|
1410
|
+
): void {
|
|
1411
|
+
this._driveResolver = resolver;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Install the source-blob fetch hook. Mirrors `setDrivePusher`.
|
|
1415
|
+
/** @internal */
|
|
1416
|
+
setSourceBlobFetcher(
|
|
1417
|
+
fetcher: (() => Promise<DocumentSourceBlob>) | null,
|
|
1418
|
+
): void {
|
|
1419
|
+
this._sourceBlobFetcher = fetcher;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Publish this document's current state back to its drive source.
|
|
1423
|
+
// Lets an app code one line on save / on a commit button to make its
|
|
1424
|
+
// edits visible to people working outside Brass on the same source:
|
|
1425
|
+
//
|
|
1426
|
+
// await doc.pushToDrive({ message: 'Wall move' });
|
|
1427
|
+
//
|
|
1428
|
+
// The platform publishes the doc's current state, overwriting the
|
|
1429
|
+
// source (unknown drive fields preserved), and marks its own write so
|
|
1430
|
+
// the inbound change-event drops it (no echo loop). It does not merge
|
|
1431
|
+
// concurrent drive-side edits. Throws on auth / link / write
|
|
1432
|
+
// failures; resolves with the new external ref and the published
|
|
1433
|
+
// entity count.
|
|
1434
|
+
async pushToDrive(
|
|
1435
|
+
input?: PushToDriveInput,
|
|
1436
|
+
): Promise<PushToDriveResult> {
|
|
1437
|
+
this._assertOpen();
|
|
1438
|
+
if (this._docId === null) {
|
|
1439
|
+
throw new Error('pushToDrive: Document has no server identity');
|
|
1440
|
+
}
|
|
1441
|
+
if (this._drivePusher === null) {
|
|
1442
|
+
throw new Error(
|
|
1443
|
+
'pushToDrive: no pusher attached. Opened via the SDK factory?',
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
return await this._drivePusher(input ?? {});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Resolve a `behind` / `diverged` drive-linked doc by opening the
|
|
1450
|
+
// hosted resolution surface, so an app can offer pull (take the source)
|
|
1451
|
+
// / keep both alongside the publish-over that `pushToDrive` does:
|
|
1452
|
+
//
|
|
1453
|
+
// if (doc.driveStatus?.state === 'diverged') await doc.resolveDrive();
|
|
1454
|
+
//
|
|
1455
|
+
// Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
|
|
1456
|
+
// takes over the drive link (take-source trashes this doc, keep-both
|
|
1457
|
+
// detaches it into a standalone fork). For a session-backed app the
|
|
1458
|
+
// SDK re-opens onto that new doc automatically (the resolver routes it
|
|
1459
|
+
// through the same `openSiblingDoc` path the history fork uses); the
|
|
1460
|
+
// returned `newDocId` lets a multi-doc app do its own switch. Resolves
|
|
1461
|
+
// with the outcome, or `null` when the user dismissed the dialog.
|
|
1462
|
+
// Throws when there is no doc identity or no resolver is attached.
|
|
1463
|
+
async resolveDrive(): Promise<DriveResolveResult | null> {
|
|
1464
|
+
this._assertOpen();
|
|
1465
|
+
if (this._docId === null) {
|
|
1466
|
+
throw new Error('resolveDrive: Document has no server identity');
|
|
1467
|
+
}
|
|
1468
|
+
if (this._driveResolver === null) {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
'resolveDrive: no resolver attached. Opened via the SDK factory?',
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
return await this._driveResolver();
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Fetch the original file behind an opaque-blob document: a short-lived
|
|
1477
|
+
// presigned GET plus the file's name / content type / size. An app that
|
|
1478
|
+
// knows the format fetches the bytes from `url`, parses them, and writes
|
|
1479
|
+
// the structured content into this document through the normal handle
|
|
1480
|
+
// surface (`doc.map('instances').mutate(...)`):
|
|
1481
|
+
//
|
|
1482
|
+
// const src = await doc.sourceBlob();
|
|
1483
|
+
// const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
1484
|
+
// const records = parseMyFormat(bytes); // app's own parser
|
|
1485
|
+
// doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
|
|
1486
|
+
//
|
|
1487
|
+
// A source file is a set of one or more streams: `src.streams` lists
|
|
1488
|
+
// every stream (primary first, so a single-stream file has one entry),
|
|
1489
|
+
// each with its own URL, so a multi-stream format (a glTF with its
|
|
1490
|
+
// `.bin` buffer) fetches only the streams it needs.
|
|
1491
|
+
//
|
|
1492
|
+
// Rejects with a `BrassError` when the document is not a stored opaque
|
|
1493
|
+
// blob (it has no original file to hand back) or the caller cannot
|
|
1494
|
+
// reach it (both 404).
|
|
1495
|
+
async sourceBlob(): Promise<DocumentSourceBlob> {
|
|
1496
|
+
this._assertOpen();
|
|
1497
|
+
if (this._docId === null) {
|
|
1498
|
+
throw new Error('sourceBlob: Document has no server identity');
|
|
1499
|
+
}
|
|
1500
|
+
if (this._sourceBlobFetcher === null) {
|
|
1501
|
+
throw new Error(
|
|
1502
|
+
'sourceBlob: no fetcher attached. Opened via the SDK factory?',
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
return await this._sourceBlobFetcher();
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// ---------------------------------------------------------------------
|
|
1509
|
+
// Streams
|
|
1510
|
+
// ---------------------------------------------------------------------
|
|
1511
|
+
|
|
1512
|
+
// Stream wiring, installed by the SDK open factory; `null` on
|
|
1513
|
+
// offline / test docs (stream methods then throw with a clear
|
|
1514
|
+
// message). The manifest cache maps stream name -> info; opens are
|
|
1515
|
+
// cached per name so `doc.stream('hvac')` is one join + subscribe
|
|
1516
|
+
// however many times it is called.
|
|
1517
|
+
private _streamRuntime: StreamRuntime | null = null;
|
|
1518
|
+
private _streamManifest: Map<string, DocumentStreamInfo> | null = null;
|
|
1519
|
+
private readonly _streamOpens = new Map<string, Promise<DocumentStream>>();
|
|
1520
|
+
private _ownStream: DocumentStream | null = null;
|
|
1521
|
+
// Which stream this Document's own handles read. An open names a stream
|
|
1522
|
+
// and the usual one has a default, so `default` is a name here rather
|
|
1523
|
+
// than a case: opening at `geometry` makes `doc.map(...)` read geometry
|
|
1524
|
+
// and `doc.stream('geometry')` resolve to this Document.
|
|
1525
|
+
private _openedStreamName: string = DEFAULT_STREAM_NAME;
|
|
1526
|
+
|
|
1527
|
+
// Install the stream runtime; `null` detaches. Mirrors `setDrivePusher`.
|
|
1528
|
+
/** @internal */
|
|
1529
|
+
setStreamRuntime(runtime: StreamRuntime | null): void {
|
|
1530
|
+
this._streamRuntime = runtime;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** @internal Name the stream this Document was opened at. Set by the SDK
|
|
1534
|
+
* open factory; defaults to `default`. */
|
|
1535
|
+
setOpenedStreamName(name: string): void {
|
|
1536
|
+
this._openedStreamName = name;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** The stream this Document's own handles read. */
|
|
1540
|
+
get openedStream(): string {
|
|
1541
|
+
return this._openedStreamName;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// Open one stream of this document, by name. Only the streams an app
|
|
1545
|
+
// touches are downloaded, imported, and subscribed, so a partitioned
|
|
1546
|
+
// document opens at the cost of what is used, not its total size:
|
|
1547
|
+
//
|
|
1548
|
+
// const hvac = await doc.stream('hvac');
|
|
1549
|
+
// const records = hvac.orderedMap('instances', 'instance_order');
|
|
1550
|
+
//
|
|
1551
|
+
// The name this Document was opened at (`'default'` unless the open said
|
|
1552
|
+
// otherwise) resolves to its own state, the same containers
|
|
1553
|
+
// `doc.map(...)` reads, with no network. That is what lets code iterate
|
|
1554
|
+
// stream names over partitioned and unpartitioned documents with one
|
|
1555
|
+
// path. Repeat calls return the same open (one join + subscribe per
|
|
1556
|
+
// stream per Document).
|
|
1557
|
+
//
|
|
1558
|
+
// Opening READS. A name the document does not hold rejects rather than
|
|
1559
|
+
// being created, so code that only reads cannot mint a stream by
|
|
1560
|
+
// probing for one; `getOrCreateStream` is the call that writes.
|
|
1561
|
+
stream(name: string): Promise<DocumentStream> {
|
|
1562
|
+
// Sync method returning a Promise: reject rather than throw, so a
|
|
1563
|
+
// caller chaining `.catch()` without `await` still sees the failure.
|
|
1564
|
+
if (!this._isLive) {
|
|
1565
|
+
return Promise.reject(new Error('Document is closed'));
|
|
1566
|
+
}
|
|
1567
|
+
// The stream this Document already IS. No network: its state is the
|
|
1568
|
+
// state these handles read.
|
|
1569
|
+
if (name === this._openedStreamName) {
|
|
1570
|
+
this._ownStream ??= new DocumentStream(name, this, false);
|
|
1571
|
+
return Promise.resolve(this._ownStream);
|
|
1572
|
+
}
|
|
1573
|
+
const cached = this._streamOpens.get(name);
|
|
1574
|
+
if (cached) return cached;
|
|
1575
|
+
const runtime = this._streamRuntime;
|
|
1576
|
+
if (runtime === null) {
|
|
1577
|
+
return Promise.reject(
|
|
1578
|
+
new Error('stream: no runtime attached. Opened via the SDK factory?'),
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
const open = (async (): Promise<DocumentStream> => {
|
|
1582
|
+
const info = await this._resolveStreamInfo(runtime, name);
|
|
1583
|
+
if (!info) {
|
|
1584
|
+
throw new Error(
|
|
1585
|
+
`This document has no stream named "${name}". ` +
|
|
1586
|
+
`doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
// Only a CRDT stream opens as live collaborative state. Any other
|
|
1590
|
+
// value fails loudly here rather than joining a line that is not
|
|
1591
|
+
// one, so a format added after this SDK shipped is refused by the
|
|
1592
|
+
// version that cannot read it.
|
|
1593
|
+
if (info.format !== 'crdt') {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`Stream "${name}" has format "${info.format}", which this ` +
|
|
1596
|
+
'SDK version cannot open as a live stream.',
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
const inner = await runtime.openLine(info.line_id);
|
|
1600
|
+
const stream = new DocumentStream(name, inner, true);
|
|
1601
|
+
// Opened while a version preview is active: bring the new stream to
|
|
1602
|
+
// the same point in time rather than showing it live under a
|
|
1603
|
+
// historical default.
|
|
1604
|
+
if (this._checkout !== null) {
|
|
1605
|
+
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id]);
|
|
1606
|
+
}
|
|
1607
|
+
return stream;
|
|
1608
|
+
})();
|
|
1609
|
+
this._streamOpens.set(name, open);
|
|
1610
|
+
// A failed open (network, deleted stream) must not poison the cache:
|
|
1611
|
+
// drop it so the next call retries.
|
|
1612
|
+
open.catch(() => {
|
|
1613
|
+
if (this._streamOpens.get(name) === open) this._streamOpens.delete(name);
|
|
1614
|
+
});
|
|
1615
|
+
return open;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Open the document's stream of that name, making it when the document
|
|
1619
|
+
// does not hold it yet. This is the call that WRITES: `stream` only
|
|
1620
|
+
// reads, so no read path can bring a stream into being.
|
|
1621
|
+
//
|
|
1622
|
+
// Two collaborators reaching for the same name converge on one stream
|
|
1623
|
+
// rather than one of them failing, so an app reaches for the stream it
|
|
1624
|
+
// expects on every open without checking first.
|
|
1625
|
+
async getOrCreateStream(name: string): Promise<DocumentStream> {
|
|
1626
|
+
this._assertOpen();
|
|
1627
|
+
// The stream this Document already IS needs no create: it exists by the
|
|
1628
|
+
// time the document is open. Asking the server would spend a
|
|
1629
|
+
// branch-write budget on a reach whose answer is already in hand, which
|
|
1630
|
+
// matters because an app loops its declared stream names on every open.
|
|
1631
|
+
if (name === this._openedStreamName) return this.stream(name);
|
|
1632
|
+
const runtime = this._streamRuntime;
|
|
1633
|
+
if (runtime === null) {
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
'getOrCreateStream: no runtime attached. Opened via the SDK factory?',
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
let info: DocumentStreamInfo;
|
|
1639
|
+
try {
|
|
1640
|
+
info = await runtime.create(name);
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
// Losing the create means someone else made this stream between our
|
|
1643
|
+
// read and our write, which is the same outcome we wanted, so resolve
|
|
1644
|
+
// theirs. Recognising the loss by re-reading rather than by matching a
|
|
1645
|
+
// status code keeps the app out of the race entirely: a rejection that
|
|
1646
|
+
// is NOT the race (no permission, doc gone) finds no stream and
|
|
1647
|
+
// rethrows untouched.
|
|
1648
|
+
const existing = await this._resolveStreamInfo(runtime, name);
|
|
1649
|
+
if (!existing) throw err;
|
|
1650
|
+
info = existing;
|
|
1651
|
+
}
|
|
1652
|
+
(this._streamManifest ??= new Map()).set(info.name, info);
|
|
1653
|
+
return this.stream(info.name);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Every stream the document holds, `default` included (it is an ordinary
|
|
1657
|
+
// manifest entry). Fetches fresh so a stream a collaborator just created
|
|
1658
|
+
// is visible.
|
|
1659
|
+
async listStreams(): Promise<DocumentStreamInfo[]> {
|
|
1660
|
+
this._assertOpen();
|
|
1661
|
+
const runtime = this._streamRuntime;
|
|
1662
|
+
if (runtime === null) {
|
|
1663
|
+
throw new Error(
|
|
1664
|
+
'listStreams: no runtime attached. Opened via the SDK factory?',
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
const streams = await runtime.list();
|
|
1668
|
+
this._streamManifest = new Map(streams.map((s) => [s.name, s]));
|
|
1669
|
+
return streams;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private async _resolveStreamInfo(
|
|
1673
|
+
runtime: StreamRuntime,
|
|
1674
|
+
name: string,
|
|
1675
|
+
): Promise<DocumentStreamInfo | undefined> {
|
|
1676
|
+
const cached = this._streamManifest?.get(name);
|
|
1677
|
+
if (cached) return cached;
|
|
1678
|
+
const streams = await runtime.list();
|
|
1679
|
+
this._streamManifest = new Map(streams.map((s) => [s.name, s]));
|
|
1680
|
+
return this._streamManifest.get(name);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Close every opened stream's own transport + state. Streams opened
|
|
1684
|
+
// while this Document was live do not outlive it.
|
|
1685
|
+
private _closeStreams(): void {
|
|
1686
|
+
for (const open of this._streamOpens.values()) {
|
|
1687
|
+
void open.then((s) => s._close()).catch(() => {
|
|
1688
|
+
// A failed open has nothing to close.
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
this._streamOpens.clear();
|
|
1692
|
+
this._streamManifest = null;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Seal a deliberate checkpoint on this document's version-
|
|
1696
|
+
// history timeline. Compaction snapshots continue to appear as
|
|
1697
|
+
// no-action fallback markers, so an app that never calls this still
|
|
1698
|
+
// gets a usable history.
|
|
1699
|
+
//
|
|
1700
|
+
// Anchors to `doc.currentSeq` synchronously, so a concurrent
|
|
1701
|
+
// editor's op landing mid-call cannot shift the anchor. Pass an
|
|
1702
|
+
// explicit `version` to seal at a different point in history (the
|
|
1703
|
+
// server validates it's `<=` the branch's live head).
|
|
1704
|
+
async createCheckpoint(): Promise<DocumentCheckpoint> {
|
|
1705
|
+
return this._checkpoints.create();
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Configure the implicit auto-checkpoint debounce. Default is 60s
|
|
1709
|
+
// after the last local edit; pass `0` to disable. Apps in test
|
|
1710
|
+
// contexts (deterministic clocks) or apps with a different "natural
|
|
1711
|
+
// pause" rhythm (a slow turn-based tool, a CAD modeler with multi-
|
|
1712
|
+
// second operations) tune this. Returning the prior value lets a
|
|
1713
|
+
// test or scoped override restore.
|
|
1714
|
+
setAutoCheckpointIntervalMs(intervalMs: number): number {
|
|
1715
|
+
return this._checkpoints.setAutoIntervalMs(intervalMs);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
// ---------------------------------------------------------------------
|
|
1720
|
+
// File-menu commands (per-doc)
|
|
1721
|
+
// ---------------------------------------------------------------------
|
|
1722
|
+
|
|
1723
|
+
// The doc's display name. `null` on offline / test docs.
|
|
1724
|
+
get name(): string | null {
|
|
1725
|
+
return this._name;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Internal: set the cached name. Fires a `'commands'` subscribe
|
|
1729
|
+
// event so apps re-render the menu trigger without an app-side
|
|
1730
|
+
// refresh.
|
|
1731
|
+
/** @internal */
|
|
1732
|
+
_setName(name: string | null): void {
|
|
1733
|
+
if (this._name === name) return;
|
|
1734
|
+
this._name = name;
|
|
1735
|
+
this._fireSubscribers({ kind: 'commands' });
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** @internal */
|
|
1739
|
+
setHostedUiProvider(provider: HostedUiProvider | null): void {
|
|
1740
|
+
this._commandMenu.setHostedUiProvider(provider);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// The per-doc file menu, as data. Each Document carries its own list
|
|
1744
|
+
// with its own `running` state; multi-doc apps render one menu per
|
|
1745
|
+
// pane. The app filters by `surface` ('menu' | 'toolbar') to place
|
|
1746
|
+
// each command into its own chrome.
|
|
1747
|
+
get commands(): readonly Command[] {
|
|
1748
|
+
return this._commandMenu.commands;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Invoke a command by id. A convenience for keyboard shortcuts
|
|
1752
|
+
// (`Ctrl-Z` -> `doc.run('undo')`). No-op for an unknown id.
|
|
1753
|
+
async run(commandId: string): Promise<void> {
|
|
1754
|
+
return this._commandMenu.run(commandId);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// Optional error sink for the doc's built-in commands. `null` falls
|
|
1758
|
+
// back to `console.error`.
|
|
1759
|
+
/** @internal */
|
|
1760
|
+
setErrorReporter(reporter: ((err: Error) => void) | null): void {
|
|
1761
|
+
this._errorReporter = reporter;
|
|
1762
|
+
this._commandMenu.setErrorReporter(reporter);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
private _reportError(err: Error): void {
|
|
1766
|
+
if (this._errorReporter) this._errorReporter(err);
|
|
1767
|
+
else console.error('[Brass]', err);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Fire all `subscribe` listeners with a synthetic event. Used for
|
|
1771
|
+
// metadata-only changes (checkout, return-to-head, branch switch,
|
|
1772
|
+
// branches list update, saving count change, commands change)
|
|
1773
|
+
// that don't originate from a Loro commit but still need the app
|
|
1774
|
+
// to re-read state. Apps typically ignore the event arg and just
|
|
1775
|
+
// call `toJSON()` in the handler; the discriminant is there for
|
|
1776
|
+
// the rare consumer that wants to branch on the reason.
|
|
1777
|
+
private _fireSubscribers(event: SyntheticSubscribeEvent): void {
|
|
1778
|
+
this._ensureLoroSubscribed();
|
|
1779
|
+
this._subs.emit(event);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Saving indicator: number of locally-issued ops not yet acked by
|
|
1783
|
+
// the server. Reactive: `subscribe` fires when this count changes
|
|
1784
|
+
// (an op is sent or an `op_ack` lands). Apps bind directly inside
|
|
1785
|
+
// the subscribe handler:
|
|
1786
|
+
//
|
|
1787
|
+
// doc.subscribe(() => {
|
|
1788
|
+
// savingEl.textContent =
|
|
1789
|
+
// doc.savingCount === 0 ? 'All changes saved'
|
|
1790
|
+
// : `Saving ${doc.savingCount} change${doc.savingCount === 1 ? '' : 's'}...`;
|
|
1791
|
+
// });
|
|
1792
|
+
get savingCount(): number {
|
|
1793
|
+
return this._outstandingByClientId.size;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Internal: forget an outstanding op once the server has acked it.
|
|
1797
|
+
// Returns true if the clientOpId was tracked (lets the caller detect
|
|
1798
|
+
// a stale ack).
|
|
1799
|
+
/** @internal */
|
|
1800
|
+
resolveOutstandingOp(clientOpId: string): boolean {
|
|
1801
|
+
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
1802
|
+
// Release this chunk from the resync-push loop guard once its op is
|
|
1803
|
+
// acked: the pushed op is now in the log, so the next handshake's
|
|
1804
|
+
// tail covers it and a fresh push is welcome (it would be a genuine
|
|
1805
|
+
// new hole, not a re-push of this one). The guard fully reopens only
|
|
1806
|
+
// once every chunk of the push has drained.
|
|
1807
|
+
this._resyncPushClientOpIds.delete(clientOpId);
|
|
1808
|
+
if (wasTracked) this._fireSubscribers({ kind: 'saving-count' });
|
|
1809
|
+
return wasTracked;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// Internal: forget an outstanding op the server TERMINALLY rejected (an
|
|
1813
|
+
// `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
|
|
1814
|
+
// server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
|
|
1815
|
+
// transport clears its resend timer; this drops the tracker entry so
|
|
1816
|
+
// `savingCount` stops counting a dead op, and surfaces the failure so the
|
|
1817
|
+
// app can react (the optimistic local commit stays applied but never
|
|
1818
|
+
// persists, so the app's cue is to reload). Returns true if it was tracked.
|
|
1819
|
+
/** @internal */
|
|
1820
|
+
_failOutstandingOp(clientOpId: string, message: string): boolean {
|
|
1821
|
+
const wasTracked = this._outstandingByClientId.delete(clientOpId);
|
|
1822
|
+
this._resyncPushClientOpIds.delete(clientOpId);
|
|
1823
|
+
if (wasTracked) {
|
|
1824
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
1825
|
+
this._reportError(new Error(message));
|
|
1826
|
+
}
|
|
1827
|
+
return wasTracked;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
private _ensureLocalUpdatesSubscribed(): void {
|
|
1831
|
+
if (this._unsubLocal) return;
|
|
1832
|
+
if (!this._localOpHook) return;
|
|
1833
|
+
// Loro's `subscribeLocalUpdates` fires after every local commit
|
|
1834
|
+
// with the op-blob bytes for that commit. Importing a remote op
|
|
1835
|
+
// does NOT trigger this callback, so we don't risk feeding the
|
|
1836
|
+
// same op back to the server in a loop.
|
|
1837
|
+
this._unsubLocal = this._doc.subscribeLocalUpdates((bytes: Uint8Array) => {
|
|
1838
|
+
const hook = this._localOpHook;
|
|
1839
|
+
if (!hook) return;
|
|
1840
|
+
// Copy: Loro reuses its internal buffer across callback calls in
|
|
1841
|
+
// some builds. Hand the consumer a stable view so the eventual
|
|
1842
|
+
// WS send sees the correct bytes regardless of when the send
|
|
1843
|
+
// actually flushes (the WS client may queue the frame).
|
|
1844
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
1845
|
+
copy.set(bytes);
|
|
1846
|
+
const clientOpId = hook(copy);
|
|
1847
|
+
this._outstandingByClientId.set(clientOpId, copy);
|
|
1848
|
+
// The local mutate has already fired `subscribe` (the Loro
|
|
1849
|
+
// event), but `savingCount` just incremented; fire one more
|
|
1850
|
+
// synthetic event so a subscribe handler bound on `savingCount`
|
|
1851
|
+
// sees the change.
|
|
1852
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Lazy-attach the underlying LoroDoc subscriber. **Do not** wire this
|
|
1857
|
+
// in the constructor or before the initial snapshot import: with a
|
|
1858
|
+
// subscriber attached, snapshot-import of a 50k doc takes ~8.5 s
|
|
1859
|
+
// because Loro walks the full event tree to deliver per-container
|
|
1860
|
+
// deltas. Lazy-attach drops 50k cold-load to ~280 ms.
|
|
1861
|
+
private _ensureLoroSubscribed(): void {
|
|
1862
|
+
// Never re-attach after close: `close()` nulls `_unsubLoro` and then
|
|
1863
|
+
// frees the underlying LoroDoc, so subscribing here would call into freed
|
|
1864
|
+
// WASM. Not every `_fireSubscribers` caller is transport-driven or
|
|
1865
|
+
// guarded: `_setName` runs from an open-time `refreshName(...).then(...)` with no
|
|
1866
|
+
// liveness re-check, and a default-branch `close()` frees the doc
|
|
1867
|
+
// immediately (no park), so a name fetch resolving after close reaches
|
|
1868
|
+
// here on a freed doc. The `_unsubLoro` short-circuit alone does not cover
|
|
1869
|
+
// it: close set it to null.
|
|
1870
|
+
if (this._closed) return;
|
|
1871
|
+
if (this._unsubLoro) return;
|
|
1872
|
+
this._unsubLoro = this._doc.subscribe((event) => {
|
|
1873
|
+
// One channel: every event reaches every subscriber, whatever
|
|
1874
|
+
// produced it. The app reconciles by value and reads the cause off
|
|
1875
|
+
// the event only when it has a non-render reason to.
|
|
1876
|
+
this._subs.emit(event);
|
|
1877
|
+
const isLocalCommit = (event as { by?: unknown }).by === 'local';
|
|
1878
|
+
this._maybeFireUndoState();
|
|
1879
|
+
// A local commit (mutate / undo / redo) schedules a debounced
|
|
1880
|
+
// thumbnail capture, when the app has registered a canvas via
|
|
1881
|
+
// `useCanvas`. Remote ops never trigger a capture: a viewer or
|
|
1882
|
+
// a passive collaborator must not re-upload a screenshot of
|
|
1883
|
+
// someone else's edit.
|
|
1884
|
+
if (isLocalCommit) {
|
|
1885
|
+
this._thumbnails.schedule();
|
|
1886
|
+
// Same reasoning for the implicit auto-checkpoint: only the
|
|
1887
|
+
// peer that made the edit seals the marker. Without this an
|
|
1888
|
+
// N-way session would write N redundant CHECKPOINT# rows per
|
|
1889
|
+
// idle period.
|
|
1890
|
+
this._checkpoints.scheduleAuto();
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Subscribe to document events. Returns an unsubscribe handle.
|
|
1896
|
+
// Lazy-attaches the underlying Loro subscriber on first call so
|
|
1897
|
+
// construction stays cheap.
|
|
1898
|
+
//
|
|
1899
|
+
// `cb` fires for EVERY change, whatever produced it: a local `mutate()`,
|
|
1900
|
+
// a remote `import`, `undo`, `redo`, `checkout`, and the SDK's synthetic
|
|
1901
|
+
// metadata events (saving-count, branches, commands). One channel; the
|
|
1902
|
+
// app re-reads / reconciles in the handler and branches on the event only
|
|
1903
|
+
// when it needs the cause. CRDT-origin events keep their Loro shape and
|
|
1904
|
+
// carry `by` / `origin` so the cause is recoverable; the engine-neutral
|
|
1905
|
+
// `origin.source` lives on the handle surface.
|
|
1906
|
+
subscribe(cb: DocumentSubscriber): DocumentUnsubscribe {
|
|
1907
|
+
this._assertOpen();
|
|
1908
|
+
this._ensureLoroSubscribed();
|
|
1909
|
+
return this._subs.on(cb);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Subscribe to changes in undo/redo enabled state. Fires once
|
|
1913
|
+
// synchronously on registration with the current values, then
|
|
1914
|
+
// again whenever either value flips (after a local mutate, an
|
|
1915
|
+
// undo / redo, or a remote import that landed an op of this
|
|
1916
|
+
// peer's). The push covers local mutates, which the regular
|
|
1917
|
+
// `subscribe(cb)` suppresses as local echo, so a button bound
|
|
1918
|
+
// here stays current while the user types, regardless of what
|
|
1919
|
+
// produced the change.
|
|
1920
|
+
//
|
|
1921
|
+
// Returns an unsubscribe handle. The callback is invoked with
|
|
1922
|
+
// `{ canUndo, canRedo }`; only fires when at least one value
|
|
1923
|
+
// differs from the prior fire.
|
|
1924
|
+
//
|
|
1925
|
+
// Wire it once in your editor bind step:
|
|
1926
|
+
//
|
|
1927
|
+
// const stop = doc.subscribeUndoState(({ canUndo, canRedo }) => {
|
|
1928
|
+
// undoBtn.disabled = !canUndo;
|
|
1929
|
+
// redoBtn.disabled = !canRedo;
|
|
1930
|
+
// });
|
|
1931
|
+
//
|
|
1932
|
+
// Lifecycle: throws on a closed Document; cleared on close /
|
|
1933
|
+
// suspend like every other app-side subscriber.
|
|
1934
|
+
subscribeUndoState(cb: UndoStateSubscriber): DocumentUnsubscribe {
|
|
1935
|
+
this._assertOpen();
|
|
1936
|
+
this._ensureLoroSubscribed();
|
|
1937
|
+
this._undoStateSubs.add(cb);
|
|
1938
|
+
// Fire immediately with current state so the consumer does
|
|
1939
|
+
// not need separate initial-paint code. Capture the values
|
|
1940
|
+
// BEFORE registering as the baseline, so the first underlying
|
|
1941
|
+
// event fires the subscriber only when the value actually
|
|
1942
|
+
// changes from this initial fire.
|
|
1943
|
+
const state: UndoState = {
|
|
1944
|
+
canUndo: this.canUndo(),
|
|
1945
|
+
canRedo: this.canRedo(),
|
|
1946
|
+
};
|
|
1947
|
+
this._lastCanUndo = state.canUndo;
|
|
1948
|
+
this._lastCanRedo = state.canRedo;
|
|
1949
|
+
try {
|
|
1950
|
+
cb(state);
|
|
1951
|
+
} catch {
|
|
1952
|
+
// A throwing initial callback must not strand the registration;
|
|
1953
|
+
// returning the unsub handle lets the app recover.
|
|
1954
|
+
}
|
|
1955
|
+
return () => {
|
|
1956
|
+
this._undoStateSubs.delete(cb);
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
|
|
1961
|
+
// Called from `_ensureLoroSubscribed`'s event dispatch.
|
|
1962
|
+
private _maybeFireUndoState(): void {
|
|
1963
|
+
if (this._undoStateSubs.size === 0) return;
|
|
1964
|
+
const canUndo = this.canUndo();
|
|
1965
|
+
const canRedo = this.canRedo();
|
|
1966
|
+
if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
this._lastCanUndo = canUndo;
|
|
1970
|
+
this._lastCanRedo = canRedo;
|
|
1971
|
+
const state: UndoState = { canUndo, canRedo };
|
|
1972
|
+
for (const cb of this._undoStateSubs) {
|
|
1973
|
+
try {
|
|
1974
|
+
cb(state);
|
|
1975
|
+
} catch {
|
|
1976
|
+
// A throwing subscriber must not affect the others.
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Register a callback that fires exactly once when the server
|
|
1982
|
+
// tells this Document its underlying doc has been evicted (today:
|
|
1983
|
+
// soft-delete via the hosted Delete tab or the underlying
|
|
1984
|
+
// `DELETE /documents/:docId` API; future: revoked membership,
|
|
1985
|
+
// organization deletion cascade). Late-attached listeners fire
|
|
1986
|
+
// synchronously on registration if the gone event already happened.
|
|
1987
|
+
//
|
|
1988
|
+
// The Document is NOT auto-closed; callers may want to render a
|
|
1989
|
+
// "the document was deleted" overlay before closing. Disable any
|
|
1990
|
+
// pending mutations in the listener and call `close()` when ready.
|
|
1991
|
+
// Mutations after `_markGone` still mutate the local CRDT and call
|
|
1992
|
+
// the `LocalOpHook`, but the server will reject those op frames
|
|
1993
|
+
// (the SUB# row was evicted at fanout time); the SDK consumer
|
|
1994
|
+
// sees the rejection via the underlying transport's error channel.
|
|
1995
|
+
onGone(cb: DocumentGoneListener): DocumentGoneUnsubscribe {
|
|
1996
|
+
// Guard like `subscribe` / `subscribeUndoState`: `_suspend` clears these
|
|
1997
|
+
// signals' listeners so a stale handle from a parked session does not
|
|
1998
|
+
// receive the next session's events. Without this throw a listener wired
|
|
1999
|
+
// onto a closed/suspended handle would attach and survive into the resumed
|
|
2000
|
+
// open (which reuses the same cached Document), the exact cross-session
|
|
2001
|
+
// leak `_suspend` sets out to prevent.
|
|
2002
|
+
this._assertOpen();
|
|
2003
|
+
return this._gone.on(cb);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// The latest opaque work-status for this document, or `null` when no
|
|
2007
|
+
// slow server-side work is in flight. An app renders `message`
|
|
2008
|
+
// verbatim, draws a bar from `progress` when present, and shows a
|
|
2009
|
+
// waiting state while `pending`. When a freshly-opened drive-linked
|
|
2010
|
+
// doc is still hydrating, `open()` surfaces the initial `pending`
|
|
2011
|
+
// status here; subsequent phases arrive via `onStatus`.
|
|
2012
|
+
get status(): DocumentWorkStatus | null {
|
|
2013
|
+
return this._workStatus.status;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// Subscribe to opaque work-status updates. The callback fires on
|
|
2017
|
+
// every phase the server pushes; when `status.pending` flips to
|
|
2018
|
+
// `false` the work is terminal (a success the app reacts to by
|
|
2019
|
+
// re-opening the doc to load the now-available content, or a failure
|
|
2020
|
+
// whose `message` the app shows). Fires once on attach with the
|
|
2021
|
+
// current status when one is already known, so a late subscriber is
|
|
2022
|
+
// not stranded. Returns an unsubscribe.
|
|
2023
|
+
//
|
|
2024
|
+
// The app must not parse `message` or infer phases from it: the
|
|
2025
|
+
// server owns the wording and the lifecycle. This surface exists so
|
|
2026
|
+
// the app can *display* progress, not understand it.
|
|
2027
|
+
onStatus(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
|
|
2028
|
+
// See `onGone`: guard the cross-session listener leak.
|
|
2029
|
+
this._assertOpen();
|
|
2030
|
+
return this._workStatus.on(cb);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// Internal: store the latest status and fire listeners. Not
|
|
2034
|
+
// one-shot: every phase re-emits. A terminal status (`pending:
|
|
2035
|
+
// false`) is retained so a late `onStatus` subscriber still sees the
|
|
2036
|
+
// final state.
|
|
2037
|
+
/** @internal */
|
|
2038
|
+
_emitStatus(status: DocumentWorkStatus): void {
|
|
2039
|
+
// A late reload/reconcile closure that resolved after `close()` must not
|
|
2040
|
+
// re-emit onto a torn-down doc (its undo stack is freed below in `close`).
|
|
2041
|
+
if (this._closed) return;
|
|
2042
|
+
this._workStatus.emit(status);
|
|
2043
|
+
// A blocking work-status is one of the inputs to `canUndo` / `canRedo`
|
|
2044
|
+
// (via `_isLoadLocked`), so a status that engages or releases the load
|
|
2045
|
+
// lock flips the undo/redo availability with no accompanying commit.
|
|
2046
|
+
// Refresh the undo-state notifier here so a subscribeUndoState-bound
|
|
2047
|
+
// button tracks the lock, not only CRDT edits.
|
|
2048
|
+
this._maybeFireUndoState();
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// The current drive sync state of this document, or `null` when the
|
|
2052
|
+
// doc is not linked to a drive source. The read-side companion to
|
|
2053
|
+
// `doc.pushToDrive(...)`: an app reads `canPush` / `state` to decide
|
|
2054
|
+
// whether to surface a "Publish" affordance, and renders the
|
|
2055
|
+
// server-authored `label` / `detail` as the wording. Seeded on open
|
|
2056
|
+
// (for a drive-linked doc) and kept live as edits, refreshes, and
|
|
2057
|
+
// pushes move the state.
|
|
2058
|
+
get driveStatus(): DocumentDriveStatus | null {
|
|
2059
|
+
return this._driveStatus.status;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Subscribe to drive sync-state changes. Fires once on attach with
|
|
2063
|
+
// the current state when one is already known, then on every change
|
|
2064
|
+
// (an edit flipping the doc to `ahead`, a push re-syncing it, the
|
|
2065
|
+
// source moving it to `behind`). De-duped server-state-side: a
|
|
2066
|
+
// METADATA change that did not move the sync state does not re-fire.
|
|
2067
|
+
// Returns an unsubscribe.
|
|
2068
|
+
//
|
|
2069
|
+
// As with `onStatus`, the app must not parse `label` / `detail`: the
|
|
2070
|
+
// server owns the wording. Branch only on `state` / `canPush`.
|
|
2071
|
+
onDriveStatus(
|
|
2072
|
+
cb: DocumentDriveStatusListener,
|
|
2073
|
+
): DocumentDriveStatusUnsubscribe {
|
|
2074
|
+
// See `onGone`: guard the cross-session listener leak.
|
|
2075
|
+
this._assertOpen();
|
|
2076
|
+
return this._driveStatus.on(cb);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// Internal: store the latest drive state and fire listeners,
|
|
2080
|
+
// skipping a no-op re-emit.
|
|
2081
|
+
/** @internal */
|
|
2082
|
+
_emitDriveStatus(status: DocumentDriveStatus): void {
|
|
2083
|
+
if (this._closed) return;
|
|
2084
|
+
this._driveStatus.emit(status);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Internal: mark the doc evicted. Idempotent (multiple frames arrive
|
|
2088
|
+
// for the same doc when both `doc:<id>` and `presence:doc:<id>`
|
|
2089
|
+
// topics fan out; we collapse them so the user-side `onGone`
|
|
2090
|
+
// listener fires exactly once).
|
|
2091
|
+
/** @internal */
|
|
2092
|
+
_markGone(reason: DocumentGoneReason): void {
|
|
2093
|
+
// An evicted doc's outstanding local ops can never ack (the transport
|
|
2094
|
+
// cancels their resend timers on `doc_gone`), so stop counting them as
|
|
2095
|
+
// "saving": without this the doc renders "no longer available" and
|
|
2096
|
+
// "Saving N changes…" at once until `close()`.
|
|
2097
|
+
if (this._outstandingByClientId.size > 0) {
|
|
2098
|
+
this._outstandingByClientId.clear();
|
|
2099
|
+
this._resyncPushClientOpIds.clear();
|
|
2100
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
2101
|
+
}
|
|
2102
|
+
this._gone.mark(reason);
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// Plain-JS projection of the current state.
|
|
2106
|
+
toJSON(): Record<string, unknown> {
|
|
2107
|
+
// When in checkout, read from the preview LoroDoc so the app
|
|
2108
|
+
// renders the historical state. Returning to head clears
|
|
2109
|
+
// `_checkout` and reads go back to the live `_doc`.
|
|
2110
|
+
const source =
|
|
2111
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
2112
|
+
return source.toJSON() as Record<string, unknown>;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* Snapshot bytes; inverse of `openFromSnapshot`, for round-trip tests.
|
|
2117
|
+
* @internal
|
|
2118
|
+
*/
|
|
2119
|
+
_exportSnapshot(): Uint8Array {
|
|
2120
|
+
return this._doc.export({ mode: 'snapshot' });
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Durability handshake (see `wireDocTransport`'s `doc_init` handler).
|
|
2124
|
+
//
|
|
2125
|
+
// Run the handshake and push the missing slice if there is one. Shared
|
|
2126
|
+
// by the `doc_init` (reconnect) path and the `doc_ops_dropped`
|
|
2127
|
+
// (prompt-notification) path, so the loop guard lives here rather than
|
|
2128
|
+
// in either caller. No-op when the durable frontier already covers the
|
|
2129
|
+
// local doc, while a prior resync push is still unacked, or when the
|
|
2130
|
+
// missing slice is too large to ship as one op (see the cap below).
|
|
2131
|
+
/** @internal */
|
|
2132
|
+
maybeResyncPush(
|
|
2133
|
+
persistedSnapshotVersionVector: Uint8Array,
|
|
2134
|
+
durableTailBlobs: Uint8Array[],
|
|
2135
|
+
): void {
|
|
2136
|
+
// `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
|
|
2137
|
+
// closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
|
|
2138
|
+
// closure can't touch freed WASM.
|
|
2139
|
+
if (this._closed) return;
|
|
2140
|
+
// Loop guard: skip while any chunk of a prior push is still unacked,
|
|
2141
|
+
// so a flurry of reconnects cannot re-push the same missing slice.
|
|
2142
|
+
for (const id of this._resyncPushClientOpIds) {
|
|
2143
|
+
if (this._outstandingByClientId.has(id)) return;
|
|
2144
|
+
}
|
|
2145
|
+
const chunks = this.computeResyncPush(
|
|
2146
|
+
persistedSnapshotVersionVector,
|
|
2147
|
+
durableTailBlobs,
|
|
2148
|
+
);
|
|
2149
|
+
if (chunks === null) return;
|
|
2150
|
+
for (const chunk of chunks) {
|
|
2151
|
+
const id = this.injectLocalUpdate(chunk);
|
|
2152
|
+
if (id !== null) this._resyncPushClientOpIds.add(id);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Given the server's persisted snapshot version vector and the op
|
|
2157
|
+
// blobs the server is also serving on the join tail, decide whether
|
|
2158
|
+
// this client holds CRDT knowledge the durable server state lacks,
|
|
2159
|
+
// and if so return the update blob that fills exactly those holes.
|
|
2160
|
+
//
|
|
2161
|
+
// The durable frontier is the snapshot's version vector advanced by
|
|
2162
|
+
// every tail op the server delivered (those ops are durable in the
|
|
2163
|
+
// log even though no snapshot has absorbed them yet). Comparing the
|
|
2164
|
+
// local doc against the SNAPSHOT vector alone would falsely flag the
|
|
2165
|
+
// whole durable tail as missing and push it back on every join, which
|
|
2166
|
+
// then grows the log and loops; folding the tail in is what keeps the
|
|
2167
|
+
// handshake a no-op in the common "server already has everything"
|
|
2168
|
+
// case. `decodeImportBlobMeta` reads each blob's end vector from its
|
|
2169
|
+
// header without a full import, so this stays cheap.
|
|
2170
|
+
//
|
|
2171
|
+
// Returns the chunks to resync-push, or null when the durable state
|
|
2172
|
+
// already covers everything local holds (or a malformed vector / tail
|
|
2173
|
+
// blob, which degrades the handshake to a no-op and warns rather than
|
|
2174
|
+
// throwing out of the frame handler that drives the live transport).
|
|
2175
|
+
//
|
|
2176
|
+
// The common case is one chunk: an `update` export from the durable
|
|
2177
|
+
// frontier carries only the missing ops plus their dependencies, never
|
|
2178
|
+
// the whole history, and a realistic gap is far under the per-frame
|
|
2179
|
+
// cap. When the missing slice exceeds the cap (a BIM-sized edit set:
|
|
2180
|
+
// a geometry import or bulk parametric change discarded then healed, or
|
|
2181
|
+
// a large offline divergence on reconnect), it is split into multiple
|
|
2182
|
+
// chunks via `updates-in-range` spans, each under the cap, so even a
|
|
2183
|
+
// large divergence heals instead of being skipped. Import is idempotent
|
|
2184
|
+
// and order-independent (Loro buffers a chunk whose dependency is in a
|
|
2185
|
+
// sibling chunk until it arrives), so the chunks need no ordering.
|
|
2186
|
+
/** @internal */
|
|
2187
|
+
computeResyncPush(
|
|
2188
|
+
persistedSnapshotVersionVector: Uint8Array,
|
|
2189
|
+
durableTailBlobs: Uint8Array[],
|
|
2190
|
+
): Uint8Array[] | null {
|
|
2191
|
+
let durable: VersionVector;
|
|
2192
|
+
try {
|
|
2193
|
+
durable = VersionVector.decode(persistedSnapshotVersionVector);
|
|
2194
|
+
for (const blob of durableTailBlobs) {
|
|
2195
|
+
const meta = decodeImportBlobMeta(blob, false);
|
|
2196
|
+
durable = mergeVersionVectors(durable, meta.partialEndVersionVector);
|
|
2197
|
+
}
|
|
2198
|
+
} catch (err) {
|
|
2199
|
+
console.warn(
|
|
2200
|
+
'[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob',
|
|
2201
|
+
err,
|
|
2202
|
+
);
|
|
2203
|
+
return null;
|
|
2204
|
+
}
|
|
2205
|
+
const local = this._doc.version();
|
|
2206
|
+
const cmp = local.compare(durable);
|
|
2207
|
+
// `compare` returns -1/0 when local is at or behind the durable
|
|
2208
|
+
// frontier (nothing to push), 1 when local is strictly ahead, and
|
|
2209
|
+
// undefined when the two are concurrent (local holds ops the server
|
|
2210
|
+
// lacks AND vice versa). Push in the ahead and concurrent cases; the
|
|
2211
|
+
// export from `durable` carries only the local-only ops either way.
|
|
2212
|
+
if (cmp !== undefined && cmp <= 0) return null;
|
|
2213
|
+
// Common case: the whole missing slice fits in one frame. Keep this
|
|
2214
|
+
// path byte-identical to a plain `update` export so the no-chunking
|
|
2215
|
+
// case is unchanged.
|
|
2216
|
+
const full = this._doc.export({ mode: 'update', from: durable });
|
|
2217
|
+
if (full.byteLength <= RESYNC_PUSH_MAX_BYTES) return [full];
|
|
2218
|
+
// Large divergence: split the per-peer missing ranges into chunks.
|
|
2219
|
+
return this.chunkMissingSlice(local, durable);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// The per-peer ranges local holds beyond `durable`, as half-open
|
|
2223
|
+
// [from, to) counter ranges. `export({ mode: 'update', from })` covers
|
|
2224
|
+
// exactly these ops; we re-express them as `updates-in-range` spans so
|
|
2225
|
+
// they can be split.
|
|
2226
|
+
private missingRanges(
|
|
2227
|
+
local: VersionVector,
|
|
2228
|
+
durable: VersionVector,
|
|
2229
|
+
): { peer: PeerID; from: number; to: number }[] {
|
|
2230
|
+
const durableMap = durable.toJSON();
|
|
2231
|
+
const ranges: { peer: PeerID; from: number; to: number }[] = [];
|
|
2232
|
+
for (const [peer, to] of local.toJSON()) {
|
|
2233
|
+
const from = durableMap.get(peer) ?? 0;
|
|
2234
|
+
if (to > from) ranges.push({ peer, from, to });
|
|
2235
|
+
}
|
|
2236
|
+
return ranges;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
// Recursively export the given ranges as one `updates-in-range` blob,
|
|
2240
|
+
// splitting by op count until each blob is under the per-frame cap. An
|
|
2241
|
+
// irreducible single op (one that alone exceeds the cap) is emitted up
|
|
2242
|
+
// to the hard ceiling, or skipped with a warning beyond it so the loop
|
|
2243
|
+
// never wedges on an un-sendable frame.
|
|
2244
|
+
private chunkMissingSlice(
|
|
2245
|
+
local: VersionVector,
|
|
2246
|
+
durable: VersionVector,
|
|
2247
|
+
): Uint8Array[] {
|
|
2248
|
+
const pack = (
|
|
2249
|
+
ranges: { peer: PeerID; from: number; to: number }[],
|
|
2250
|
+
): Uint8Array[] => {
|
|
2251
|
+
const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
|
|
2252
|
+
if (total === 0) return [];
|
|
2253
|
+
const blob = this._doc.export({
|
|
2254
|
+
mode: 'updates-in-range',
|
|
2255
|
+
spans: ranges.map((r) => ({
|
|
2256
|
+
id: { peer: r.peer, counter: r.from },
|
|
2257
|
+
len: r.to - r.from,
|
|
2258
|
+
})),
|
|
2259
|
+
});
|
|
2260
|
+
if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES) return [blob];
|
|
2261
|
+
if (total === 1) {
|
|
2262
|
+
// A single op that cannot be split further.
|
|
2263
|
+
if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES) return [blob];
|
|
2264
|
+
console.warn(
|
|
2265
|
+
`[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`,
|
|
2266
|
+
);
|
|
2267
|
+
return [];
|
|
2268
|
+
}
|
|
2269
|
+
const [first, second] = splitRangesByLength(ranges, total);
|
|
2270
|
+
return [...pack(first), ...pack(second)];
|
|
2271
|
+
};
|
|
2272
|
+
return pack(this.missingRanges(local, durable));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Feed an op blob into the local-op send path as if it were a fresh
|
|
2276
|
+
// local commit: ship it through the transport's send hook (which mints
|
|
2277
|
+
// a client_op_id, sends the frame, and arms the retry tracker) and
|
|
2278
|
+
// record it in the outstanding-op map so `savingCount` reflects it and
|
|
2279
|
+
// its `op_ack` resolves it. Used by the resync push, whose blob is an
|
|
2280
|
+
// export rather than a Loro-emitted local update, so it does not arrive
|
|
2281
|
+
// through `subscribeLocalUpdates`. Returns the minted client_op_id, or
|
|
2282
|
+
// null when no send hook is wired (offline / test path).
|
|
2283
|
+
/** @internal */
|
|
2284
|
+
injectLocalUpdate(blob: Uint8Array): string | null {
|
|
2285
|
+
this._ensureLocalUpdatesSubscribed();
|
|
2286
|
+
const hook = this._localOpHook;
|
|
2287
|
+
if (!hook) return null;
|
|
2288
|
+
const copy = new Uint8Array(blob.byteLength);
|
|
2289
|
+
copy.set(blob);
|
|
2290
|
+
const clientOpId = hook(copy);
|
|
2291
|
+
this._outstandingByClientId.set(clientOpId, copy);
|
|
2292
|
+
this._fireSubscribers({ kind: 'saving-count' });
|
|
2293
|
+
return clientOpId;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// Internal: park the Document in the SDK cache. Clears app-side
|
|
2297
|
+
// listeners so a stale handle from this session does not receive
|
|
2298
|
+
// events from the next session, but keeps the LoroDoc,
|
|
2299
|
+
// UndoController, outstanding-op map, and the underlying WS subscribe
|
|
2300
|
+
// alive. `mutate()` and `subscribe()` throw while suspended; the next
|
|
2301
|
+
// `openDocumentById` for the same (docId, branchId) calls `_resume()`
|
|
2302
|
+
// and the user gets a fresh session over the same state. App code
|
|
2303
|
+
// calls `close()`, not this method.
|
|
2304
|
+
/** @internal */
|
|
2305
|
+
_suspend(): void {
|
|
2306
|
+
if (this._closed || this._suspended) return;
|
|
2307
|
+
this._suspended = true;
|
|
2308
|
+
// A pending auto-checkpoint would otherwise fire against a parked
|
|
2309
|
+
// Document and silently no-op via the scheduler's guard.
|
|
2310
|
+
// Cancelling here keeps the timer count clean across long-lived
|
|
2311
|
+
// caches and avoids the no-op fetch flush.
|
|
2312
|
+
this._checkpoints.cancelAuto();
|
|
2313
|
+
this._subs.clear();
|
|
2314
|
+
this._undoStateSubs.clear();
|
|
2315
|
+
this._gone.clearListeners();
|
|
2316
|
+
this._workStatus.clearListeners();
|
|
2317
|
+
this._driveStatus.clearListeners();
|
|
2318
|
+
// Presence subscribes its OWN WS topic and keeps it alive across a park
|
|
2319
|
+
// (resume does not re-create the controller), so drop its app-side
|
|
2320
|
+
// change listeners here the same way as the sets above: a parked doc
|
|
2321
|
+
// must not fire the app's `presence.on('change')` callback on remote
|
|
2322
|
+
// cursor frames for a doc the app has closed. `leave()` would be wrong,
|
|
2323
|
+
// it terminally unsubscribes and the resume path never rebuilds it.
|
|
2324
|
+
this.presence?._suspendListeners();
|
|
2325
|
+
// Tear down the app-side handle subscriptions, THEN drop the caches.
|
|
2326
|
+
// A handle subscribes DIRECTLY to its Loro container (`handles.ts`
|
|
2327
|
+
// `_attachChange`), not through `_unsubLoro`, and a parked doc keeps
|
|
2328
|
+
// applying remote ops to those live containers (the WS stays attached),
|
|
2329
|
+
// so a subscription an app registered before `close()` would keep firing
|
|
2330
|
+
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
2331
|
+
// fresh handles are minted on resume + next access.
|
|
2332
|
+
for (const entry of this._rootHandles.values()) {
|
|
2333
|
+
teardownRootHandle(entry.handle);
|
|
2334
|
+
}
|
|
2335
|
+
this._rootHandles.clear();
|
|
2336
|
+
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
2337
|
+
// map / list handles it was built from; leaving it while the root caches
|
|
2338
|
+
// are cleared would strand those delegates outside `_rebindRootHandles`,
|
|
2339
|
+
// so a later version checkout would not rebind them and the ordered map
|
|
2340
|
+
// would keep reading live-head state during a historical preview.
|
|
2341
|
+
this._orderedMapHandles.clear();
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// Internal: pair of `_suspend`. The SDK calls this when handing a
|
|
2345
|
+
// cached Document to a new openDocumentById call. App code does
|
|
2346
|
+
// not invoke this directly.
|
|
2347
|
+
/** @internal */
|
|
2348
|
+
_resume(): void {
|
|
2349
|
+
this._suspended = false;
|
|
2350
|
+
// Pair of the `_suspend` presence gate: reopen the presence controller's
|
|
2351
|
+
// outbound path and re-advertise this client's cursor on the resumed open.
|
|
2352
|
+
// `_suspend` only closed the gate; without this the parked doc's presence
|
|
2353
|
+
// would stay silent for the life of the resumed session.
|
|
2354
|
+
this.presence?._resumeListeners();
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// Internal: whether the Document is currently parked. The SDK's
|
|
2358
|
+
// open-document layer reads this to gate transport-level work
|
|
2359
|
+
// (an op-stream frame arriving for a non-parked, non-closed doc
|
|
2360
|
+
// routes normally; for a parked doc the WS is still attached and
|
|
2361
|
+
// the frame applies to state but does not run app-side callbacks).
|
|
2362
|
+
/** @internal */
|
|
2363
|
+
get _isSuspended(): boolean {
|
|
2364
|
+
return this._suspended;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
close(): void {
|
|
2368
|
+
if (this._closed) return;
|
|
2369
|
+
this._closeStreams();
|
|
2370
|
+
this._closed = true;
|
|
2371
|
+
this._suspended = false;
|
|
2372
|
+
if (this._unsubLoro) {
|
|
2373
|
+
this._unsubLoro();
|
|
2374
|
+
this._unsubLoro = null;
|
|
2375
|
+
}
|
|
2376
|
+
if (this._unsubLocal) {
|
|
2377
|
+
this._unsubLocal();
|
|
2378
|
+
this._unsubLocal = null;
|
|
2379
|
+
}
|
|
2380
|
+
if (this._undo) {
|
|
2381
|
+
this._undo.free();
|
|
2382
|
+
this._undo = null;
|
|
2383
|
+
}
|
|
2384
|
+
this._thumbnails.cancel();
|
|
2385
|
+
this._checkpoints.cancelAuto();
|
|
2386
|
+
this._localOpHook = null;
|
|
2387
|
+
this._currentSeqProvider = null;
|
|
2388
|
+
this._outstandingByClientId.clear();
|
|
2389
|
+
this._resyncPushClientOpIds.clear();
|
|
2390
|
+
this._subs.clear();
|
|
2391
|
+
this._undoStateSubs.clear();
|
|
2392
|
+
// Drop the signal listeners too, mirroring `_suspend()`. Their frames
|
|
2393
|
+
// can no longer fire once the transport is torn down, but clearing
|
|
2394
|
+
// them keeps `close()` from retaining an app's callbacks (and the
|
|
2395
|
+
// closures they hold) for the Document's remaining lifetime.
|
|
2396
|
+
this._gone.clearListeners();
|
|
2397
|
+
this._workStatus.clearListeners();
|
|
2398
|
+
this._driveStatus.clearListeners();
|
|
2399
|
+
// Detach handle subscriptions before freeing the doc (below): a handle's
|
|
2400
|
+
// per-container Loro subscription outlives the cache reference, and
|
|
2401
|
+
// `_doc.free()` must not run with subscriptions still attached.
|
|
2402
|
+
for (const entry of this._rootHandles.values()) {
|
|
2403
|
+
teardownRootHandle(entry.handle);
|
|
2404
|
+
}
|
|
2405
|
+
this._rootHandles.clear();
|
|
2406
|
+
this._orderedMapHandles.clear();
|
|
2407
|
+
// Free a live historical-checkout preview: it is a distinct LoroDoc
|
|
2408
|
+
// (allocated by `_checkoutVersion`), so closing while previewing without a
|
|
2409
|
+
// prior `returnToHead()` would otherwise leak its WASM heap. The guard
|
|
2410
|
+
// mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
|
|
2411
|
+
// has `previewDoc === _doc`, where `_doc.free()` below already covers it
|
|
2412
|
+
// and an unguarded free would double-free.
|
|
2413
|
+
if (this._checkout && this._checkout.previewDoc !== this._doc) {
|
|
2414
|
+
try {
|
|
2415
|
+
this._checkout.previewDoc.free();
|
|
2416
|
+
} catch {
|
|
2417
|
+
// ignore; already freed
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
this._checkout = null;
|
|
2421
|
+
// Loro's WASM has a `free()` to drop the heap-allocated doc state.
|
|
2422
|
+
// Calling it after `close()` invalidates the doc, which is what we
|
|
2423
|
+
// want; future mutate/subscribe calls throw above.
|
|
2424
|
+
this._doc.free();
|
|
2425
|
+
}
|
|
2426
|
+
}
|