@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/dist/documents.js CHANGED
@@ -8,14 +8,15 @@
8
8
  // 2. The `getOwnPropertyDescriptor` trap participates in
9
9
  // `Object.keys`; doing `loroMap.keys().includes(key)` there makes
10
10
  // `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
11
- import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from 'loro-crdt';
11
+ import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from './wasm-runtime.js';
12
12
  import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
13
13
  import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
14
14
  import { flattenPagedLookup, isPagedMarker, META_KEY, pagedRootKey, } from './record-paging.js';
15
15
  import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
16
- import { GoneSignal, WorkStatusSignal, DriveStatusSignal, } from './document-signals.js';
16
+ import { GoneSignal, WorkStatusSignal, DriveStatusSignal, DrivePublishTargetSignal, } from './document-signals.js';
17
17
  import { ThumbnailCapture, } from './document-thumbnail-capture.js';
18
18
  import { CheckpointScheduler, } from './document-checkpoints.js';
19
+ import { whenSaved } from './when-saved.js';
19
20
  import { BranchController, } from './document-branches.js';
20
21
  import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
21
22
  import { CommandMenu, } from './document-commands.js';
@@ -24,6 +25,31 @@ import { createEmitter } from './emitter.js';
24
25
  // The stream name `doc.stream()` resolves to the document's own default
25
26
  // state; mirrors the server's reserved name.
26
27
  export const DEFAULT_STREAM_NAME = 'default';
28
+ // Loro names a container's type in its id (`cid:root-notes:Map`), so the kind
29
+ // comes off the enumeration itself and costs no read. A type this package
30
+ // cannot hand anyone a handle for, and cannot describe as text either, is
31
+ // left out rather than guessed at, so every entry a reader gets is one they
32
+ // can act on.
33
+ function rootKindOf(containerId) {
34
+ const type = containerId.slice(containerId.lastIndexOf(':') + 1);
35
+ if (type === 'Map')
36
+ return 'map';
37
+ if (type === 'MovableList' || type === 'List')
38
+ return 'list';
39
+ if (type === 'Text')
40
+ return 'text';
41
+ return null;
42
+ }
43
+ // Maps count entries, lists and text count length. Both are the container's
44
+ // own counter rather than a projection of what it holds.
45
+ function rootIsEmpty(container) {
46
+ const sized = container;
47
+ if (typeof sized.size === 'number')
48
+ return sized.size === 0;
49
+ if (typeof sized.length === 'number')
50
+ return sized.length === 0;
51
+ return false;
52
+ }
27
53
  // Commit-origin tags stamped on every locally-produced commit via Loro's
28
54
  // `setNextCommitOrigin`, so `origin.source` can be recovered off the
29
55
  // event. Stamping the commit (not a JS-side flag) survives a
@@ -51,6 +77,26 @@ function changeSourceOf(event) {
51
77
  return 'redo';
52
78
  return 'local';
53
79
  }
80
+ // Whether a text container stores `value` unchanged. Loro's text is UTF-8, so
81
+ // an unpaired surrogate is stored as U+FFFD and what the document holds is
82
+ // not the string that was written. Scanned here rather than through
83
+ // `String.prototype.isWellFormed`, which is younger than the browsers this
84
+ // package runs in.
85
+ function storedVerbatim(value) {
86
+ for (let i = 0; i < value.length; i += 1) {
87
+ const unit = value.charCodeAt(i);
88
+ if (unit < 0xd800 || unit > 0xdfff)
89
+ continue;
90
+ // A trailing surrogate reached before its lead is unpaired by itself.
91
+ if (unit > 0xdbff)
92
+ return false;
93
+ i += 1;
94
+ const low = i < value.length ? value.charCodeAt(i) : 0;
95
+ if (low < 0xdc00 || low > 0xdfff)
96
+ return false;
97
+ }
98
+ return true;
99
+ }
54
100
  // Per-chunk cap on a blob this Document pushes as several ops: a durability
55
101
  // resync's missing slice (`computeResyncPush`) and a local commit no write
56
102
  // budget bounded (`splitLocalUpdate`).
