@brass-build/client 0.3.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.
Files changed (128) hide show
  1. package/AGENTS.md +26 -5
  2. package/CHANGELOG.md +50 -2
  3. package/README.md +233 -77
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +278 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +28 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +555 -11
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-contents.d.ts +15 -0
  19. package/dist/document-contents.d.ts.map +1 -0
  20. package/dist/document-contents.js +88 -0
  21. package/dist/document-contents.js.map +1 -0
  22. package/dist/document-streams.d.ts +4 -2
  23. package/dist/document-streams.d.ts.map +1 -1
  24. package/dist/document-streams.js +6 -0
  25. package/dist/document-streams.js.map +1 -1
  26. package/dist/documents.d.ts +45 -1
  27. package/dist/documents.d.ts.map +1 -1
  28. package/dist/documents.js +305 -22
  29. package/dist/documents.js.map +1 -1
  30. package/dist/export-guest.d.ts.map +1 -1
  31. package/dist/export-guest.js +5 -0
  32. package/dist/export-guest.js.map +1 -1
  33. package/dist/handle-types.d.ts +23 -0
  34. package/dist/handle-types.d.ts.map +1 -1
  35. package/dist/handles.d.ts +1 -1
  36. package/dist/handles.d.ts.map +1 -1
  37. package/dist/handles.js +1 -1
  38. package/dist/handles.js.map +1 -1
  39. package/dist/hosted-ui.d.ts +10 -0
  40. package/dist/hosted-ui.d.ts.map +1 -1
  41. package/dist/hosted-ui.js +115 -30
  42. package/dist/hosted-ui.js.map +1 -1
  43. package/dist/importer.d.ts.map +1 -1
  44. package/dist/importer.js +17 -6
  45. package/dist/importer.js.map +1 -1
  46. package/dist/index.d.ts +8 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +25 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal.d.ts +1 -0
  51. package/dist/internal.d.ts.map +1 -1
  52. package/dist/internal.js +1 -0
  53. package/dist/internal.js.map +1 -1
  54. package/dist/mutate-batching.js +2 -2
  55. package/dist/mutate-batching.js.map +1 -1
  56. package/dist/open-document.d.ts +1 -1
  57. package/dist/open-document.d.ts.map +1 -1
  58. package/dist/open-document.js +69 -34
  59. package/dist/open-document.js.map +1 -1
  60. package/dist/presence.d.ts +10 -0
  61. package/dist/presence.d.ts.map +1 -1
  62. package/dist/presence.js.map +1 -1
  63. package/dist/session-toolbar.d.ts +9 -0
  64. package/dist/session-toolbar.d.ts.map +1 -1
  65. package/dist/session-toolbar.js +60 -12
  66. package/dist/session-toolbar.js.map +1 -1
  67. package/dist/tail-ops.js +2 -2
  68. package/dist/tail-ops.js.map +1 -1
  69. package/dist/types.d.ts +0 -42
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/undo.d.ts +1 -1
  72. package/dist/undo.d.ts.map +1 -1
  73. package/dist/undo.js +1 -1
  74. package/dist/undo.js.map +1 -1
  75. package/dist/version-bar.d.ts.map +1 -1
  76. package/dist/version-bar.js +52 -2
  77. package/dist/version-bar.js.map +1 -1
  78. package/dist/version.d.ts +2 -2
  79. package/dist/version.js +1 -1
  80. package/dist/wasm-ready-gate.d.ts +13 -0
  81. package/dist/wasm-ready-gate.d.ts.map +1 -0
  82. package/dist/wasm-ready-gate.js +28 -0
  83. package/dist/wasm-ready-gate.js.map +1 -0
  84. package/dist/wasm-runtime.browser.d.ts +11 -0
  85. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  86. package/dist/wasm-runtime.browser.js +44 -0
  87. package/dist/wasm-runtime.browser.js.map +1 -0
  88. package/dist/wasm-runtime.d.ts +6 -0
  89. package/dist/wasm-runtime.d.ts.map +1 -0
  90. package/dist/wasm-runtime.js +20 -0
  91. package/dist/wasm-runtime.js.map +1 -0
  92. package/docs/app-manifest.md +335 -5
  93. package/docs/custom-chrome.md +3 -0
  94. package/docs/presence.md +12 -0
  95. package/docs/services.md +265 -0
  96. package/docs/text-fields.md +191 -0
  97. package/package.json +4 -5
  98. package/src/acting-organization.ts +396 -0
  99. package/src/api-headers.ts +7 -7
  100. package/src/call-service.ts +354 -0
  101. package/src/client.ts +618 -13
  102. package/src/document-contents.ts +104 -0
  103. package/src/document-streams.ts +15 -1
  104. package/src/documents.ts +340 -20
  105. package/src/export-guest.ts +5 -0
  106. package/src/handle-types.ts +35 -0
  107. package/src/handles.ts +1 -1
  108. package/src/hosted-ui.ts +155 -29
  109. package/src/importer.ts +17 -6
  110. package/src/index.ts +43 -4
  111. package/src/internal.ts +4 -0
  112. package/src/mutate-batching.ts +2 -2
  113. package/src/open-document.ts +79 -38
  114. package/src/presence.ts +6 -0
  115. package/src/session-toolbar.ts +80 -12
  116. package/src/tail-ops.ts +2 -2
  117. package/src/types.ts +6 -4
  118. package/src/undo.ts +1 -1
  119. package/src/version-bar.ts +53 -2
  120. package/src/version.ts +1 -1
  121. package/src/wasm-ready-gate.ts +30 -0
  122. package/src/wasm-runtime.browser.ts +48 -0
  123. package/src/wasm-runtime.ts +21 -0
  124. package/dist/vite.d.ts +0 -31
  125. package/dist/vite.d.ts.map +0 -1
  126. package/dist/vite.js +0 -161
  127. package/dist/vite.js.map +0 -1
  128. 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
+ }
@@ -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 { ListHandle, MapHandle, OrderedMapHandle } from './handle-types.js';
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 'loro-crdt';
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, each one landing on the document's single storage
226
- // partition at exactly the size that stays inline on its row. Measured, a
227
- // 40 MB divergence healed in 16 KB chunks throttled the partition and left
228
- // the join a tail it refused to serve. The same divergence at this size is
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
- resyncRootHandle(entry.handle);
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
- // Opened while a version preview is active: bring the new stream to
1913
- // the same point in time rather than showing it live under a
1914
- // historical default.
1915
- if (this._checkout !== null) {
1916
- await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
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 Telemetry for Brass's own surfaces. An app reads `savingCount`
2203
- * to know a save is outstanding, and the SDK resolves a stuck one itself.
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) return;
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 a single large row on the document's one storage partition.
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
- teardownRootHandle(entry.handle);
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
- teardownRootHandle(entry.handle);
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();