@brass-build/client 0.2.0 → 0.4.0

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