@@ -60,11 +106,10 @@ function changeSourceOf(event) {
60
106
  // the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
61
107
  // makes a heal the most expensive write the SDK performs: the slice a bulk
62
108
  // write leaves behind is the whole document, and at frame size that is
63
- // thousands of ops, each one landing on the document's single storage
64
- // partition at exactly the size that stays inline on its row. Measured, a
65
- // 40 MB divergence healed in 16 KB chunks throttled the partition and left
66
- // the join a tail it refused to serve. The same divergence at this size is
67
- // tens of ops whose bytes go to the bucket instead.
109
+ // thousands of ops written one after another against the same document.
110
+ // Measured, a 40 MB divergence healed in 16 KB chunks overwhelmed that
111
+ // document's write path and left the join a tail it refused to serve. The
112
+ // same divergence at this size is tens of ops.
68
113
  const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
69
114
  // Hard ceiling for a single irreducible op (one Loro op, unsplittable),
70
115
  // beyond which the chunk is skipped with a warning rather than retried
@@ -244,10 +289,11 @@ const READ_ONLY_MESSAGES = {
244
289
  app: 'Document is read-only: this app is not permitted to edit it.',
245
290
  unsaved: 'Document is read-only: a change could not be saved, so this copy holds ' +
246
291
  'work the platform does not. Brass is replacing it with the platform copy.',
292
+ capacity: 'Stream is read-only: it reached the largest size the platform can keep. ' +
293
+ 'Reopen it to keep working from the saved state.',
294
+ no_connection: 'Document is read-only: it opened with no live connection, so a change ' +
295
+ 'would stay on this device.',
247
296
  };
248
- // Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
249
- // toJSON / close. Construction is cheap: the Loro subscriber attaches
250
- // lazily (see `_ensureLoroSubscribed`).
251
297
  export class Document {
252
298
  _doc;
253
299
  _rootKey;
@@ -341,9 +387,24 @@ export class Document {
341
387
  this._fireSubscribers({ kind: 'branches' });
342
388
  },
343
389
  });
390
+ // The version of the newest picture the server holds of this document, or
391
+ // `null` for none. Seeded from the join and moved by this instance's own
392
+ // uploads; a picture older than the document's head is what arms the seed
393
+ // trigger, so a placeholder taken before the content arrived is replaced
394
+ // once it does.
395
+ _thumbnailVersion = null;
344
396
  _thumbnails = new ThumbnailCapture({
345
- isCaptureAllowed: () => this._isLive && this._checkout === null,
397
+ // `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
398
+ // thumbnail from a reader it will not take writes from, so a capture
399
+ // there costs a request and a warn to be told so. The load lock is the
400
+ // other half of `readOnly` and must NOT gate this, because a document
401
+ // still hydrating is exactly the one the seed watch is armed for, and
402
+ // the arm happens while that lock is still on.
403
+ isCaptureAllowed: () => this._isLive && this._checkout === null && !this._forcedReadOnly,
346
404
  isAttached: () => this._isLive,
405
+ onCaptured: () => {
406
+ this._thumbnailVersion = this.currentSeq ?? 0;
407
+ },
347
408
  });
348
409
  _checkpoints = new CheckpointScheduler({
349
410
  docId: () => this._docId,
@@ -354,6 +415,7 @@ export class Document {
354
415
  if (this._undo)
355
416
  this._undo.sealStep();
356
417
  },
418
+ whenLocalOpsSaved: () => whenSaved(this),
357
419
  });
358
420
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
359
421
  // then throws with a clear message rather than silently dropping.
