@brass-build/client 0.2.0 → 0.4.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 +26 -5
- package/CHANGELOG.md +61 -2
- package/README.md +247 -87
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +278 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +583 -12
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +51 -3
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +415 -36
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3 -3
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +28 -66
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +16 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +123 -35
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +191 -21
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +75 -4
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +7 -2
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +655 -15
- package/src/document-checkpoints.ts +30 -0
- package/src/document-contents.ts +104 -0
- package/src/document-signals.ts +113 -1
- package/src/document-streams.ts +15 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +460 -33
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +7 -6
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +28 -62
- package/src/index.ts +43 -4
- package/src/internal.ts +10 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +179 -39
- package/src/presence.ts +6 -0
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +219 -22
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +77 -7
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/src/documents.ts
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
import {
|
|
13
13
|
LoroDoc,
|
|
14
14
|
LoroMap,
|
|
15
|
+
type LoroText,
|
|
15
16
|
type PeerID,
|
|
16
17
|
VersionVector,
|
|
17
18
|
decodeImportBlobMeta,
|
|
18
|
-
} from '
|
|
19
|
+
} from './wasm-runtime.js';
|
|
19
20
|
|
|
20
21
|
import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
|
|
21
22
|
import type { DocSyncDiagnostics, UndoControllerOptions } from './types.js';
|
|
@@ -31,11 +32,14 @@ import {
|
|
|
31
32
|
type StateVersion,
|
|
32
33
|
} from './handles.js';
|
|
33
34
|
import type {
|
|
35
|
+
ChangeOrigin,
|
|
34
36
|
ChangeSource,
|
|
35
37
|
HandleOwner,
|
|
36
38
|
ListHandle,
|
|
37
39
|
MapHandle,
|
|
38
40
|
OrderedMapHandle,
|
|
41
|
+
TextHandle,
|
|
42
|
+
TextSubscriber,
|
|
39
43
|
} from './handle-types.js';
|
|
40
44
|
import {
|
|
41
45
|
flattenPagedLookup,
|
|
@@ -57,6 +61,7 @@ import {
|
|
|
57
61
|
GoneSignal,
|
|
58
62
|
WorkStatusSignal,
|
|
59
63
|
DriveStatusSignal,
|
|
64
|
+
DrivePublishTargetSignal,
|
|
60
65
|
type DocumentGoneListener,
|
|
61
66
|
type DocumentGoneReason,
|
|
62
67
|
type DocumentGoneUnsubscribe,
|
|
@@ -68,6 +73,8 @@ import {
|
|
|
68
73
|
type DocumentDriveStatusUnsubscribe,
|
|
69
74
|
type DocumentDriveTrackingState,
|
|
70
75
|
type DriveMirrorDirection,
|
|
76
|
+
type DrivePublishTarget,
|
|
77
|
+
type DrivePublishState,
|
|
71
78
|
} from './document-signals.js';
|
|
72
79
|
import {
|
|
73
80
|
ThumbnailCapture,
|
|
@@ -78,6 +85,7 @@ import {
|
|
|
78
85
|
CheckpointScheduler,
|
|
79
86
|
type CheckpointCreator,
|
|
80
87
|
} from './document-checkpoints.js';
|
|
88
|
+
import { whenSaved } from './when-saved.js';
|
|
81
89
|
import {
|
|
82
90
|
BranchController,
|
|
83
91
|
type BranchProvider,
|
|
@@ -120,6 +128,7 @@ export type {
|
|
|
120
128
|
DocumentDriveStatusUnsubscribe,
|
|
121
129
|
DocumentDriveTrackingState,
|
|
122
130
|
DriveMirrorDirection,
|
|
131
|
+
DrivePublishTarget,
|
|
123
132
|
};
|
|
124
133
|
|
|
125
134
|
// The proxy callback receives an object with the root map (`entities`)
|
|
@@ -148,6 +157,46 @@ export type EntityProxy = Record<string, unknown>;
|
|
|
148
157
|
export type DocumentSubscriber = (event: SyntheticSubscribeEvent) => void;
|
|
149
158
|
export type DocumentUnsubscribe = () => void;
|
|
150
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Which handle reads a collection: `map` for `doc.map(key)`, `list` for
|
|
162
|
+
* `doc.list(key)`, `text` for `doc.text(key)`.
|
|
163
|
+
*
|
|
164
|
+
* An ordered map is a pairing of a `map` and a `list` rather than a kind of
|
|
165
|
+
* its own, so both halves are listed under their own keys.
|
|
166
|
+
*
|
|
167
|
+
* Read it as an open set and render an unrecognised kind as one you cannot
|
|
168
|
+
* name, so a kind added later reaches your app as a value rather than as a
|
|
169
|
+
* break. This set grew from two to three when `doc.text` arrived.
|
|
170
|
+
*/
|
|
171
|
+
export type RootKind = KnownRootKind | (string & {});
|
|
172
|
+
|
|
173
|
+
// The kinds this build resolves, kept narrow so the producer below is still
|
|
174
|
+
// checked against them: the public type is open, which on its own would let a
|
|
175
|
+
// misspelt kind through as an ordinary string.
|
|
176
|
+
type KnownRootKind = 'map' | 'list' | 'text';
|
|
177
|
+
|
|
178
|
+
// Loro names a container's type in its id (`cid:root-notes:Map`), so the kind
|
|
179
|
+
// comes off the enumeration itself and costs no read. A type this package
|
|
180
|
+
// cannot hand anyone a handle for, and cannot describe as text either, is
|
|
181
|
+
// left out rather than guessed at, so every entry a reader gets is one they
|
|
182
|
+
// can act on.
|
|
183
|
+
function rootKindOf(containerId: string): KnownRootKind | null {
|
|
184
|
+
const type = containerId.slice(containerId.lastIndexOf(':') + 1);
|
|
185
|
+
if (type === 'Map') return 'map';
|
|
186
|
+
if (type === 'MovableList' || type === 'List') return 'list';
|
|
187
|
+
if (type === 'Text') return 'text';
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Maps count entries, lists and text count length. Both are the container's
|
|
192
|
+
// own counter rather than a projection of what it holds.
|
|
193
|
+
function rootIsEmpty(container: unknown): boolean {
|
|
194
|
+
const sized = container as { size?: number; length?: number };
|
|
195
|
+
if (typeof sized.size === 'number') return sized.size === 0;
|
|
196
|
+
if (typeof sized.length === 'number') return sized.length === 0;
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
151
200
|
// The discriminated event `doc.subscribe` fires. Every event the document
|
|
152
201
|
// produces arrives in this shape, whether a content change or one of the
|
|
153
202
|
// SDK's own metadata ticks, so an app can ignore the argument and re-read in
|
|
@@ -208,6 +257,24 @@ function changeSourceOf(event: unknown): ChangeSource {
|
|
|
208
257
|
return 'local';
|
|
209
258
|
}
|
|
210
259
|
|
|
260
|
+
// Whether a text container stores `value` unchanged. Loro's text is UTF-8, so
|
|
261
|
+
// an unpaired surrogate is stored as U+FFFD and what the document holds is
|
|
262
|
+
// not the string that was written. Scanned here rather than through
|
|
263
|
+
// `String.prototype.isWellFormed`, which is younger than the browsers this
|
|
264
|
+
// package runs in.
|
|
265
|
+
function storedVerbatim(value: string): boolean {
|
|
266
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
267
|
+
const unit = value.charCodeAt(i);
|
|
268
|
+
if (unit < 0xd800 || unit > 0xdfff) continue;
|
|
269
|
+
// A trailing surrogate reached before its lead is unpaired by itself.
|
|
270
|
+
if (unit > 0xdbff) return false;
|
|
271
|
+
i += 1;
|
|
272
|
+
const low = i < value.length ? value.charCodeAt(i) : 0;
|
|
273
|
+
if (low < 0xdc00 || low > 0xdfff) return false;
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
211
278
|
// Per-chunk cap on a blob this Document pushes as several ops: a durability
|
|
212
279
|
// resync's missing slice (`computeResyncPush`) and a local commit no write
|
|
213
280
|
// budget bounded (`splitLocalUpdate`).
|
|
@@ -217,11 +284,10 @@ function changeSourceOf(event: unknown): ChangeSource {
|
|
|
217
284
|
// the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
|
|
218
285
|
// makes a heal the most expensive write the SDK performs: the slice a bulk
|
|
219
286
|
// write leaves behind is the whole document, and at frame size that is
|
|
220
|
-
// thousands of ops
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// tens of ops whose bytes go to the bucket instead.
|
|
287
|
+
// thousands of ops written one after another against the same document.
|
|
288
|
+
// Measured, a 40 MB divergence healed in 16 KB chunks overwhelmed that
|
|
289
|
+
// document's write path and left the join a tail it refused to serve. The
|
|
290
|
+
// same divergence at this size is tens of ops.
|
|
225
291
|
const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
|
|
226
292
|
|
|
227
293
|
// Hard ceiling for a single irreducible op (one Loro op, unsplittable),
|
|
@@ -449,7 +515,15 @@ export type ReadOnlyReason =
|
|
|
449
515
|
// A change this copy holds was refused, so it shows something no other
|
|
450
516
|
// reader has. Editing on top of it builds more of the same, and `reload()`
|
|
451
517
|
// is what returns the copy to what the platform holds.
|
|
452
|
-
| 'unsaved'
|
|
518
|
+
| 'unsaved'
|
|
519
|
+
// A stream reached the largest size the platform can keep, so it refuses
|
|
520
|
+
// further writes. Unlike `unsaved`, no reload follows: a stream is one
|
|
521
|
+
// part of a document, and the rest keeps taking writes, so the app reads
|
|
522
|
+
// `stream.atCapacity` and stops offering the bulk work on this stream.
|
|
523
|
+
| 'capacity'
|
|
524
|
+
// The document opened with no live connection to write through, so a
|
|
525
|
+
// change would stay on this device.
|
|
526
|
+
| 'no_connection';
|
|
453
527
|
|
|
454
528
|
const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
455
529
|
link: 'Document is read-only: it was opened via a view-only share link.',
|
|
@@ -458,6 +532,12 @@ const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
|
458
532
|
unsaved:
|
|
459
533
|
'Document is read-only: a change could not be saved, so this copy holds ' +
|
|
460
534
|
'work the platform does not. Brass is replacing it with the platform copy.',
|
|
535
|
+
capacity:
|
|
536
|
+
'Stream is read-only: it reached the largest size the platform can keep. ' +
|
|
537
|
+
'Reopen it to keep working from the saved state.',
|
|
538
|
+
no_connection:
|
|
539
|
+
'Document is read-only: it opened with no live connection, so a change ' +
|
|
540
|
+
'would stay on this device.',
|
|
461
541
|
};
|
|
462
542
|
|
|
463
543
|
export interface DocumentOptions {
|
|
@@ -499,6 +579,15 @@ type LocalOpSendHook = (opBlob: Uint8Array) => string;
|
|
|
499
579
|
// Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
|
|
500
580
|
// toJSON / close. Construction is cheap: the Loro subscriber attaches
|
|
501
581
|
// lazily (see `_ensureLoroSubscribed`).
|
|
582
|
+
// A root text handle, plus the three hooks the Document drives on it. A text
|
|
583
|
+
// container's subscription anchors to one LoroDoc, so a version checkout has
|
|
584
|
+
// to move it the way it moves a map's or a list's anchor.
|
|
585
|
+
interface RootTextHandle extends TextHandle {
|
|
586
|
+
_rebind(next: LoroText): void;
|
|
587
|
+
_resync(): void;
|
|
588
|
+
_teardown(): void;
|
|
589
|
+
}
|
|
590
|
+
|
|
502
591
|
export class Document {
|
|
503
592
|
private readonly _doc: LoroDoc;
|
|
504
593
|
private readonly _rootKey: string;
|
|
@@ -612,10 +701,25 @@ export class Document {
|
|
|
612
701
|
this._fireSubscribers({ kind: 'branches' });
|
|
613
702
|
},
|
|
614
703
|
});
|
|
704
|
+
// The version of the newest picture the server holds of this document, or
|
|
705
|
+
// `null` for none. Seeded from the join and moved by this instance's own
|
|
706
|
+
// uploads; a picture older than the document's head is what arms the seed
|
|
707
|
+
// trigger, so a placeholder taken before the content arrived is replaced
|
|
708
|
+
// once it does.
|
|
709
|
+
private _thumbnailVersion: number | null = null;
|
|
615
710
|
private readonly _thumbnails = new ThumbnailCapture({
|
|
711
|
+
// `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
|
|
712
|
+
// thumbnail from a reader it will not take writes from, so a capture
|
|
713
|
+
// there costs a request and a warn to be told so. The load lock is the
|
|
714
|
+
// other half of `readOnly` and must NOT gate this, because a document
|
|
715
|
+
// still hydrating is exactly the one the seed watch is armed for, and
|
|
716
|
+
// the arm happens while that lock is still on.
|
|
616
717
|
isCaptureAllowed: (): boolean =>
|
|
617
|
-
this._isLive && this._checkout === null,
|
|
718
|
+
this._isLive && this._checkout === null && !this._forcedReadOnly,
|
|
618
719
|
isAttached: (): boolean => this._isLive,
|
|
720
|
+
onCaptured: (): void => {
|
|
721
|
+
this._thumbnailVersion = this.currentSeq ?? 0;
|
|
722
|
+
},
|
|
619
723
|
});
|
|
620
724
|
private readonly _checkpoints = new CheckpointScheduler({
|
|
621
725
|
docId: (): string | null => this._docId,
|
|
@@ -626,6 +730,7 @@ export class Document {
|
|
|
626
730
|
sealUndoStep: (): void => {
|
|
627
731
|
if (this._undo) this._undo.sealStep();
|
|
628
732
|
},
|
|
733
|
+
whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
|
|
629
734
|
});
|
|
630
735
|
// Drive push hook. `null` on the offline / test path; `pushToDrive`
|
|
631
736
|
// then throws with a clear message rather than silently dropping.
|
|
@@ -694,6 +799,11 @@ export class Document {
|
|
|
694
799
|
// surfaces a terminally-rejected op here so a "could not be saved" outcome
|
|
695
800
|
// reaches the app instead of being a silent forever-retry.
|
|
696
801
|
private _errorReporter: ((err: Error) => void) | null = null;
|
|
802
|
+
// Set when this document was opened expecting a live transport that was not
|
|
803
|
+
// available. A write then changes local state and reaches no server, so the
|
|
804
|
+
// first one is reported rather than dropped in silence.
|
|
805
|
+
private _transportExpected = false;
|
|
806
|
+
private _transportUnavailableReported = false;
|
|
697
807
|
private _closed = false;
|
|
698
808
|
// Set true when the Document is parked in the SDK's per-(docId, branchId)
|
|
699
809
|
// cache after an app-level `close()`. The underlying LoroDoc, UndoController,
|
|
@@ -706,6 +816,14 @@ export class Document {
|
|
|
706
816
|
// next session). `_resume()`
|
|
707
817
|
// flips the flag back when the SDK hands the Doc to a new open() call.
|
|
708
818
|
private _suspended = false;
|
|
819
|
+
// How many times this instance has been handed to an open. The park cache
|
|
820
|
+
// hands the SAME Document back on a reopen, so "this document is open" is
|
|
821
|
+
// not one interval per instance and a per-instance mark cannot say which
|
|
822
|
+
// open it was made in. Anything that spends a once-per-open budget on a
|
|
823
|
+
// Document (the import runner's one attempt) records this alongside its
|
|
824
|
+
// mark and re-reads it, so a reopen is a new open rather than the old one
|
|
825
|
+
// continuing.
|
|
826
|
+
private _openEpoch = 0;
|
|
709
827
|
// Live = neither closed nor parked. This is the public-surface gate;
|
|
710
828
|
// internal ingress (remote ops, status emits) deliberately checks
|
|
711
829
|
// `_closed` alone, because a parked doc keeps applying remote state.
|
|
@@ -738,6 +856,9 @@ export class Document {
|
|
|
738
856
|
// Drive sync state, latest-retained. `null` until the open factory
|
|
739
857
|
// seeds it (drive-linked docs only) and on docs with no drive link.
|
|
740
858
|
private readonly _driveStatus = new DriveStatusSignal();
|
|
859
|
+
// Where this document could be published when it mirrors nothing yet.
|
|
860
|
+
// Seeded from the open, emptied once it becomes a mirror.
|
|
861
|
+
private readonly _drivePublishTarget = new DrivePublishTargetSignal();
|
|
741
862
|
|
|
742
863
|
// Document-scoped presence. `null` until the WS transport is wired
|
|
743
864
|
// on, and on the offline / test path where there is no transport.
|
|
@@ -800,6 +921,7 @@ export class Document {
|
|
|
800
921
|
string,
|
|
801
922
|
| { kind: 'map'; key: string; handle: MapHandle<unknown> }
|
|
802
923
|
| { kind: 'list'; key: string; handle: ListHandle<unknown> }
|
|
924
|
+
| { kind: 'text'; key: string; handle: RootTextHandle }
|
|
803
925
|
>();
|
|
804
926
|
|
|
805
927
|
// Names the pages this Document's writes may occupy. A page is one value,
|
|
@@ -881,6 +1003,125 @@ export class Document {
|
|
|
881
1003
|
string,
|
|
882
1004
|
OrderedMapHandle<unknown>
|
|
883
1005
|
>();
|
|
1006
|
+
/**
|
|
1007
|
+
* A handle on the top-level text container at `key`, which `roots()` reports
|
|
1008
|
+
* as kind `'text'`.
|
|
1009
|
+
*
|
|
1010
|
+
* Cached per key, so `doc.text('x') === doc.text('x')` holds as it does for
|
|
1011
|
+
* `doc.map`, and a subscriber registered through one keeps firing across a
|
|
1012
|
+
* version checkout.
|
|
1013
|
+
*/
|
|
1014
|
+
text(key: string): TextHandle {
|
|
1015
|
+
this._assertOpen();
|
|
1016
|
+
const cacheKey = `text:${key}`;
|
|
1017
|
+
const existing = this._rootHandles.get(cacheKey);
|
|
1018
|
+
if (existing) return existing.handle as TextHandle;
|
|
1019
|
+
const owner = this._handleOwner;
|
|
1020
|
+
const activeSource = (): LoroDoc =>
|
|
1021
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
1022
|
+
// Reads and writes resolve the active source per call, so a checkout
|
|
1023
|
+
// needs no rebinding for them. A SUBSCRIPTION cannot work that way: it
|
|
1024
|
+
// anchors a listener to one container, and a checkout preview is a
|
|
1025
|
+
// different LoroDoc, so the anchor has to be moved. That is what makes
|
|
1026
|
+
// this handle cached and registered like a map or a list, rather than a
|
|
1027
|
+
// fresh object per call: the app holds the object the rebind reaches.
|
|
1028
|
+
let anchor: LoroText = activeSource().getText(key);
|
|
1029
|
+
const subscribers = new Set<TextSubscriber>();
|
|
1030
|
+
let detach: (() => void) | null = null;
|
|
1031
|
+
// The string this handle's own `update` has just written, armed only
|
|
1032
|
+
// across the synchronous window in which `commit` fires the Loro
|
|
1033
|
+
// subscriber. Materializing a text container is the one cost here that
|
|
1034
|
+
// grows with a document's accumulated edit history rather than with its
|
|
1035
|
+
// length, and `update` has already paid it once to diff against, so a
|
|
1036
|
+
// fanout that materialized again would double the per-keystroke cost of
|
|
1037
|
+
// exactly the field the README teaches. Anything else, a remote op or a
|
|
1038
|
+
// checkout, finds it null and materializes.
|
|
1039
|
+
let justWrote: string | null = null;
|
|
1040
|
+
const fanout = (origin?: ChangeOrigin): void => {
|
|
1041
|
+
// Read for the local write alone. A change from any other source
|
|
1042
|
+
// describes a container this handle did not write, so its value comes
|
|
1043
|
+
// from the container even while a local write is arming one.
|
|
1044
|
+
const value =
|
|
1045
|
+
(origin?.source === 'local' ? justWrote : null) ?? anchor.toString();
|
|
1046
|
+
// Fired off a snapshot: a subscriber may unsubscribe (or register
|
|
1047
|
+
// another) while it runs, and the set it is iterating is the one that
|
|
1048
|
+
// changes underneath it.
|
|
1049
|
+
const current = Array.from(subscribers);
|
|
1050
|
+
for (const cb of current) cb(value, origin);
|
|
1051
|
+
};
|
|
1052
|
+
const attach = (): void => {
|
|
1053
|
+
if (detach !== null || subscribers.size === 0) return;
|
|
1054
|
+
detach = anchor.subscribe((event) => {
|
|
1055
|
+
fanout({ source: owner.originOf(event) });
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
const handle: RootTextHandle = {
|
|
1059
|
+
read: (): string => {
|
|
1060
|
+
owner.assertReadable();
|
|
1061
|
+
return activeSource().getText(key).toString();
|
|
1062
|
+
},
|
|
1063
|
+
update: (next: string): void => {
|
|
1064
|
+
owner.assertWritable();
|
|
1065
|
+
owner.ensureLocalUpdatesSubscribed();
|
|
1066
|
+
activeSource().getText(key).update(next);
|
|
1067
|
+
// Armed after the write, so the container already holds `next` for
|
|
1068
|
+
// every subscriber the commit reaches, and cleared however the
|
|
1069
|
+
// commit returns: outside this window the fanout has no way to know
|
|
1070
|
+
// the value is still current, and reads it. Armed only for a string
|
|
1071
|
+
// the container holds verbatim, so a subscriber is never handed a
|
|
1072
|
+
// value the document and its peers disagree with.
|
|
1073
|
+
justWrote = storedVerbatim(next) ? next : null;
|
|
1074
|
+
try {
|
|
1075
|
+
owner.commitMutate();
|
|
1076
|
+
} finally {
|
|
1077
|
+
justWrote = null;
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
subscribe: (cb: TextSubscriber): (() => void) => {
|
|
1081
|
+
owner.assertReadable();
|
|
1082
|
+
subscribers.add(cb);
|
|
1083
|
+
attach();
|
|
1084
|
+
// No origin: a registration fire describes no authored transition,
|
|
1085
|
+
// matching what a map subscriber's first fire carries.
|
|
1086
|
+
cb(anchor.toString(), undefined);
|
|
1087
|
+
return (): void => {
|
|
1088
|
+
subscribers.delete(cb);
|
|
1089
|
+
if (subscribers.size === 0 && detach !== null) {
|
|
1090
|
+
detach();
|
|
1091
|
+
detach = null;
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
},
|
|
1095
|
+
_rebind: (next: LoroText): void => {
|
|
1096
|
+
if (detach !== null) {
|
|
1097
|
+
detach();
|
|
1098
|
+
detach = null;
|
|
1099
|
+
}
|
|
1100
|
+
anchor = next;
|
|
1101
|
+
attach();
|
|
1102
|
+
},
|
|
1103
|
+
// A checkout or a return to head swaps the container rather than
|
|
1104
|
+
// writing into it, so the catch-up fire carries no origin.
|
|
1105
|
+
_resync: (): void => {
|
|
1106
|
+
fanout(undefined);
|
|
1107
|
+
},
|
|
1108
|
+
// Detach the container subscription and forget the callbacks behind it,
|
|
1109
|
+
// as a map or a list handle does: the Document drops its handle cache
|
|
1110
|
+
// here, so an app still holding this one subscribes onto a handle
|
|
1111
|
+
// nothing else reaches, and every callback registered before the park
|
|
1112
|
+
// would come back with it.
|
|
1113
|
+
_teardown: (): void => {
|
|
1114
|
+
if (detach !== null) {
|
|
1115
|
+
detach();
|
|
1116
|
+
detach = null;
|
|
1117
|
+
}
|
|
1118
|
+
subscribers.clear();
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
this._rootHandles.set(cacheKey, { kind: 'text', key, handle });
|
|
1122
|
+
return handle;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
884
1125
|
orderedMap<T = unknown>(
|
|
885
1126
|
mapKey: string,
|
|
886
1127
|
orderKey: string,
|
|
@@ -1145,6 +1386,11 @@ export class Document {
|
|
|
1145
1386
|
// reload/resync closure can't touch freed WASM. `_closed` only; a parked
|
|
1146
1387
|
// doc still imports.
|
|
1147
1388
|
if (this._closed) return;
|
|
1389
|
+
// Arm the seed BEFORE the import, because this is the moment the content
|
|
1390
|
+
// arrives and the Loro subscriber cannot be relied on to report it: it is
|
|
1391
|
+
// attached lazily, so a document whose content lands before the session has
|
|
1392
|
+
// bound its canvas has nobody subscribed and the import fires nothing.
|
|
1393
|
+
this._armSeedIfPictureIsStale();
|
|
1148
1394
|
this._doc.import(snapshot);
|
|
1149
1395
|
// The undo stack pre-snapshot referenced ops the new snapshot
|
|
1150
1396
|
// supersedes. Drop both stacks so undo doesn't try to revert
|
|
@@ -1553,12 +1799,15 @@ export class Document {
|
|
|
1553
1799
|
for (const entry of this._rootHandles.values()) {
|
|
1554
1800
|
if (entry.kind === 'map') {
|
|
1555
1801
|
rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
|
|
1556
|
-
} else {
|
|
1802
|
+
} else if (entry.kind === 'list') {
|
|
1557
1803
|
rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
|
|
1804
|
+
} else {
|
|
1805
|
+
entry.handle._rebind(source.getText(entry.key));
|
|
1558
1806
|
}
|
|
1559
1807
|
}
|
|
1560
1808
|
for (const entry of this._rootHandles.values()) {
|
|
1561
|
-
|
|
1809
|
+
if (entry.kind === 'text') entry.handle._resync();
|
|
1810
|
+
else resyncRootHandle(entry.handle);
|
|
1562
1811
|
}
|
|
1563
1812
|
}
|
|
1564
1813
|
|
|
@@ -1651,6 +1900,23 @@ export class Document {
|
|
|
1651
1900
|
this._thumbnails.setUploader(uploader);
|
|
1652
1901
|
}
|
|
1653
1902
|
|
|
1903
|
+
// Tell the document which version the server's picture of it depicts, from
|
|
1904
|
+
// the join, and start a seed watch when that picture is behind the content.
|
|
1905
|
+
/** @internal */
|
|
1906
|
+
_setThumbnailVersion(version: number | null): void {
|
|
1907
|
+
this._thumbnailVersion = version;
|
|
1908
|
+
this._armSeedIfPictureIsStale();
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// A picture at or above the head depicts what the document currently holds.
|
|
1912
|
+
private _armSeedIfPictureIsStale(): void {
|
|
1913
|
+
const head = this.currentSeq ?? 0;
|
|
1914
|
+
if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
this._thumbnails.armSeed();
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1654
1920
|
// Install the checkpoint-create hook; `null` detaches.
|
|
1655
1921
|
/** @internal */
|
|
1656
1922
|
setCheckpointCreator(creator: CheckpointCreator | null): void {
|
|
@@ -1736,11 +2002,11 @@ export class Document {
|
|
|
1736
2002
|
return await this._driveResolver();
|
|
1737
2003
|
}
|
|
1738
2004
|
|
|
1739
|
-
// Fetch the
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
2005
|
+
// Fetch the file a document was uploaded as: a short-lived presigned GET
|
|
2006
|
+
// plus the file's name / content type / size. An app that knows the format
|
|
2007
|
+
// fetches the bytes from `url`, parses them, and writes the structured
|
|
2008
|
+
// content into this document through the normal handle surface
|
|
2009
|
+
// (`doc.map('instances').mutate(...)`):
|
|
1744
2010
|
//
|
|
1745
2011
|
// const src = await doc.sourceBlob();
|
|
1746
2012
|
// const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
@@ -1752,9 +2018,8 @@ export class Document {
|
|
|
1752
2018
|
// each with its own URL, so a multi-stream format (a glTF with its
|
|
1753
2019
|
// `.bin` buffer) fetches only the streams it needs.
|
|
1754
2020
|
//
|
|
1755
|
-
// Rejects with a `BrassError` when the document
|
|
1756
|
-
//
|
|
1757
|
-
// reach it (both 404).
|
|
2021
|
+
// Rejects with a `BrassError` when the document holds no such file, or
|
|
2022
|
+
// the caller cannot reach it (both 404).
|
|
1758
2023
|
async sourceBlob(): Promise<DocumentSourceBlob> {
|
|
1759
2024
|
this._assertOpen();
|
|
1760
2025
|
if (this._docId === null) {
|
|
@@ -1864,11 +2129,23 @@ export class Document {
|
|
|
1864
2129
|
}
|
|
1865
2130
|
const inner = await runtime.openLine(info.line_id);
|
|
1866
2131
|
const stream = new DocumentStream(name, inner, true);
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
2132
|
+
try {
|
|
2133
|
+
// Opened while a version preview is active: bring the new stream to
|
|
2134
|
+
// the same point in time rather than showing it live under a
|
|
2135
|
+
// historical default.
|
|
2136
|
+
if (this._checkout !== null) {
|
|
2137
|
+
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
|
|
2138
|
+
}
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
// The line is joined and subscribed by now, and a rejected open is
|
|
2141
|
+
// dropped from the cache so the next call opens a second one. Nothing
|
|
2142
|
+
// else holds this Document, and nothing else would ever close it: the
|
|
2143
|
+
// parent closes the streams it has, and a stream whose open failed is
|
|
2144
|
+
// not one of them. So the failure disposes what it is abandoning,
|
|
2145
|
+
// rather than leaving a line applying remote ops for the life of the
|
|
2146
|
+
// page.
|
|
2147
|
+
stream._close();
|
|
2148
|
+
throw err;
|
|
1872
2149
|
}
|
|
1873
2150
|
return stream;
|
|
1874
2151
|
})();
|
|
@@ -2075,6 +2352,41 @@ export class Document {
|
|
|
2075
2352
|
this._commandMenu.setErrorReporter(reporter);
|
|
2076
2353
|
}
|
|
2077
2354
|
|
|
2355
|
+
// Called by the factory when a document is opened with no live transport,
|
|
2356
|
+
// which outside tests means `wsBaseUrl` was left unset. Marking it is what
|
|
2357
|
+
// separates that from the snapshot reads and test stubs that legitimately
|
|
2358
|
+
// have no transport and must stay quiet.
|
|
2359
|
+
/** @internal */
|
|
2360
|
+
markTransportUnavailable(): void {
|
|
2361
|
+
this._transportExpected = true;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// A write on such a document reaches nothing: local state changes, the
|
|
2365
|
+
// reader sees their edit, and no server ever hears about it, so the edit is
|
|
2366
|
+
// gone on the next open with no failure anywhere. This is what makes that
|
|
2367
|
+
// observable. Once per document, because the condition holds for as long as
|
|
2368
|
+
// the document is open and repeating it per keystroke buries it.
|
|
2369
|
+
private _reportTransportUnavailable(): void {
|
|
2370
|
+
if (!this._transportExpected) return;
|
|
2371
|
+
if (this._transportUnavailableReported) return;
|
|
2372
|
+
this._transportUnavailableReported = true;
|
|
2373
|
+
// Lock the document as well as reporting it. The write that reached this
|
|
2374
|
+
// point stays local, exactly as `unsaved` describes for a refused one, so
|
|
2375
|
+
// the same gate closes: an app that disables on `readOnly` stops offering
|
|
2376
|
+
// an editor that cannot save, and a later `mutate` throws a sentence
|
|
2377
|
+
// naming the reason instead of succeeding into nothing. The lock lands
|
|
2378
|
+
// after this write's own gate has passed, so the first one still runs
|
|
2379
|
+
// locally and only what follows it is refused.
|
|
2380
|
+
this._markReadOnly('no_connection');
|
|
2381
|
+
this._reportError(
|
|
2382
|
+
new Error(
|
|
2383
|
+
'This document has no live connection, so edits stay on this device and ' +
|
|
2384
|
+
'reach no one else. Pass wsBaseUrl to Brass.init alongside the other ' +
|
|
2385
|
+
'base URLs: naming any one of them leaves wsBaseUrl unset.',
|
|
2386
|
+
),
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2078
2390
|
private _reportError(err: Error): void {
|
|
2079
2391
|
if (this._errorReporter) this._errorReporter(err);
|
|
2080
2392
|
else console.error('[Brass]', err);
|
|
@@ -2154,8 +2466,9 @@ export class Document {
|
|
|
2154
2466
|
* reconnects, and under which correlator, which is what separates a
|
|
2155
2467
|
* connection that is answering nothing from one that is merely behind.
|
|
2156
2468
|
*
|
|
2157
|
-
* @internal
|
|
2158
|
-
* to know a save is outstanding, and the SDK resolves a stuck
|
|
2469
|
+
* @internal Read on demand by Brass's own surfaces. An app reads
|
|
2470
|
+
* `savingCount` to know a save is outstanding, and the SDK resolves a stuck
|
|
2471
|
+
* one itself.
|
|
2159
2472
|
*/
|
|
2160
2473
|
diagnostics(): DocSyncDiagnostics {
|
|
2161
2474
|
const own: DocSyncDiagnostics = this._diagnosticsProvider?.() ?? {
|
|
@@ -2362,7 +2675,10 @@ export class Document {
|
|
|
2362
2675
|
|
|
2363
2676
|
private _ensureLocalUpdatesSubscribed(): void {
|
|
2364
2677
|
if (this._unsubLocal) return;
|
|
2365
|
-
if (!this._localOpHook)
|
|
2678
|
+
if (!this._localOpHook) {
|
|
2679
|
+
this._reportTransportUnavailable();
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2366
2682
|
// Loro's `subscribeLocalUpdates` fires after every local commit
|
|
2367
2683
|
// with the op-blob bytes for that commit. Importing a remote op
|
|
2368
2684
|
// does NOT trigger this callback, so we don't risk feeding the
|
|
@@ -2450,9 +2766,9 @@ export class Document {
|
|
|
2450
2766
|
this._maybeFireUndoState();
|
|
2451
2767
|
// A local commit (mutate / undo / redo) schedules a debounced
|
|
2452
2768
|
// thumbnail capture, when the app has registered a canvas via
|
|
2453
|
-
// `useCanvas`.
|
|
2454
|
-
// a passive collaborator
|
|
2455
|
-
// someone else's edit.
|
|
2769
|
+
// `useCanvas`. The debounce is for the peer that drew the change, so
|
|
2770
|
+
// it is local-only: a passive collaborator re-uploading a screenshot
|
|
2771
|
+
// of someone else's edit is what a per-op capture would be.
|
|
2456
2772
|
if (isLocalCommit) {
|
|
2457
2773
|
this._thumbnails.schedule();
|
|
2458
2774
|
// Same reasoning for the implicit auto-checkpoint: only the
|
|
@@ -2460,6 +2776,17 @@ export class Document {
|
|
|
2460
2776
|
// N-way session would write N redundant CHECKPOINT# rows per
|
|
2461
2777
|
// idle period.
|
|
2462
2778
|
this._checkpoints.scheduleAuto();
|
|
2779
|
+
} else {
|
|
2780
|
+
// Content arriving with no local commit behind it is what an import
|
|
2781
|
+
// looks like from here, and it moves the head past whatever picture
|
|
2782
|
+
// the document has. This DOES reach a reader who drew none of it,
|
|
2783
|
+
// which the debounce above deliberately does not: an import runs on
|
|
2784
|
+
// whichever client claimed it, so gating the seed on local authorship
|
|
2785
|
+
// would leave the documents that most need a picture without one.
|
|
2786
|
+
// Every such reader captures the same document at the same version,
|
|
2787
|
+
// and the targeted snapshot each upload enqueues is idempotent on
|
|
2788
|
+
// that version, so the concurrent seeds resolve themselves.
|
|
2789
|
+
this._armSeedIfPictureIsStale();
|
|
2463
2790
|
}
|
|
2464
2791
|
});
|
|
2465
2792
|
}
|
|
@@ -2671,6 +2998,55 @@ export class Document {
|
|
|
2671
2998
|
_emitDriveStatus(status: DocumentDriveStatus): void {
|
|
2672
2999
|
if (this._closed) return;
|
|
2673
3000
|
this._driveStatus.emit(status);
|
|
3001
|
+
// A document that has become a mirror publishes through its link from
|
|
3002
|
+
// now on, so the create-an-artifact offer goes with the arrival of the
|
|
3003
|
+
// status that replaces it.
|
|
3004
|
+
this._drivePublishTarget.set(null);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// Whether this document's export runs in THIS app, which is what decides
|
|
3008
|
+
// whether a push carries bytes. Not on `driveStatus`: it is a fact about
|
|
3009
|
+
// the caller rather than about the document, and it is all an app is told
|
|
3010
|
+
// about the exporter, never which app it is when it is not this one.
|
|
3011
|
+
private _exportsHere = false;
|
|
3012
|
+
|
|
3013
|
+
/** @internal */
|
|
3014
|
+
get _exportRunsHere(): boolean {
|
|
3015
|
+
return this._exportsHere;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/** @internal */
|
|
3019
|
+
_setExportRunsHere(runsHere: boolean): void {
|
|
3020
|
+
this._exportsHere = runsHere;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
// Internal: where this document can be published while it mirrors nothing.
|
|
3024
|
+
/** @internal */
|
|
3025
|
+
get _publishTarget(): DrivePublishTarget | null {
|
|
3026
|
+
return this._drivePublishTarget.target;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/** @internal */
|
|
3030
|
+
_setPublishTarget(target: DrivePublishTarget | null): void {
|
|
3031
|
+
if (this._closed) return;
|
|
3032
|
+
this._drivePublishTarget.set(target);
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// Internal: how the publish of a document that mirrors nothing is going.
|
|
3036
|
+
/** @internal */
|
|
3037
|
+
get _publishState(): DrivePublishState {
|
|
3038
|
+
return this._drivePublishTarget.state;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
/** @internal */
|
|
3042
|
+
_emitPublishState(state: DrivePublishState): void {
|
|
3043
|
+
if (this._closed) return;
|
|
3044
|
+
this._drivePublishTarget.setState(state);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/** @internal */
|
|
3048
|
+
_onPublishState(cb: () => void): () => void {
|
|
3049
|
+
return this._drivePublishTarget.onState(cb);
|
|
2674
3050
|
}
|
|
2675
3051
|
|
|
2676
3052
|
// Internal: mark the doc evicted. Idempotent (multiple frames arrive
|
|
@@ -2691,6 +3067,45 @@ export class Document {
|
|
|
2691
3067
|
this._gone.mark(reason);
|
|
2692
3068
|
}
|
|
2693
3069
|
|
|
3070
|
+
/**
|
|
3071
|
+
* The top-level collections of the stream this document is open at, keyed
|
|
3072
|
+
* as it keys them, each naming the kind of handle that reads it.
|
|
3073
|
+
*
|
|
3074
|
+
* A partitioned document keeps its collections per stream, so this is the
|
|
3075
|
+
* open stream's answer rather than every stream's. `listStreams` names the
|
|
3076
|
+
* rest, and `stream.roots()` asks one of them the same question.
|
|
3077
|
+
*
|
|
3078
|
+
* `map` / `list` are get-or-create, and taking the wrong kind for a key
|
|
3079
|
+
* materializes a second container beside the real one, so read this before
|
|
3080
|
+
* taking a handle on a key you are unsure of. A collection holding nothing
|
|
3081
|
+
* is not listed, which is what keeps a handle taken but never written from
|
|
3082
|
+
* appearing as one.
|
|
3083
|
+
*
|
|
3084
|
+
* The cost is the number of collections rather than the size of the
|
|
3085
|
+
* document. In checkout, it describes the version being previewed.
|
|
3086
|
+
*/
|
|
3087
|
+
roots(): Record<string, RootKind> {
|
|
3088
|
+
this._assertNotClosed();
|
|
3089
|
+
const source =
|
|
3090
|
+
this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
3091
|
+
const found: [string, RootKind][] = [];
|
|
3092
|
+
for (const [key, containerId] of Object.entries(source.getShallowValue())) {
|
|
3093
|
+
const kind = rootKindOf(containerId);
|
|
3094
|
+
if (kind === null) continue;
|
|
3095
|
+
// Reading the container's own count, rather than its value, is what
|
|
3096
|
+
// keeps this off the projection: an empty collection is skipped without
|
|
3097
|
+
// anything being decoded.
|
|
3098
|
+
const container = source.getContainerById(containerId);
|
|
3099
|
+
if (container === undefined || rootIsEmpty(container)) continue;
|
|
3100
|
+
found.push([key, kind]);
|
|
3101
|
+
}
|
|
3102
|
+
// Loro reports roots in registration order, which is an accident of who
|
|
3103
|
+
// wrote what first. Inserting in key order makes a reader's iteration
|
|
3104
|
+
// stable across opens.
|
|
3105
|
+
found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
3106
|
+
return Object.fromEntries(found);
|
|
3107
|
+
}
|
|
3108
|
+
|
|
2694
3109
|
// Plain-JS projection of the current state.
|
|
2695
3110
|
toJSON(): Record<string, unknown> {
|
|
2696
3111
|
this._assertNotClosed();
|
|
@@ -2938,7 +3353,7 @@ export class Document {
|
|
|
2938
3353
|
// undo step, so a bulk write that correctly split into many ops comes back
|
|
2939
3354
|
// as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
|
|
2940
3355
|
// is refused outright and the redone edit never persists, and below it, it
|
|
2941
|
-
// still lands as
|
|
3356
|
+
// still lands as one very large write against the document.
|
|
2942
3357
|
//
|
|
2943
3358
|
// The ops already exist, so the only lever left is which spans they ship in.
|
|
2944
3359
|
// The blob names the counter range it covers, and re-exporting a sub-range
|
|
@@ -3019,6 +3434,7 @@ export class Document {
|
|
|
3019
3434
|
this._gone.clearListeners();
|
|
3020
3435
|
this._workStatus.clearListeners();
|
|
3021
3436
|
this._driveStatus.clearListeners();
|
|
3437
|
+
this._drivePublishTarget.clearListeners();
|
|
3022
3438
|
// Presence subscribes its OWN WS topic and keeps it alive across a park
|
|
3023
3439
|
// (resume does not re-create the controller), so drop its app-side
|
|
3024
3440
|
// change listeners here the same way as the sets above: a parked doc
|
|
@@ -3034,7 +3450,8 @@ export class Document {
|
|
|
3034
3450
|
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
3035
3451
|
// fresh handles are minted on resume + next access.
|
|
3036
3452
|
for (const entry of this._rootHandles.values()) {
|
|
3037
|
-
|
|
3453
|
+
if (entry.kind === 'text') entry.handle._teardown();
|
|
3454
|
+
else teardownRootHandle(entry.handle);
|
|
3038
3455
|
}
|
|
3039
3456
|
this._rootHandles.clear();
|
|
3040
3457
|
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
@@ -3051,6 +3468,7 @@ export class Document {
|
|
|
3051
3468
|
/** @internal */
|
|
3052
3469
|
_resume(): void {
|
|
3053
3470
|
this._suspended = false;
|
|
3471
|
+
this._openEpoch += 1;
|
|
3054
3472
|
this._setStreamsSuspended(false);
|
|
3055
3473
|
// Pair of the `_suspend` presence gate: reopen the presence controller's
|
|
3056
3474
|
// outbound path and re-advertise this client's cursor on the resumed open.
|
|
@@ -3069,6 +3487,13 @@ export class Document {
|
|
|
3069
3487
|
return this._suspended;
|
|
3070
3488
|
}
|
|
3071
3489
|
|
|
3490
|
+
/** @internal Which open this Document is currently in (see `_openEpoch`).
|
|
3491
|
+
* Read by anything holding a per-open mark on a Document, since a park hit
|
|
3492
|
+
* hands the same instance to the next open. */
|
|
3493
|
+
get _openGeneration(): number {
|
|
3494
|
+
return this._openEpoch;
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3072
3497
|
close(): void {
|
|
3073
3498
|
if (this._closed) return;
|
|
3074
3499
|
this._closeStreams();
|
|
@@ -3086,7 +3511,7 @@ export class Document {
|
|
|
3086
3511
|
this._undo.free();
|
|
3087
3512
|
this._undo = null;
|
|
3088
3513
|
}
|
|
3089
|
-
this._thumbnails.
|
|
3514
|
+
this._thumbnails.destroy();
|
|
3090
3515
|
this._checkpoints.cancelAuto();
|
|
3091
3516
|
this._localOpHook = null;
|
|
3092
3517
|
this._currentSeqProvider = null;
|
|
@@ -3102,11 +3527,13 @@ export class Document {
|
|
|
3102
3527
|
this._gone.clearListeners();
|
|
3103
3528
|
this._workStatus.clearListeners();
|
|
3104
3529
|
this._driveStatus.clearListeners();
|
|
3530
|
+
this._drivePublishTarget.clearListeners();
|
|
3105
3531
|
// Detach handle subscriptions before freeing the doc (below): a handle's
|
|
3106
3532
|
// per-container Loro subscription outlives the cache reference, and
|
|
3107
3533
|
// `_doc.free()` must not run with subscriptions still attached.
|
|
3108
3534
|
for (const entry of this._rootHandles.values()) {
|
|
3109
|
-
|
|
3535
|
+
if (entry.kind === 'text') entry.handle._teardown();
|
|
3536
|
+
else teardownRootHandle(entry.handle);
|
|
3110
3537
|
}
|
|
3111
3538
|
this._rootHandles.clear();
|
|
3112
3539
|
this._orderedMapHandles.clear();
|