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