@@ -418,6 +480,11 @@ export class Document {
418
480
  // surfaces a terminally-rejected op here so a "could not be saved" outcome
419
481
  // reaches the app instead of being a silent forever-retry.
420
482
  _errorReporter = null;
483
+ // Set when this document was opened expecting a live transport that was not
484
+ // available. A write then changes local state and reaches no server, so the
485
+ // first one is reported rather than dropped in silence.
486
+ _transportExpected = false;
487
+ _transportUnavailableReported = false;
421
488
  _closed = false;
422
489
  // Set true when the Document is parked in the SDK's per-(docId, branchId)
423
490
  // cache after an app-level `close()`. The underlying LoroDoc, UndoController,
@@ -430,6 +497,14 @@ export class Document {
430
497
  // next session). `_resume()`
431
498
  // flips the flag back when the SDK hands the Doc to a new open() call.
432
499
  _suspended = false;
500
+ // How many times this instance has been handed to an open. The park cache
501
+ // hands the SAME Document back on a reopen, so "this document is open" is
502
+ // not one interval per instance and a per-instance mark cannot say which
503
+ // open it was made in. Anything that spends a once-per-open budget on a
504
+ // Document (the import runner's one attempt) records this alongside its
505
+ // mark and re-reads it, so a reopen is a new open rather than the old one
506
+ // continuing.
507
+ _openEpoch = 0;
433
508
  // Live = neither closed nor parked. This is the public-surface gate;
434
509
  // internal ingress (remote ops, status emits) deliberately checks
435
510
  // `_closed` alone, because a parked doc keeps applying remote state.
@@ -462,6 +537,9 @@ export class Document {
462
537
  // Drive sync state, latest-retained. `null` until the open factory
463
538
  // seeds it (drive-linked docs only) and on docs with no drive link.
464
539
  _driveStatus = new DriveStatusSignal();
540
+ // Where this document could be published when it mirrors nothing yet.
541
+ // Seeded from the open, emptied once it becomes a mirror.
542
+ _drivePublishTarget = new DrivePublishTargetSignal();
465
543
  // Document-scoped presence. `null` until the WS transport is wired
466
544
  // on, and on the offline / test path where there is no transport.
467
545
  presence = null;
@@ -590,6 +668,126 @@ export class Document {
590
668
  // Built over the same `map(mapKey)` / `list(orderKey)` handles, so the
591
669
  // paged-store presentation and subscriptions carry through.
592
670
  _orderedMapHandles = new Map();
671
+ /**
672
+ * A handle on the top-level text container at `key`, which `roots()` reports
673
+ * as kind `'text'`.
674
+ *
675
+ * Cached per key, so `doc.text('x') === doc.text('x')` holds as it does for
676
+ * `doc.map`, and a subscriber registered through one keeps firing across a
677
+ * version checkout.
678
+ */
679
+ text(key) {
680
+ this._assertOpen();
681
+ const cacheKey = `text:${key}`;
682
+ const existing = this._rootHandles.get(cacheKey);
683
+ if (existing)
684
+ return existing.handle;
685
+ const owner = this._handleOwner;
686
+ const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
687
+ // Reads and writes resolve the active source per call, so a checkout
688
+ // needs no rebinding for them. A SUBSCRIPTION cannot work that way: it
689
+ // anchors a listener to one container, and a checkout preview is a
690
+ // different LoroDoc, so the anchor has to be moved. That is what makes
691
+ // this handle cached and registered like a map or a list, rather than a
692
+ // fresh object per call: the app holds the object the rebind reaches.
693
+ let anchor = activeSource().getText(key);
694
+ const subscribers = new Set();
695
+ let detach = null;
696
+ // The string this handle's own `update` has just written, armed only
697
+ // across the synchronous window in which `commit` fires the Loro
698
+ // subscriber. Materializing a text container is the one cost here that
699
+ // grows with a document's accumulated edit history rather than with its
700
+ // length, and `update` has already paid it once to diff against, so a
701
+ // fanout that materialized again would double the per-keystroke cost of
702
+ // exactly the field the README teaches. Anything else, a remote op or a
703
+ // checkout, finds it null and materializes.
704
+ let justWrote = null;
705
+ const fanout = (origin) => {
706
+ // Read for the local write alone. A change from any other source
707
+ // describes a container this handle did not write, so its value comes
708
+ // from the container even while a local write is arming one.
709
+ const value = (origin?.source === 'local' ? justWrote : null) ?? anchor.toString();
710
+ // Fired off a snapshot: a subscriber may unsubscribe (or register
711
+ // another) while it runs, and the set it is iterating is the one that
712
+ // changes underneath it.
713
+ const current = Array.from(subscribers);
714
+ for (const cb of current)
715
+ cb(value, origin);
716
+ };
717
+ const attach = () => {
718
+ if (detach !== null || subscribers.size === 0)
719
+ return;
720
+ detach = anchor.subscribe((event) => {
721
+ fanout({ source: owner.originOf(event) });
722
+ });
723
+ };
724
+ const handle = {
725
+ read: () => {
726
+ owner.assertReadable();
727
+ return activeSource().getText(key).toString();
728
+ },
729
+ update: (next) => {
730
+ owner.assertWritable();
731
+ owner.ensureLocalUpdatesSubscribed();
732
+ activeSource().getText(key).update(next);
733
+ // Armed after the write, so the container already holds `next` for
734
+ // every subscriber the commit reaches, and cleared however the
735
+ // commit returns: outside this window the fanout has no way to know
736
+ // the value is still current, and reads it. Armed only for a string
737
+ // the container holds verbatim, so a subscriber is never handed a
738
+ // value the document and its peers disagree with.
739
+ justWrote = storedVerbatim(next) ? next : null;
740
+ try {
741
+ owner.commitMutate();
742
+ }
743
+ finally {
744
+ justWrote = null;
745
+ }
746
+ },
747
+ subscribe: (cb) => {
748
+ owner.assertReadable();
749
+ subscribers.add(cb);
750
+ attach();
751
+ // No origin: a registration fire describes no authored transition,
752
+ // matching what a map subscriber's first fire carries.
753
+ cb(anchor.toString(), undefined);
754
+ return () => {
755
+ subscribers.delete(cb);
756
+ if (subscribers.size === 0 && detach !== null) {
757
+ detach();
758
+ detach = null;
759
+ }
760
+ };
761
+ },
762
+ _rebind: (next) => {
763
+ if (detach !== null) {
764
+ detach();
765
+ detach = null;
766
+ }
767
+ anchor = next;
768
+ attach();
769
+ },
770
+ // A checkout or a return to head swaps the container rather than
771
+ // writing into it, so the catch-up fire carries no origin.
772
+ _resync: () => {
773
+ fanout(undefined);
774
+ },
775
+ // Detach the container subscription and forget the callbacks behind it,
776
+ // as a map or a list handle does: the Document drops its handle cache
777
+ // here, so an app still holding this one subscribes onto a handle
778
+ // nothing else reaches, and every callback registered before the park
779
+ // would come back with it.
780
+ _teardown: () => {
781
+ if (detach !== null) {
782
+ detach();
783
+ detach = null;
784
+ }
785
+ subscribers.clear();
786
+ },
787
+ };
788
+ this._rootHandles.set(cacheKey, { kind: 'text', key, handle });
789
+ return handle;
790
+ }
593
791
  orderedMap(mapKey, orderKey) {
594
792
  this._assertOpen();
595
793
  const cacheKey = `${mapKey}\u0000${orderKey}`;
@@ -825,6 +1023,11 @@ export class Document {
825
1023
  // doc still imports.
826
1024
  if (this._closed)
827
1025
  return;
1026
+ // Arm the seed BEFORE the import, because this is the moment the content
1027
+ // arrives and the Loro subscriber cannot be relied on to report it: it is
1028
+ // attached lazily, so a document whose content lands before the session has
1029
+ // bound its canvas has nobody subscribed and the import fires nothing.
1030
+ this._armSeedIfPictureIsStale();
828
1031
  this._doc.import(snapshot);
829
1032
  // The undo stack pre-snapshot referenced ops the new snapshot
830
1033
  // supersedes. Drop both stacks so undo doesn't try to revert
@@ -1218,12 +1421,18 @@ export class Document {
1218
1421
  if (entry.kind === 'map') {
1219
1422
  rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
1220
1423
  }
1221
- else {
1424
+ else if (entry.kind === 'list') {
1222
1425
  rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
1223
1426
  }
1427
+ else {
1428
+ entry.handle._rebind(source.getText(entry.key));
1429
+ }
1224
1430
  }
1225
1431
  for (const entry of this._rootHandles.values()) {
1226
- resyncRootHandle(entry.handle);
1432
+ if (entry.kind === 'text')
1433
+ entry.handle._resync();
1434
+ else
1435
+ resyncRootHandle(entry.handle);
1227
1436
  }
1228
1437
  }
1229
1438
  /**
@@ -1300,6 +1509,21 @@ export class Document {
1300
1509
  setThumbnailUploader(uploader) {
1301
1510
  this._thumbnails.setUploader(uploader);
1302
1511
  }
1512
+ // Tell the document which version the server's picture of it depicts, from
1513
+ // the join, and start a seed watch when that picture is behind the content.
1514
+ /** @internal */
1515
+ _setThumbnailVersion(version) {
1516
+ this._thumbnailVersion = version;
1517
+ this._armSeedIfPictureIsStale();
1518
+ }
1519
+ // A picture at or above the head depicts what the document currently holds.
1520
+ _armSeedIfPictureIsStale() {
1521
+ const head = this.currentSeq ?? 0;
1522
+ if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
1523
+ return;
1524
+ }
1525
+ this._thumbnails.armSeed();
1526
+ }
1303
1527
  // Install the checkpoint-create hook; `null` detaches.
1304
1528
  /** @internal */
1305
1529
  setCheckpointCreator(creator) {
@@ -1365,11 +1589,11 @@ export class Document {
1365
1589
  }
1366
1590
  return await this._driveResolver();
1367
1591
  }
1368
- // Fetch the original file behind an opaque-blob document: a short-lived
1369
- // presigned GET plus the file's name / content type / size. An app that
1370
- // knows the format fetches the bytes from `url`, parses them, and writes
1371
- // the structured content into this document through the normal handle
1372
- // surface (`doc.map('instances').mutate(...)`):
1592
+ // Fetch the file a document was uploaded as: a short-lived presigned GET
1593
+ // plus the file's name / content type / size. An app that knows the format
1594
+ // fetches the bytes from `url`, parses them, and writes the structured
1595
+ // content into this document through the normal handle surface
1596
+ // (`doc.map('instances').mutate(...)`):
1373
1597
  //
1374
1598
  // const src = await doc.sourceBlob();
1375
1599
  // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
@@ -1381,9 +1605,8 @@ export class Document {
1381
1605
  // each with its own URL, so a multi-stream format (a glTF with its
1382
1606
  // `.bin` buffer) fetches only the streams it needs.
1383
1607
  //
1384
- // Rejects with a `BrassError` when the document is not a stored opaque
1385
- // blob (it has no original file to hand back) or the caller cannot
1386
- // reach it (both 404).
1608
+ // Rejects with a `BrassError` when the document holds no such file, or
1609
+ // the caller cannot reach it (both 404).
1387
1610
  async sourceBlob() {
1388
1611
  this._assertOpen();
1389
1612
  if (this._docId === null) {
@@ -1480,11 +1703,24 @@ export class Document {
1480
1703
  }
1481
1704
  const inner = await runtime.openLine(info.line_id);
1482
1705
  const stream = new DocumentStream(name, inner, true);
1483
- // Opened while a version preview is active: bring the new stream to
1484
- // the same point in time rather than showing it live under a
1485
- // historical default.
1486
- if (this._checkout !== null) {
1487
- await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
1706
+ try {
1707
+ // Opened while a version preview is active: bring the new stream to
1708
+ // the same point in time rather than showing it live under a
1709
+ // historical default.
1710
+ if (this._checkout !== null) {
1711
+ await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
1712
+ }
1713
+ }
1714
+ catch (err) {
1715
+ // The line is joined and subscribed by now, and a rejected open is
1716
+ // dropped from the cache so the next call opens a second one. Nothing
1717
+ // else holds this Document, and nothing else would ever close it: the
1718
+ // parent closes the streams it has, and a stream whose open failed is
1719
+ // not one of them. So the failure disposes what it is abandoning,
1720
+ // rather than leaving a line applying remote ops for the life of the
1721
+ // page.
1722
+ stream._close();
1723
+ throw err;
1488
1724
  }
1489
1725
  return stream;
1490
1726
  })();
@@ -1675,6 +1911,37 @@ export class Document {
1675
1911
  this._errorReporter = reporter;
1676
1912
  this._commandMenu.setErrorReporter(reporter);
1677
1913
  }
1914
+ // Called by the factory when a document is opened with no live transport,
1915
+ // which outside tests means `wsBaseUrl` was left unset. Marking it is what
1916
+ // separates that from the snapshot reads and test stubs that legitimately
1917
+ // have no transport and must stay quiet.
1918
+ /** @internal */
1919
+ markTransportUnavailable() {
1920
+ this._transportExpected = true;
1921
+ }
1922
+ // A write on such a document reaches nothing: local state changes, the
1923
+ // reader sees their edit, and no server ever hears about it, so the edit is
1924
+ // gone on the next open with no failure anywhere. This is what makes that
1925
+ // observable. Once per document, because the condition holds for as long as
1926
+ // the document is open and repeating it per keystroke buries it.
1927
+ _reportTransportUnavailable() {
1928
+ if (!this._transportExpected)
1929
+ return;
1930
+ if (this._transportUnavailableReported)
1931
+ return;
1932
+ this._transportUnavailableReported = true;
1933
+ // Lock the document as well as reporting it. The write that reached this
1934
+ // point stays local, exactly as `unsaved` describes for a refused one, so
1935
+ // the same gate closes: an app that disables on `readOnly` stops offering
1936
+ // an editor that cannot save, and a later `mutate` throws a sentence
1937
+ // naming the reason instead of succeeding into nothing. The lock lands
1938
+ // after this write's own gate has passed, so the first one still runs
1939
+ // locally and only what follows it is refused.
1940
+ this._markReadOnly('no_connection');
1941
+ this._reportError(new Error('This document has no live connection, so edits stay on this device and ' +
1942
+ 'reach no one else. Pass wsBaseUrl to Brass.init alongside the other ' +
1943
+ 'base URLs: naming any one of them leaves wsBaseUrl unset.'));
1944
+ }
1678
1945
  _reportError(err) {
1679
1946
  if (this._errorReporter)
1680
1947
  this._errorReporter(err);
@@ -1749,8 +2016,9 @@ export class Document {
1749
2016
  * reconnects, and under which correlator, which is what separates a
1750
2017
  * connection that is answering nothing from one that is merely behind.
1751
2018
  *
1752
- * @internal Telemetry for Brass's own surfaces. An app reads `savingCount`
1753
- * to know a save is outstanding, and the SDK resolves a stuck one itself.
2019
+ * @internal Read on demand by Brass's own surfaces. An app reads
2020
+ * `savingCount` to know a save is outstanding, and the SDK resolves a stuck
2021
+ * one itself.
1754
2022
  */
1755
2023
  diagnostics() {
1756
2024
  const own = this._diagnosticsProvider?.() ?? {
@@ -1946,8 +2214,10 @@ export class Document {
1946
2214
  _ensureLocalUpdatesSubscribed() {
1947
2215
  if (this._unsubLocal)
1948
2216
  return;
1949
- if (!this._localOpHook)
2217
+ if (!this._localOpHook) {
2218
+ this._reportTransportUnavailable();
1950
2219
  return;
2220
+ }
1951
2221
  // Loro's `subscribeLocalUpdates` fires after every local commit
1952
2222
  // with the op-blob bytes for that commit. Importing a remote op
1953
2223
  // does NOT trigger this callback, so we don't risk feeding the
@@ -2039,9 +2309,9 @@ export class Document {
2039
2309
  this._maybeFireUndoState();
2040
2310
  // A local commit (mutate / undo / redo) schedules a debounced
2041
2311
  // thumbnail capture, when the app has registered a canvas via
2042
- // `useCanvas`. Remote ops never trigger a capture: a viewer or
2043
- // a passive collaborator must not re-upload a screenshot of
2044
- // someone else's edit.
2312
+ // `useCanvas`. The debounce is for the peer that drew the change, so
2313
+ // it is local-only: a passive collaborator re-uploading a screenshot
2314
+ // of someone else's edit is what a per-op capture would be.
2045
2315
  if (isLocalCommit) {
2046
2316
  this._thumbnails.schedule();
2047
2317
  // Same reasoning for the implicit auto-checkpoint: only the
@@ -2050,6 +2320,18 @@ export class Document {
2050
2320
  // idle period.
2051
2321
  this._checkpoints.scheduleAuto();
2052
2322
  }
2323
+ else {
2324
+ // Content arriving with no local commit behind it is what an import
2325
+ // looks like from here, and it moves the head past whatever picture
2326
+ // the document has. This DOES reach a reader who drew none of it,
2327
+ // which the debounce above deliberately does not: an import runs on
2328
+ // whichever client claimed it, so gating the seed on local authorship
2329
+ // would leave the documents that most need a picture without one.
2330
+ // Every such reader captures the same document at the same version,
2331
+ // and the targeted snapshot each upload enqueues is idempotent on
2332
+ // that version, so the concurrent seeds resolve themselves.
2333
+ this._armSeedIfPictureIsStale();
2334
+ }
2053
2335
  });
2054
2336
  }
2055
2337
  // Subscribe to document events. Returns an unsubscribe handle.
@@ -2245,6 +2527,49 @@ export class Document {
2245
2527
  if (this._closed)
2246
2528
  return;
2247
2529
  this._driveStatus.emit(status);
2530
+ // A document that has become a mirror publishes through its link from
2531
+ // now on, so the create-an-artifact offer goes with the arrival of the
2532
+ // status that replaces it.
2533
+ this._drivePublishTarget.set(null);
2534
+ }
2535
+ // Whether this document's export runs in THIS app, which is what decides
2536
+ // whether a push carries bytes. Not on `driveStatus`: it is a fact about
2537
+ // the caller rather than about the document, and it is all an app is told
2538
+ // about the exporter, never which app it is when it is not this one.
2539
+ _exportsHere = false;
2540
+ /** @internal */
2541
+ get _exportRunsHere() {
2542
+ return this._exportsHere;
2543
+ }
2544
+ /** @internal */
2545
+ _setExportRunsHere(runsHere) {
2546
+ this._exportsHere = runsHere;
2547
+ }
2548
+ // Internal: where this document can be published while it mirrors nothing.
2549
+ /** @internal */
2550
+ get _publishTarget() {
2551
+ return this._drivePublishTarget.target;
2552
+ }
2553
+ /** @internal */
2554
+ _setPublishTarget(target) {
2555
+ if (this._closed)
2556
+ return;
2557
+ this._drivePublishTarget.set(target);
2558
+ }
2559
+ // Internal: how the publish of a document that mirrors nothing is going.
2560
+ /** @internal */
2561
+ get _publishState() {
2562
+ return this._drivePublishTarget.state;
2563
+ }
2564
+ /** @internal */
2565
+ _emitPublishState(state) {
2566
+ if (this._closed)
2567
+ return;
2568
+ this._drivePublishTarget.setState(state);
2569
+ }
2570
+ /** @internal */
2571
+ _onPublishState(cb) {
2572
+ return this._drivePublishTarget.onState(cb);
2248
2573
  }
2249
2574
  // Internal: mark the doc evicted. Idempotent (multiple frames arrive
2250
2575
  // for the same doc when both `doc:<id>` and `presence:doc:<id>`
@@ -2263,6 +2588,45 @@ export class Document {
2263
2588
  }
2264
2589
  this._gone.mark(reason);
2265
2590
  }
2591
+ /**
2592
+ * The top-level collections of the stream this document is open at, keyed
2593
+ * as it keys them, each naming the kind of handle that reads it.
2594
+ *
2595
+ * A partitioned document keeps its collections per stream, so this is the
2596
+ * open stream's answer rather than every stream's. `listStreams` names the
2597
+ * rest, and `stream.roots()` asks one of them the same question.
2598
+ *
2599
+ * `map` / `list` are get-or-create, and taking the wrong kind for a key
2600
+ * materializes a second container beside the real one, so read this before
2601
+ * taking a handle on a key you are unsure of. A collection holding nothing
2602
+ * is not listed, which is what keeps a handle taken but never written from
2603
+ * appearing as one.
2604
+ *
2605
+ * The cost is the number of collections rather than the size of the
2606
+ * document. In checkout, it describes the version being previewed.
2607
+ */
2608
+ roots() {
2609
+ this._assertNotClosed();
2610
+ const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
2611
+ const found = [];
2612
+ for (const [key, containerId] of Object.entries(source.getShallowValue())) {
2613
+ const kind = rootKindOf(containerId);
2614
+ if (kind === null)
2615
+ continue;
2616
+ // Reading the container's own count, rather than its value, is what
2617
+ // keeps this off the projection: an empty collection is skipped without
2618
+ // anything being decoded.
2619
+ const container = source.getContainerById(containerId);
2620
+ if (container === undefined || rootIsEmpty(container))
2621
+ continue;
2622
+ found.push([key, kind]);
2623
+ }
2624
+ // Loro reports roots in registration order, which is an accident of who
2625
+ // wrote what first. Inserting in key order makes a reader's iteration
2626
+ // stable across opens.
2627
+ found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
2628
+ return Object.fromEntries(found);
2629
+ }
2266
2630
  // Plain-JS projection of the current state.
2267
2631
  toJSON() {
2268
2632
  this._assertNotClosed();
@@ -2496,7 +2860,7 @@ export class Document {
2496
2860
  // undo step, so a bulk write that correctly split into many ops comes back
2497
2861
  // as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
2498
2862
  // is refused outright and the redone edit never persists, and below it, it
2499
- // still lands as a single large row on the document's one storage partition.
2863
+ // still lands as one very large write against the document.
2500
2864
  //
2501
2865
  // The ops already exist, so the only lever left is which spans they ship in.
2502
2866
  // The blob names the counter range it covers, and re-exporting a sub-range
@@ -2580,6 +2944,7 @@ export class Document {
2580
2944
  this._gone.clearListeners();
2581
2945
  this._workStatus.clearListeners();
2582
2946
  this._driveStatus.clearListeners();
2947
+ this._drivePublishTarget.clearListeners();
2583
2948
  // Presence subscribes its OWN WS topic and keeps it alive across a park
2584
2949
  // (resume does not re-create the controller), so drop its app-side
2585
2950
  // change listeners here the same way as the sets above: a parked doc
@@ -2595,7 +2960,10 @@ export class Document {
2595
2960
  // the app's callback on a doc it has closed. Detaching first stops that;
2596
2961
  // fresh handles are minted on resume + next access.
2597
2962
  for (const entry of this._rootHandles.values()) {
2598
- teardownRootHandle(entry.handle);
2963
+ if (entry.kind === 'text')
2964
+ entry.handle._teardown();
2965
+ else
2966
+ teardownRootHandle(entry.handle);
2599
2967
  }
2600
2968
  this._rootHandles.clear();
2601
2969
  // Drop the ordered-map cache too. Each wrapper holds the specific root
@@ -2611,6 +2979,7 @@ export class Document {
2611
2979
  /** @internal */
2612
2980
  _resume() {
2613
2981
  this._suspended = false;
2982
+ this._openEpoch += 1;
2614
2983
  this._setStreamsSuspended(false);
2615
2984
  // Pair of the `_suspend` presence gate: reopen the presence controller's
2616
2985
  // outbound path and re-advertise this client's cursor on the resumed open.
@@ -2627,6 +2996,12 @@ export class Document {
2627
2996
  get _isSuspended() {
2628
2997
  return this._suspended;
2629
2998
  }
2999
+ /** @internal Which open this Document is currently in (see `_openEpoch`).
3000
+ * Read by anything holding a per-open mark on a Document, since a park hit
3001
+ * hands the same instance to the next open. */
3002
+ get _openGeneration() {
3003
+ return this._openEpoch;
3004
+ }
2630
3005
  close() {
2631
3006
  if (this._closed)
2632
3007
  return;
@@ -2645,7 +3020,7 @@ export class Document {
2645
3020
  this._undo.free();
2646
3021
  this._undo = null;
2647
3022
  }
2648
- this._thumbnails.cancel();
3023
+ this._thumbnails.destroy();
2649
3024
  this._checkpoints.cancelAuto();
2650
3025
  this._localOpHook = null;
2651
3026
  this._currentSeqProvider = null;
@@ -2661,11 +3036,15 @@ export class Document {
2661
3036
  this._gone.clearListeners();
2662
3037
  this._workStatus.clearListeners();
2663
3038
  this._driveStatus.clearListeners();
3039
+ this._drivePublishTarget.clearListeners();
2664
3040
  // Detach handle subscriptions before freeing the doc (below): a handle's
2665
3041
  // per-container Loro subscription outlives the cache reference, and
2666
3042
  // `_doc.free()` must not run with subscriptions still attached.
2667
3043
  for (const entry of this._rootHandles.values()) {
2668
- teardownRootHandle(entry.handle);
3044
+ if (entry.kind === 'text')
3045
+ entry.handle._teardown();
3046
+ else
3047
+ teardownRootHandle(entry.handle);
2669
3048
  }
2670
3049
  this._rootHandles.clear();
2671
3050
  this._orderedMapHandles.clear();