@brass-build/client 0.3.0 → 0.4.1
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 +50 -7
- package/CHANGELOG.md +63 -2
- package/README.md +233 -77
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +273 -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 +31 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +611 -12
- package/dist/client.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-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/documents.d.ts +45 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +305 -22
- 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 +1 -1
- 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 +17 -6
- 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 +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -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 +1 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +69 -34
- 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/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +60 -12
- 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 +52 -2
- 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/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +3 -0
- 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 +390 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +680 -14
- package/src/document-contents.ts +104 -0
- package/src/document-streams.ts +15 -1
- package/src/documents.ts +340 -20
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +1 -1
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +17 -6
- package/src/index.ts +43 -4
- package/src/internal.ts +4 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +79 -38
- package/src/presence.ts +6 -0
- package/src/session-toolbar.ts +80 -12
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +53 -2
- 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/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/dist/documents.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 2. The `getOwnPropertyDescriptor` trap participates in
|
|
9
9
|
// `Object.keys`; doing `loroMap.keys().includes(key)` there makes
|
|
10
10
|
// `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
|
|
11
|
-
import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from '
|
|
11
|
+
import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from './wasm-runtime.js';
|
|
12
12
|
import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
|
|
13
13
|
import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
|
|
14
14
|
import { flattenPagedLookup, isPagedMarker, META_KEY, pagedRootKey, } from './record-paging.js';
|
|
@@ -25,6 +25,31 @@ import { createEmitter } from './emitter.js';
|
|
|
25
25
|
// The stream name `doc.stream()` resolves to the document's own default
|
|
26
26
|
// state; mirrors the server's reserved name.
|
|
27
27
|
export const DEFAULT_STREAM_NAME = 'default';
|
|
28
|
+
// Loro names a container's type in its id (`cid:root-notes:Map`), so the kind
|
|
29
|
+
// comes off the enumeration itself and costs no read. A type this package
|
|
30
|
+
// cannot hand anyone a handle for, and cannot describe as text either, is
|
|
31
|
+
// left out rather than guessed at, so every entry a reader gets is one they
|
|
32
|
+
// can act on.
|
|
33
|
+
function rootKindOf(containerId) {
|
|
34
|
+
const type = containerId.slice(containerId.lastIndexOf(':') + 1);
|
|
35
|
+
if (type === 'Map')
|
|
36
|
+
return 'map';
|
|
37
|
+
if (type === 'MovableList' || type === 'List')
|
|
38
|
+
return 'list';
|
|
39
|
+
if (type === 'Text')
|
|
40
|
+
return 'text';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Maps count entries, lists and text count length. Both are the container's
|
|
44
|
+
// own counter rather than a projection of what it holds.
|
|
45
|
+
function rootIsEmpty(container) {
|
|
46
|
+
const sized = container;
|
|
47
|
+
if (typeof sized.size === 'number')
|
|
48
|
+
return sized.size === 0;
|
|
49
|
+
if (typeof sized.length === 'number')
|
|
50
|
+
return sized.length === 0;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
28
53
|
// Commit-origin tags stamped on every locally-produced commit via Loro's
|
|
29
54
|
// `setNextCommitOrigin`, so `origin.source` can be recovered off the
|
|
30
55
|
// event. Stamping the commit (not a JS-side flag) survives a
|
|
@@ -52,6 +77,26 @@ function changeSourceOf(event) {
|
|
|
52
77
|
return 'redo';
|
|
53
78
|
return 'local';
|
|
54
79
|
}
|
|
80
|
+
// Whether a text container stores `value` unchanged. Loro's text is UTF-8, so
|
|
81
|
+
// an unpaired surrogate is stored as U+FFFD and what the document holds is
|
|
82
|
+
// not the string that was written. Scanned here rather than through
|
|
83
|
+
// `String.prototype.isWellFormed`, which is younger than the browsers this
|
|
84
|
+
// package runs in.
|
|
85
|
+
function storedVerbatim(value) {
|
|
86
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
87
|
+
const unit = value.charCodeAt(i);
|
|
88
|
+
if (unit < 0xd800 || unit > 0xdfff)
|
|
89
|
+
continue;
|
|
90
|
+
// A trailing surrogate reached before its lead is unpaired by itself.
|
|
91
|
+
if (unit > 0xdbff)
|
|
92
|
+
return false;
|
|
93
|
+
i += 1;
|
|
94
|
+
const low = i < value.length ? value.charCodeAt(i) : 0;
|
|
95
|
+
if (low < 0xdc00 || low > 0xdfff)
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
55
100
|
// Per-chunk cap on a blob this Document pushes as several ops: a durability
|
|
56
101
|
// resync's missing slice (`computeResyncPush`) and a local commit no write
|
|
57
102
|
// budget bounded (`splitLocalUpdate`).
|
|
@@ -61,11 +106,10 @@ function changeSourceOf(event) {
|
|
|
61
106
|
// the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
|
|
62
107
|
// makes a heal the most expensive write the SDK performs: the slice a bulk
|
|
63
108
|
// write leaves behind is the whole document, and at frame size that is
|
|
64
|
-
// thousands of ops
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// tens of ops whose bytes go to the bucket instead.
|
|
109
|
+
// thousands of ops written one after another against the same document.
|
|
110
|
+
// Measured, a 40 MB divergence healed in 16 KB chunks overwhelmed that
|
|
111
|
+
// document's write path and left the join a tail it refused to serve. The
|
|
112
|
+
// same divergence at this size is tens of ops.
|
|
69
113
|
const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
|
|
70
114
|
// Hard ceiling for a single irreducible op (one Loro op, unsplittable),
|
|
71
115
|
// beyond which the chunk is skipped with a warning rather than retried
|
|
@@ -245,10 +289,11 @@ const READ_ONLY_MESSAGES = {
|
|
|
245
289
|
app: 'Document is read-only: this app is not permitted to edit it.',
|
|
246
290
|
unsaved: 'Document is read-only: a change could not be saved, so this copy holds ' +
|
|
247
291
|
'work the platform does not. Brass is replacing it with the platform copy.',
|
|
292
|
+
capacity: 'Stream is read-only: it reached the largest size the platform can keep. ' +
|
|
293
|
+
'Reopen it to keep working from the saved state.',
|
|
294
|
+
no_connection: 'Document is read-only: it opened with no live connection, so a change ' +
|
|
295
|
+
'would stay on this device.',
|
|
248
296
|
};
|
|
249
|
-
// Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
|
|
250
|
-
// toJSON / close. Construction is cheap: the Loro subscriber attaches
|
|
251
|
-
// lazily (see `_ensureLoroSubscribed`).
|
|
252
297
|
export class Document {
|
|
253
298
|
_doc;
|
|
254
299
|
_rootKey;
|
|
@@ -435,6 +480,11 @@ export class Document {
|
|
|
435
480
|
// surfaces a terminally-rejected op here so a "could not be saved" outcome
|
|
436
481
|
// reaches the app instead of being a silent forever-retry.
|
|
437
482
|
_errorReporter = null;
|
|
483
|
+
// Set when this document was opened expecting a live transport that was not
|
|
484
|
+
// available. A write then changes local state and reaches no server, so the
|
|
485
|
+
// first one is reported rather than dropped in silence.
|
|
486
|
+
_transportExpected = false;
|
|
487
|
+
_transportUnavailableReported = false;
|
|
438
488
|
_closed = false;
|
|
439
489
|
// Set true when the Document is parked in the SDK's per-(docId, branchId)
|
|
440
490
|
// cache after an app-level `close()`. The underlying LoroDoc, UndoController,
|
|
@@ -447,6 +497,14 @@ export class Document {
|
|
|
447
497
|
// next session). `_resume()`
|
|
448
498
|
// flips the flag back when the SDK hands the Doc to a new open() call.
|
|
449
499
|
_suspended = false;
|
|
500
|
+
// How many times this instance has been handed to an open. The park cache
|
|
501
|
+
// hands the SAME Document back on a reopen, so "this document is open" is
|
|
502
|
+
// not one interval per instance and a per-instance mark cannot say which
|
|
503
|
+
// open it was made in. Anything that spends a once-per-open budget on a
|
|
504
|
+
// Document (the import runner's one attempt) records this alongside its
|
|
505
|
+
// mark and re-reads it, so a reopen is a new open rather than the old one
|
|
506
|
+
// continuing.
|
|
507
|
+
_openEpoch = 0;
|
|
450
508
|
// Live = neither closed nor parked. This is the public-surface gate;
|
|
451
509
|
// internal ingress (remote ops, status emits) deliberately checks
|
|
452
510
|
// `_closed` alone, because a parked doc keeps applying remote state.
|
|
@@ -610,6 +668,126 @@ export class Document {
|
|
|
610
668
|
// Built over the same `map(mapKey)` / `list(orderKey)` handles, so the
|
|
611
669
|
// paged-store presentation and subscriptions carry through.
|
|
612
670
|
_orderedMapHandles = new Map();
|
|
671
|
+
/**
|
|
672
|
+
* A handle on the top-level text container at `key`, which `roots()` reports
|
|
673
|
+
* as kind `'text'`.
|
|
674
|
+
*
|
|
675
|
+
* Cached per key, so `doc.text('x') === doc.text('x')` holds as it does for
|
|
676
|
+
* `doc.map`, and a subscriber registered through one keeps firing across a
|
|
677
|
+
* version checkout.
|
|
678
|
+
*/
|
|
679
|
+
text(key) {
|
|
680
|
+
this._assertOpen();
|
|
681
|
+
const cacheKey = `text:${key}`;
|
|
682
|
+
const existing = this._rootHandles.get(cacheKey);
|
|
683
|
+
if (existing)
|
|
684
|
+
return existing.handle;
|
|
685
|
+
const owner = this._handleOwner;
|
|
686
|
+
const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
687
|
+
// Reads and writes resolve the active source per call, so a checkout
|
|
688
|
+
// needs no rebinding for them. A SUBSCRIPTION cannot work that way: it
|
|
689
|
+
// anchors a listener to one container, and a checkout preview is a
|
|
690
|
+
// different LoroDoc, so the anchor has to be moved. That is what makes
|
|
691
|
+
// this handle cached and registered like a map or a list, rather than a
|
|
692
|
+
// fresh object per call: the app holds the object the rebind reaches.
|
|
693
|
+
let anchor = activeSource().getText(key);
|
|
694
|
+
const subscribers = new Set();
|
|
695
|
+
let detach = null;
|
|
696
|
+
// The string this handle's own `update` has just written, armed only
|
|
697
|
+
// across the synchronous window in which `commit` fires the Loro
|
|
698
|
+
// subscriber. Materializing a text container is the one cost here that
|
|
699
|
+
// grows with a document's accumulated edit history rather than with its
|
|
700
|
+
// length, and `update` has already paid it once to diff against, so a
|
|
701
|
+
// fanout that materialized again would double the per-keystroke cost of
|
|
702
|
+
// exactly the field the README teaches. Anything else, a remote op or a
|
|
703
|
+
// checkout, finds it null and materializes.
|
|
704
|
+
let justWrote = null;
|
|
705
|
+
const fanout = (origin) => {
|
|
706
|
+
// Read for the local write alone. A change from any other source
|
|
707
|
+
// describes a container this handle did not write, so its value comes
|
|
708
|
+
// from the container even while a local write is arming one.
|
|
709
|
+
const value = (origin?.source === 'local' ? justWrote : null) ?? anchor.toString();
|
|
710
|
+
// Fired off a snapshot: a subscriber may unsubscribe (or register
|
|
711
|
+
// another) while it runs, and the set it is iterating is the one that
|
|
712
|
+
// changes underneath it.
|
|
713
|
+
const current = Array.from(subscribers);
|
|
714
|
+
for (const cb of current)
|
|
715
|
+
cb(value, origin);
|
|
716
|
+
};
|
|
717
|
+
const attach = () => {
|
|
718
|
+
if (detach !== null || subscribers.size === 0)
|
|
719
|
+
return;
|
|
720
|
+
detach = anchor.subscribe((event) => {
|
|
721
|
+
fanout({ source: owner.originOf(event) });
|
|
722
|
+
});
|
|
723
|
+
};
|
|
724
|
+
const handle = {
|
|
725
|
+
read: () => {
|
|
726
|
+
owner.assertReadable();
|
|
727
|
+
return activeSource().getText(key).toString();
|
|
728
|
+
},
|
|
729
|
+
update: (next) => {
|
|
730
|
+
owner.assertWritable();
|
|
731
|
+
owner.ensureLocalUpdatesSubscribed();
|
|
732
|
+
activeSource().getText(key).update(next);
|
|
733
|
+
// Armed after the write, so the container already holds `next` for
|
|
734
|
+
// every subscriber the commit reaches, and cleared however the
|
|
735
|
+
// commit returns: outside this window the fanout has no way to know
|
|
736
|
+
// the value is still current, and reads it. Armed only for a string
|
|
737
|
+
// the container holds verbatim, so a subscriber is never handed a
|
|
738
|
+
// value the document and its peers disagree with.
|
|
739
|
+
justWrote = storedVerbatim(next) ? next : null;
|
|
740
|
+
try {
|
|
741
|
+
owner.commitMutate();
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
justWrote = null;
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
subscribe: (cb) => {
|
|
748
|
+
owner.assertReadable();
|
|
749
|
+
subscribers.add(cb);
|
|
750
|
+
attach();
|
|
751
|
+
// No origin: a registration fire describes no authored transition,
|
|
752
|
+
// matching what a map subscriber's first fire carries.
|
|
753
|
+
cb(anchor.toString(), undefined);
|
|
754
|
+
return () => {
|
|
755
|
+
subscribers.delete(cb);
|
|
756
|
+
if (subscribers.size === 0 && detach !== null) {
|
|
757
|
+
detach();
|
|
758
|
+
detach = null;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
},
|
|
762
|
+
_rebind: (next) => {
|
|
763
|
+
if (detach !== null) {
|
|
764
|
+
detach();
|
|
765
|
+
detach = null;
|
|
766
|
+
}
|
|
767
|
+
anchor = next;
|
|
768
|
+
attach();
|
|
769
|
+
},
|
|
770
|
+
// A checkout or a return to head swaps the container rather than
|
|
771
|
+
// writing into it, so the catch-up fire carries no origin.
|
|
772
|
+
_resync: () => {
|
|
773
|
+
fanout(undefined);
|
|
774
|
+
},
|
|
775
|
+
// Detach the container subscription and forget the callbacks behind it,
|
|
776
|
+
// as a map or a list handle does: the Document drops its handle cache
|
|
777
|
+
// here, so an app still holding this one subscribes onto a handle
|
|
778
|
+
// nothing else reaches, and every callback registered before the park
|
|
779
|
+
// would come back with it.
|
|
780
|
+
_teardown: () => {
|
|
781
|
+
if (detach !== null) {
|
|
782
|
+
detach();
|
|
783
|
+
detach = null;
|
|
784
|
+
}
|
|
785
|
+
subscribers.clear();
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
this._rootHandles.set(cacheKey, { kind: 'text', key, handle });
|
|
789
|
+
return handle;
|
|
790
|
+
}
|
|
613
791
|
orderedMap(mapKey, orderKey) {
|
|
614
792
|
this._assertOpen();
|
|
615
793
|
const cacheKey = `${mapKey}\u0000${orderKey}`;
|
|
@@ -1243,12 +1421,18 @@ export class Document {
|
|
|
1243
1421
|
if (entry.kind === 'map') {
|
|
1244
1422
|
rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
|
|
1245
1423
|
}
|
|
1246
|
-
else {
|
|
1424
|
+
else if (entry.kind === 'list') {
|
|
1247
1425
|
rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
|
|
1248
1426
|
}
|
|
1427
|
+
else {
|
|
1428
|
+
entry.handle._rebind(source.getText(entry.key));
|
|
1429
|
+
}
|
|
1249
1430
|
}
|
|
1250
1431
|
for (const entry of this._rootHandles.values()) {
|
|
1251
|
-
|
|
1432
|
+
if (entry.kind === 'text')
|
|
1433
|
+
entry.handle._resync();
|
|
1434
|
+
else
|
|
1435
|
+
resyncRootHandle(entry.handle);
|
|
1252
1436
|
}
|
|
1253
1437
|
}
|
|
1254
1438
|
/**
|
|
@@ -1519,11 +1703,24 @@ export class Document {
|
|
|
1519
1703
|
}
|
|
1520
1704
|
const inner = await runtime.openLine(info.line_id);
|
|
1521
1705
|
const stream = new DocumentStream(name, inner, true);
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1706
|
+
try {
|
|
1707
|
+
// Opened while a version preview is active: bring the new stream to
|
|
1708
|
+
// the same point in time rather than showing it live under a
|
|
1709
|
+
// historical default.
|
|
1710
|
+
if (this._checkout !== null) {
|
|
1711
|
+
await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
// The line is joined and subscribed by now, and a rejected open is
|
|
1716
|
+
// dropped from the cache so the next call opens a second one. Nothing
|
|
1717
|
+
// else holds this Document, and nothing else would ever close it: the
|
|
1718
|
+
// parent closes the streams it has, and a stream whose open failed is
|
|
1719
|
+
// not one of them. So the failure disposes what it is abandoning,
|
|
1720
|
+
// rather than leaving a line applying remote ops for the life of the
|
|
1721
|
+
// page.
|
|
1722
|
+
stream._close();
|
|
1723
|
+
throw err;
|
|
1527
1724
|
}
|
|
1528
1725
|
return stream;
|
|
1529
1726
|
})();
|
|
@@ -1714,6 +1911,37 @@ export class Document {
|
|
|
1714
1911
|
this._errorReporter = reporter;
|
|
1715
1912
|
this._commandMenu.setErrorReporter(reporter);
|
|
1716
1913
|
}
|
|
1914
|
+
// Called by the factory when a document is opened with no live transport,
|
|
1915
|
+
// which outside tests means `wsBaseUrl` was left unset. Marking it is what
|
|
1916
|
+
// separates that from the snapshot reads and test stubs that legitimately
|
|
1917
|
+
// have no transport and must stay quiet.
|
|
1918
|
+
/** @internal */
|
|
1919
|
+
markTransportUnavailable() {
|
|
1920
|
+
this._transportExpected = true;
|
|
1921
|
+
}
|
|
1922
|
+
// A write on such a document reaches nothing: local state changes, the
|
|
1923
|
+
// reader sees their edit, and no server ever hears about it, so the edit is
|
|
1924
|
+
// gone on the next open with no failure anywhere. This is what makes that
|
|
1925
|
+
// observable. Once per document, because the condition holds for as long as
|
|
1926
|
+
// the document is open and repeating it per keystroke buries it.
|
|
1927
|
+
_reportTransportUnavailable() {
|
|
1928
|
+
if (!this._transportExpected)
|
|
1929
|
+
return;
|
|
1930
|
+
if (this._transportUnavailableReported)
|
|
1931
|
+
return;
|
|
1932
|
+
this._transportUnavailableReported = true;
|
|
1933
|
+
// Lock the document as well as reporting it. The write that reached this
|
|
1934
|
+
// point stays local, exactly as `unsaved` describes for a refused one, so
|
|
1935
|
+
// the same gate closes: an app that disables on `readOnly` stops offering
|
|
1936
|
+
// an editor that cannot save, and a later `mutate` throws a sentence
|
|
1937
|
+
// naming the reason instead of succeeding into nothing. The lock lands
|
|
1938
|
+
// after this write's own gate has passed, so the first one still runs
|
|
1939
|
+
// locally and only what follows it is refused.
|
|
1940
|
+
this._markReadOnly('no_connection');
|
|
1941
|
+
this._reportError(new Error('This document has no live connection, so edits stay on this device and ' +
|
|
1942
|
+
'reach no one else. Pass wsBaseUrl to Brass.init alongside the other ' +
|
|
1943
|
+
'base URLs: naming any one of them leaves wsBaseUrl unset.'));
|
|
1944
|
+
}
|
|
1717
1945
|
_reportError(err) {
|
|
1718
1946
|
if (this._errorReporter)
|
|
1719
1947
|
this._errorReporter(err);
|
|
@@ -1788,8 +2016,9 @@ export class Document {
|
|
|
1788
2016
|
* reconnects, and under which correlator, which is what separates a
|
|
1789
2017
|
* connection that is answering nothing from one that is merely behind.
|
|
1790
2018
|
*
|
|
1791
|
-
* @internal
|
|
1792
|
-
* to know a save is outstanding, and the SDK resolves a stuck
|
|
2019
|
+
* @internal Read on demand by Brass's own surfaces. An app reads
|
|
2020
|
+
* `savingCount` to know a save is outstanding, and the SDK resolves a stuck
|
|
2021
|
+
* one itself.
|
|
1793
2022
|
*/
|
|
1794
2023
|
diagnostics() {
|
|
1795
2024
|
const own = this._diagnosticsProvider?.() ?? {
|
|
@@ -1985,8 +2214,10 @@ export class Document {
|
|
|
1985
2214
|
_ensureLocalUpdatesSubscribed() {
|
|
1986
2215
|
if (this._unsubLocal)
|
|
1987
2216
|
return;
|
|
1988
|
-
if (!this._localOpHook)
|
|
2217
|
+
if (!this._localOpHook) {
|
|
2218
|
+
this._reportTransportUnavailable();
|
|
1989
2219
|
return;
|
|
2220
|
+
}
|
|
1990
2221
|
// Loro's `subscribeLocalUpdates` fires after every local commit
|
|
1991
2222
|
// with the op-blob bytes for that commit. Importing a remote op
|
|
1992
2223
|
// does NOT trigger this callback, so we don't risk feeding the
|
|
@@ -2357,6 +2588,45 @@ export class Document {
|
|
|
2357
2588
|
}
|
|
2358
2589
|
this._gone.mark(reason);
|
|
2359
2590
|
}
|
|
2591
|
+
/**
|
|
2592
|
+
* The top-level collections of the stream this document is open at, keyed
|
|
2593
|
+
* as it keys them, each naming the kind of handle that reads it.
|
|
2594
|
+
*
|
|
2595
|
+
* A partitioned document keeps its collections per stream, so this is the
|
|
2596
|
+
* open stream's answer rather than every stream's. `listStreams` names the
|
|
2597
|
+
* rest, and `stream.roots()` asks one of them the same question.
|
|
2598
|
+
*
|
|
2599
|
+
* `map` / `list` are get-or-create, and taking the wrong kind for a key
|
|
2600
|
+
* materializes a second container beside the real one, so read this before
|
|
2601
|
+
* taking a handle on a key you are unsure of. A collection holding nothing
|
|
2602
|
+
* is not listed, which is what keeps a handle taken but never written from
|
|
2603
|
+
* appearing as one.
|
|
2604
|
+
*
|
|
2605
|
+
* The cost is the number of collections rather than the size of the
|
|
2606
|
+
* document. In checkout, it describes the version being previewed.
|
|
2607
|
+
*/
|
|
2608
|
+
roots() {
|
|
2609
|
+
this._assertNotClosed();
|
|
2610
|
+
const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
|
|
2611
|
+
const found = [];
|
|
2612
|
+
for (const [key, containerId] of Object.entries(source.getShallowValue())) {
|
|
2613
|
+
const kind = rootKindOf(containerId);
|
|
2614
|
+
if (kind === null)
|
|
2615
|
+
continue;
|
|
2616
|
+
// Reading the container's own count, rather than its value, is what
|
|
2617
|
+
// keeps this off the projection: an empty collection is skipped without
|
|
2618
|
+
// anything being decoded.
|
|
2619
|
+
const container = source.getContainerById(containerId);
|
|
2620
|
+
if (container === undefined || rootIsEmpty(container))
|
|
2621
|
+
continue;
|
|
2622
|
+
found.push([key, kind]);
|
|
2623
|
+
}
|
|
2624
|
+
// Loro reports roots in registration order, which is an accident of who
|
|
2625
|
+
// wrote what first. Inserting in key order makes a reader's iteration
|
|
2626
|
+
// stable across opens.
|
|
2627
|
+
found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
2628
|
+
return Object.fromEntries(found);
|
|
2629
|
+
}
|
|
2360
2630
|
// Plain-JS projection of the current state.
|
|
2361
2631
|
toJSON() {
|
|
2362
2632
|
this._assertNotClosed();
|
|
@@ -2590,7 +2860,7 @@ export class Document {
|
|
|
2590
2860
|
// undo step, so a bulk write that correctly split into many ops comes back
|
|
2591
2861
|
// as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
|
|
2592
2862
|
// is refused outright and the redone edit never persists, and below it, it
|
|
2593
|
-
// still lands as
|
|
2863
|
+
// still lands as one very large write against the document.
|
|
2594
2864
|
//
|
|
2595
2865
|
// The ops already exist, so the only lever left is which spans they ship in.
|
|
2596
2866
|
// The blob names the counter range it covers, and re-exporting a sub-range
|
|
@@ -2690,7 +2960,10 @@ export class Document {
|
|
|
2690
2960
|
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
2691
2961
|
// fresh handles are minted on resume + next access.
|
|
2692
2962
|
for (const entry of this._rootHandles.values()) {
|
|
2693
|
-
|
|
2963
|
+
if (entry.kind === 'text')
|
|
2964
|
+
entry.handle._teardown();
|
|
2965
|
+
else
|
|
2966
|
+
teardownRootHandle(entry.handle);
|
|
2694
2967
|
}
|
|
2695
2968
|
this._rootHandles.clear();
|
|
2696
2969
|
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
@@ -2706,6 +2979,7 @@ export class Document {
|
|
|
2706
2979
|
/** @internal */
|
|
2707
2980
|
_resume() {
|
|
2708
2981
|
this._suspended = false;
|
|
2982
|
+
this._openEpoch += 1;
|
|
2709
2983
|
this._setStreamsSuspended(false);
|
|
2710
2984
|
// Pair of the `_suspend` presence gate: reopen the presence controller's
|
|
2711
2985
|
// outbound path and re-advertise this client's cursor on the resumed open.
|
|
@@ -2722,6 +2996,12 @@ export class Document {
|
|
|
2722
2996
|
get _isSuspended() {
|
|
2723
2997
|
return this._suspended;
|
|
2724
2998
|
}
|
|
2999
|
+
/** @internal Which open this Document is currently in (see `_openEpoch`).
|
|
3000
|
+
* Read by anything holding a per-open mark on a Document, since a park hit
|
|
3001
|
+
* hands the same instance to the next open. */
|
|
3002
|
+
get _openGeneration() {
|
|
3003
|
+
return this._openEpoch;
|
|
3004
|
+
}
|
|
2725
3005
|
close() {
|
|
2726
3006
|
if (this._closed)
|
|
2727
3007
|
return;
|
|
@@ -2761,7 +3041,10 @@ export class Document {
|
|
|
2761
3041
|
// per-container Loro subscription outlives the cache reference, and
|
|
2762
3042
|
// `_doc.free()` must not run with subscriptions still attached.
|
|
2763
3043
|
for (const entry of this._rootHandles.values()) {
|
|
2764
|
-
|
|
3044
|
+
if (entry.kind === 'text')
|
|
3045
|
+
entry.handle._teardown();
|
|
3046
|
+
else
|
|
3047
|
+
teardownRootHandle(entry.handle);
|
|
2765
3048
|
}
|
|
2766
3049
|
this._rootHandles.clear();
|
|
2767
3050
|
this._orderedMapHandles.clear();
|