@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// A whole document read out as plain JSON, from the bytes a join delivers.
|
|
2
|
+
//
|
|
3
|
+
// The caller is a detached reader: `@brass-build/cli`, which fetches a
|
|
4
|
+
// document's snapshot and tail and wants what it holds, with no session, no
|
|
5
|
+
// socket, and no app around it. That read is the store's own business, so it
|
|
6
|
+
// lives here beside `flattenPagedLookup` rather than being reassembled by
|
|
7
|
+
// whoever needs it. A second implementation of the paging precedence is the
|
|
8
|
+
// bug this module exists to prevent.
|
|
9
|
+
//
|
|
10
|
+
// The whole document, not a window over it. A reader building against an
|
|
11
|
+
// example file needs the record TYPES the file carries, and a prefix of a
|
|
12
|
+
// store does not carry them: an import arrives grouped by entity type, so the
|
|
13
|
+
// first records of a large file are a couple of types out of hundreds. The
|
|
14
|
+
// caller holding the document locally can answer any question about it; a
|
|
15
|
+
// caller holding a slice can only answer the ones the slice happens to cover.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here is bounded, and that is deliberate: this runs on a developer's
|
|
18
|
+
// machine, which is the only place a large document fits. The platform's own
|
|
19
|
+
// join refuses rather than serving a partial document, so the bytes handed in
|
|
20
|
+
// are complete or they are not served at all.
|
|
21
|
+
|
|
22
|
+
// Through the runtime seam, not from `loro-crdt` directly: which engine build
|
|
23
|
+
// this package carries is decided by the two `wasm-runtime` halves, and a
|
|
24
|
+
// third module naming the dependency is an import the browser substitution
|
|
25
|
+
// does not reach. The reader is synchronous by design, so it takes the engine
|
|
26
|
+
// and not `ensureWasmReady`: its caller holds the bytes already, which means
|
|
27
|
+
// something upstream has opened a document and readied the runtime.
|
|
28
|
+
import { LoroDoc } from './wasm-runtime.js';
|
|
29
|
+
import {
|
|
30
|
+
META_KEY,
|
|
31
|
+
flattenPagedLookup,
|
|
32
|
+
isPagedMarker,
|
|
33
|
+
pagedRootKey,
|
|
34
|
+
} from './record-paging.js';
|
|
35
|
+
|
|
36
|
+
/** A document's root containers, each read whole, keyed as the document
|
|
37
|
+
* keys them. A map root reads back as the object an app sees through
|
|
38
|
+
* `doc.map(key)`, a list as its items, a text root as its string. The JSON
|
|
39
|
+
* types tell them apart, so nothing here restates the container kind. */
|
|
40
|
+
export type DocumentContents = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Materialise a document from a join's bytes and read every root.
|
|
44
|
+
*
|
|
45
|
+
* `snapshot` is the compacted state (null for a document that has never been
|
|
46
|
+
* compacted) and `ops` the tail that follows it, in seq order. Both are the
|
|
47
|
+
* shapes `GET /documents/{docId}?include=snapshot` delivers, once its inline
|
|
48
|
+
* and presigned entries are resolved to bytes.
|
|
49
|
+
*/
|
|
50
|
+
export function readDocumentContents(
|
|
51
|
+
snapshot: Uint8Array | null,
|
|
52
|
+
ops: readonly Uint8Array[],
|
|
53
|
+
): DocumentContents {
|
|
54
|
+
const doc = new LoroDoc();
|
|
55
|
+
if (snapshot !== null) doc.import(snapshot);
|
|
56
|
+
for (const op of ops) doc.import(op);
|
|
57
|
+
return readRoots(doc);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A root container id is `cid:root-<name>:<Type>`; the name arrives as the
|
|
61
|
+
// key, so only the type suffix is read.
|
|
62
|
+
function containerType(containerId: string): string {
|
|
63
|
+
return containerId.slice(containerId.lastIndexOf(':') + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readRoots(doc: LoroDoc): DocumentContents {
|
|
67
|
+
const contents: DocumentContents = {};
|
|
68
|
+
// Root ids only, so the enumeration converts nothing: each root below
|
|
69
|
+
// decides its own read.
|
|
70
|
+
for (const [key, containerId] of Object.entries(doc.getShallowValue())) {
|
|
71
|
+
switch (containerType(String(containerId))) {
|
|
72
|
+
case 'Map':
|
|
73
|
+
contents[key] = readMapRoot(doc, key);
|
|
74
|
+
break;
|
|
75
|
+
case 'List':
|
|
76
|
+
contents[key] = doc.getList(key).toJSON();
|
|
77
|
+
break;
|
|
78
|
+
case 'MovableList':
|
|
79
|
+
contents[key] = doc.getMovableList(key).toJSON();
|
|
80
|
+
break;
|
|
81
|
+
case 'Text':
|
|
82
|
+
contents[key] = doc.getText(key).toString();
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
// A container type this build does not read. Named with a null so
|
|
86
|
+
// the root's existence survives the read rather than the document
|
|
87
|
+
// appearing not to have it.
|
|
88
|
+
contents[key] = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return contents;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readMapRoot(doc: LoroDoc, key: string): Record<string, unknown> {
|
|
95
|
+
// Whether the store is paged is read from the root's own `meta` key, which
|
|
96
|
+
// is authoritative: in an unpaged store every key is a record key, a
|
|
97
|
+
// reserved-looking one included, because nothing refused it before the
|
|
98
|
+
// store was written.
|
|
99
|
+
const paged = isPagedMarker(doc.getMap(META_KEY).get(pagedRootKey(key)));
|
|
100
|
+
// One bulk conversion, then the flatten: a per-container walk of a large
|
|
101
|
+
// map costs work that grows with the map's own container count.
|
|
102
|
+
const lookup = doc.getMap(key).toJSON() as Record<string, unknown>;
|
|
103
|
+
return flattenPagedLookup(lookup, paged);
|
|
104
|
+
}
|
package/src/document-streams.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
DocumentSubscriber,
|
|
17
17
|
DocumentUnsubscribe,
|
|
18
18
|
EntityProxy,
|
|
19
|
+
RootKind,
|
|
19
20
|
} from './documents.js';
|
|
20
21
|
import type { DocumentStreamInfo } from './documents-namespace.js';
|
|
21
22
|
import type {
|
|
@@ -25,7 +26,12 @@ import type {
|
|
|
25
26
|
DocumentStatusUnsubscribe,
|
|
26
27
|
DocumentWorkStatus,
|
|
27
28
|
} from './document-signals.js';
|
|
28
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
ListHandle,
|
|
31
|
+
MapHandle,
|
|
32
|
+
OrderedMapHandle,
|
|
33
|
+
TextHandle,
|
|
34
|
+
} from './handle-types.js';
|
|
29
35
|
|
|
30
36
|
// The wiring `Document.stream()` runs on: list/create manifests over
|
|
31
37
|
// HTTP, and open a stream's line (join + WS subscribe) returning the
|
|
@@ -78,6 +84,14 @@ export class DocumentStream {
|
|
|
78
84
|
this._inner.mutate<E>(fn);
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
roots(): Record<string, RootKind> {
|
|
88
|
+
return this._inner.roots();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
text(key: string): TextHandle {
|
|
92
|
+
return this._inner.text(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
toJSON(): Record<string, unknown> {
|
|
82
96
|
return this._inner.toJSON();
|
|
83
97
|
}
|
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,
|
|
@@ -153,6 +157,46 @@ export type EntityProxy = Record<string, unknown>;
|
|
|
153
157
|
export type DocumentSubscriber = (event: SyntheticSubscribeEvent) => void;
|
|
154
158
|
export type DocumentUnsubscribe = () => void;
|
|
155
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
|
+
|
|
156
200
|
// The discriminated event `doc.subscribe` fires. Every event the document
|
|
157
201
|
// produces arrives in this shape, whether a content change or one of the
|
|
158
202
|
// SDK's own metadata ticks, so an app can ignore the argument and re-read in
|
|
@@ -213,6 +257,24 @@ function changeSourceOf(event: unknown): ChangeSource {
|
|
|
213
257
|
return 'local';
|
|
214
258
|
}
|
|
215
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
|
+
|
|
216
278
|
// Per-chunk cap on a blob this Document pushes as several ops: a durability
|
|
217
279
|
// resync's missing slice (`computeResyncPush`) and a local commit no write
|
|
218
280
|
// budget bounded (`splitLocalUpdate`).
|
|
@@ -222,11 +284,10 @@ function changeSourceOf(event: unknown): ChangeSource {
|
|
|
222
284
|
// the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
|
|
223
285
|
// makes a heal the most expensive write the SDK performs: the slice a bulk
|
|
224
286
|
// write leaves behind is the whole document, and at frame size that is
|
|
225
|
-
// thousands of ops
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// 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.
|
|
230
291
|
const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
|
|
231
292
|
|
|
232
293
|
// Hard ceiling for a single irreducible op (one Loro op, unsplittable),
|
|
@@ -454,7 +515,15 @@ export type ReadOnlyReason =
|
|
|
454
515
|
// A change this copy holds was refused, so it shows something no other
|
|
455
516
|
// reader has. Editing on top of it builds more of the same, and `reload()`
|
|
456
517
|
// is what returns the copy to what the platform holds.
|
|
457
|
-
| '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';
|
|
458
527
|
|
|
459
528
|
const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
460
529
|
link: 'Document is read-only: it was opened via a view-only share link.',
|
|
@@ -463,6 +532,12 @@ const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
|
|
|
463
532
|
unsaved:
|
|
464
533
|
'Document is read-only: a change could not be saved, so this copy holds ' +
|
|
465
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.',
|
|
466
541
|
};
|
|
467
542
|
|
|
468
543
|
export interface DocumentOptions {
|
|
@@ -504,6 +579,15 @@ type LocalOpSendHook = (opBlob: Uint8Array) => string;
|
|
|
504
579
|
// Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
|
|
505
580
|
// toJSON / close. Construction is cheap: the Loro subscriber attaches
|
|
506
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
|
+
|
|
507
591
|
export class Document {
|
|
508
592
|
private readonly _doc: LoroDoc;
|
|
509
593
|
private readonly _rootKey: string;
|
|
@@ -715,6 +799,11 @@ export class Document {
|
|
|
715
799
|
// surfaces a terminally-rejected op here so a "could not be saved" outcome
|
|
716
800
|
// reaches the app instead of being a silent forever-retry.
|
|
717
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;
|
|
718
807
|
private _closed = false;
|
|
719
808
|
// Set true when the Document is parked in the SDK's per-(docId, branchId)
|
|
720
809
|
// cache after an app-level `close()`. The underlying LoroDoc, UndoController,
|
|
@@ -727,6 +816,14 @@ export class Document {
|
|
|
727
816
|
// next session). `_resume()`
|
|
728
817
|
// flips the flag back when the SDK hands the Doc to a new open() call.
|
|
729
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;
|
|
730
827
|
// Live = neither closed nor parked. This is the public-surface gate;
|
|
731
828
|
// internal ingress (remote ops, status emits) deliberately checks
|
|
732
829
|
// `_closed` alone, because a parked doc keeps applying remote state.
|
|
@@ -824,6 +921,7 @@ export class Document {
|
|
|
824
921
|
string,
|
|
825
922
|
| { kind: 'map'; key: string; handle: MapHandle<unknown> }
|
|
826
923
|
| { kind: 'list'; key: string; handle: ListHandle<unknown> }
|
|
924
|
+
| { kind: 'text'; key: string; handle: RootTextHandle }
|
|
827
925
|
>();
|
|
828
926
|
|
|
829
927
|
// Names the pages this Document's writes may occupy. A page is one value,
|
|
@@ -905,6 +1003,125 @@ export class Document {
|
|
|
905
1003
|
string,
|
|
906
1004
|
OrderedMapHandle<unknown>
|
|
907
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
|
+
|
|
908
1125
|
orderedMap<T = unknown>(
|
|
909
1126
|
mapKey: string,
|
|
910
1127
|
orderKey: string,
|
|
@@ -1582,12 +1799,15 @@ export class Document {
|
|
|
1582
1799
|
for (const entry of this._rootHandles.values()) {
|
|
1583
1800
|
if (entry.kind === 'map') {
|
|
1584
1801
|
rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
|
|
1585
|
-
} else {
|
|
1802
|
+
} else if (entry.kind === 'list') {
|
|
1586
1803
|
rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
|
|
1804
|
+
} else {
|
|
1805
|
+
entry.handle._rebind(source.getText(entry.key));
|
|
1587
1806
|
}
|
|
1588
1807
|
}
|
|
1589
1808
|
for (const entry of this._rootHandles.values()) {
|
|
1590
|
-
|
|
1809
|
+
if (entry.kind === 'text') entry.handle._resync();
|
|
1810
|
+
else resyncRootHandle(entry.handle);
|
|
1591
1811
|
}
|
|
1592
1812
|
}
|
|
1593
1813
|
|
|
@@ -1909,11 +2129,23 @@ export class Document {
|
|
|
1909
2129
|
}
|
|
1910
2130
|
const inner = await runtime.openLine(info.line_id);
|
|
1911
2131
|
const stream = new DocumentStream(name, inner, true);
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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;
|
|
1917
2149
|
}
|
|
1918
2150
|
return stream;
|
|
1919
2151
|
})();
|
|
@@ -2120,6 +2352,41 @@ export class Document {
|
|
|
2120
2352
|
this._commandMenu.setErrorReporter(reporter);
|
|
2121
2353
|
}
|
|
2122
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
|
+
|
|
2123
2390
|
private _reportError(err: Error): void {
|
|
2124
2391
|
if (this._errorReporter) this._errorReporter(err);
|
|
2125
2392
|
else console.error('[Brass]', err);
|
|
@@ -2199,8 +2466,9 @@ export class Document {
|
|
|
2199
2466
|
* reconnects, and under which correlator, which is what separates a
|
|
2200
2467
|
* connection that is answering nothing from one that is merely behind.
|
|
2201
2468
|
*
|
|
2202
|
-
* @internal
|
|
2203
|
-
* 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.
|
|
2204
2472
|
*/
|
|
2205
2473
|
diagnostics(): DocSyncDiagnostics {
|
|
2206
2474
|
const own: DocSyncDiagnostics = this._diagnosticsProvider?.() ?? {
|
|
@@ -2407,7 +2675,10 @@ export class Document {
|
|
|
2407
2675
|
|
|
2408
2676
|
private _ensureLocalUpdatesSubscribed(): void {
|
|
2409
2677
|
if (this._unsubLocal) return;
|
|
2410
|
-
if (!this._localOpHook)
|
|
2678
|
+
if (!this._localOpHook) {
|
|
2679
|
+
this._reportTransportUnavailable();
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2411
2682
|
// Loro's `subscribeLocalUpdates` fires after every local commit
|
|
2412
2683
|
// with the op-blob bytes for that commit. Importing a remote op
|
|
2413
2684
|
// does NOT trigger this callback, so we don't risk feeding the
|
|
@@ -2796,6 +3067,45 @@ export class Document {
|
|
|
2796
3067
|
this._gone.mark(reason);
|
|
2797
3068
|
}
|
|
2798
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
|
+
|
|
2799
3109
|
// Plain-JS projection of the current state.
|
|
2800
3110
|
toJSON(): Record<string, unknown> {
|
|
2801
3111
|
this._assertNotClosed();
|
|
@@ -3043,7 +3353,7 @@ export class Document {
|
|
|
3043
3353
|
// undo step, so a bulk write that correctly split into many ops comes back
|
|
3044
3354
|
// as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
|
|
3045
3355
|
// is refused outright and the redone edit never persists, and below it, it
|
|
3046
|
-
// still lands as
|
|
3356
|
+
// still lands as one very large write against the document.
|
|
3047
3357
|
//
|
|
3048
3358
|
// The ops already exist, so the only lever left is which spans they ship in.
|
|
3049
3359
|
// The blob names the counter range it covers, and re-exporting a sub-range
|
|
@@ -3140,7 +3450,8 @@ export class Document {
|
|
|
3140
3450
|
// the app's callback on a doc it has closed. Detaching first stops that;
|
|
3141
3451
|
// fresh handles are minted on resume + next access.
|
|
3142
3452
|
for (const entry of this._rootHandles.values()) {
|
|
3143
|
-
|
|
3453
|
+
if (entry.kind === 'text') entry.handle._teardown();
|
|
3454
|
+
else teardownRootHandle(entry.handle);
|
|
3144
3455
|
}
|
|
3145
3456
|
this._rootHandles.clear();
|
|
3146
3457
|
// Drop the ordered-map cache too. Each wrapper holds the specific root
|
|
@@ -3157,6 +3468,7 @@ export class Document {
|
|
|
3157
3468
|
/** @internal */
|
|
3158
3469
|
_resume(): void {
|
|
3159
3470
|
this._suspended = false;
|
|
3471
|
+
this._openEpoch += 1;
|
|
3160
3472
|
this._setStreamsSuspended(false);
|
|
3161
3473
|
// Pair of the `_suspend` presence gate: reopen the presence controller's
|
|
3162
3474
|
// outbound path and re-advertise this client's cursor on the resumed open.
|
|
@@ -3175,6 +3487,13 @@ export class Document {
|
|
|
3175
3487
|
return this._suspended;
|
|
3176
3488
|
}
|
|
3177
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
|
+
|
|
3178
3497
|
close(): void {
|
|
3179
3498
|
if (this._closed) return;
|
|
3180
3499
|
this._closeStreams();
|
|
@@ -3213,7 +3532,8 @@ export class Document {
|
|
|
3213
3532
|
// per-container Loro subscription outlives the cache reference, and
|
|
3214
3533
|
// `_doc.free()` must not run with subscriptions still attached.
|
|
3215
3534
|
for (const entry of this._rootHandles.values()) {
|
|
3216
|
-
|
|
3535
|
+
if (entry.kind === 'text') entry.handle._teardown();
|
|
3536
|
+
else teardownRootHandle(entry.handle);
|
|
3217
3537
|
}
|
|
3218
3538
|
this._rootHandles.clear();
|
|
3219
3539
|
this._orderedMapHandles.clear();
|