@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,2045 @@
1
+ // SDK surface: `Brass.Document` plain-object proxy over Loro.
2
+ //
3
+ // Two load-bearing Loro gotchas:
4
+ // 1. `LoroDoc.subscribe(fn)` fires synchronously on every import,
5
+ // including the initial snapshot import (8.5 s on a 50k snapshot).
6
+ // So lazy-attach the Loro subscriber only on first `doc.subscribe`
7
+ // (see `_ensureLoroSubscribed`).
8
+ // 2. The `getOwnPropertyDescriptor` trap participates in
9
+ // `Object.keys`; doing `loroMap.keys().includes(key)` there makes
10
+ // `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
11
+ import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from 'loro-crdt';
12
+ import { MutateBatcher } from './mutate-batching.js';
13
+ import { UndoController, } from './undo.js';
14
+ import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, teardownRootHandle, wrapLoroMapProxy, } from './handles.js';
15
+ import { CURRENT_PAGING_SCHEME, isPagedByPageCount, META_KEY, PAGE_COUNT_KEY, PAGING_SCHEME_KEY, RECORDS_KEY, } from './record-paging.js';
16
+ import { GoneSignal, WorkStatusSignal, DriveStatusSignal, } from './document-signals.js';
17
+ import { ThumbnailCapture, } from './document-thumbnail-capture.js';
18
+ import { CheckpointScheduler, } from './document-checkpoints.js';
19
+ import { BranchController, } from './document-branches.js';
20
+ import { CommandMenu, } from './document-commands.js';
21
+ import { DocumentStream } from './document-streams.js';
22
+ import { createEmitter } from './emitter.js';
23
+ // The stream name `doc.stream()` resolves to the document's own default
24
+ // state; mirrors the server's reserved name.
25
+ export const DEFAULT_STREAM_NAME = 'default';
26
+ // Commit-origin tags stamped on every locally-produced commit via Loro's
27
+ // `setNextCommitOrigin`, so `origin.source` can be recovered off the
28
+ // event. Stamping the commit (not a JS-side flag) survives a
29
+ // microtask-deferred subscribe callback: the tag travels with the event.
30
+ // `mutate()` carries `MUTATE`; `undo()`/`redo()` stamp their own tag
31
+ // before driving the UndoManager so they read back as `'undo'`/`'redo'`
32
+ // not `'local'`. A remote op (`by === 'import'`) and a checkout
33
+ // (`by === 'checkout'`) need no tag.
34
+ const MUTATE_COMMIT_ORIGIN = 'brass.mutate';
35
+ const UNDO_COMMIT_ORIGIN = 'brass.undo';
36
+ const REDO_COMMIT_ORIGIN = 'brass.redo';
37
+ // Classify a Loro event by what produced it. The render path never calls
38
+ // this (it reconciles by value); it backs a change's `origin.source` for
39
+ // attribution and the SDK's own local-vs-remote side-effect gating
40
+ // (thumbnail capture, auto-checkpoint).
41
+ function changeSourceOf(event) {
42
+ const by = event.by;
43
+ if (by === 'import')
44
+ return 'remote';
45
+ if (by === 'checkout')
46
+ return 'checkout';
47
+ // by === 'local': disambiguate via the commit-origin tag.
48
+ const origin = event.origin;
49
+ if (origin === UNDO_COMMIT_ORIGIN)
50
+ return 'undo';
51
+ if (origin === REDO_COMMIT_ORIGIN)
52
+ return 'redo';
53
+ return 'local';
54
+ }
55
+ // Per-chunk cap on a resync-push blob (durability handshake). Each push
56
+ // ships as one op frame, and the transport carries 128 KB; the blob is
57
+ // base64-encoded (~1.33x) inside a JSON envelope, so the raw blob must
58
+ // stay well under that. 64 KB base64s to ~85 KB. A larger missing slice
59
+ // is split into multiple chunks (see `computeResyncPush`), each capped.
60
+ const RESYNC_PUSH_MAX_BYTES = 64 * 1024;
61
+ // Hard ceiling for a single irreducible op (one Loro op, unsplittable).
62
+ // Sized so the base64 frame still clears the 128 KB limit (90 KB base64s
63
+ // to ~120 KB plus envelope). An op this large cannot occur on a doc
64
+ // whose normal edits send at all (same frame limit), so this only guards
65
+ // a pathological op; it is skipped with a warning rather than retried
66
+ // forever, while the doc's other chunks still heal.
67
+ const RESYNC_PUSH_HARD_MAX_BYTES = 90 * 1024;
68
+ // Element-wise max of two Loro version vectors. `VersionVector` exposes
69
+ // no public join, so round-trip through its `Map<PeerID, number>` JSON
70
+ // form. Counters are exclusive ends, so the larger is more advanced.
71
+ // Used by the durability handshake to fold the durable tail into the
72
+ // snapshot's version vector.
73
+ function mergeVersionVectors(a, b) {
74
+ const merged = a.toJSON();
75
+ for (const [peer, counter] of b.toJSON()) {
76
+ const current = merged.get(peer);
77
+ if (current === undefined || counter > current)
78
+ merged.set(peer, counter);
79
+ }
80
+ return VersionVector.parseJSON(merged);
81
+ }
82
+ // Split half-open per-peer counter ranges into two halves by total op
83
+ // count, splitting at most one range across the boundary. `total` must
84
+ // be >= 2 so both halves are non-empty (the caller handles total === 1
85
+ // as the irreducible single-op case). Order is preserved.
86
+ function splitRangesByLength(ranges, total) {
87
+ const half = Math.floor(total / 2);
88
+ const first = [];
89
+ const second = [];
90
+ let acc = 0;
91
+ for (const r of ranges) {
92
+ const len = r.to - r.from;
93
+ if (acc >= half) {
94
+ second.push(r);
95
+ }
96
+ else if (acc + len <= half) {
97
+ first.push(r);
98
+ acc += len;
99
+ }
100
+ else {
101
+ const splitAt = r.from + (half - acc);
102
+ first.push({ peer: r.peer, from: r.from, to: splitAt });
103
+ second.push({ peer: r.peer, from: splitAt, to: r.to });
104
+ acc = half;
105
+ }
106
+ }
107
+ return [first, second];
108
+ }
109
+ // The record store's page count off a doc's `meta` map, or 0 when
110
+ // unpaged. `handles.ts` reads it to resolve which page a key hashes to
111
+ // AND to decide whether the store is paged at all (on every root-map
112
+ // read / edit). Read by PATH, not `getMap(META_KEY)`: `getMap` is
113
+ // get-or-create and would materialize an empty `meta` root on every
114
+ // unpaged document, polluting its wire shape; `getByPath` is a pure read
115
+ // returning undefined when `meta` does not exist.
116
+ function readStorePageCount(source) {
117
+ const pc = source.getByPath(`${META_KEY}/${PAGE_COUNT_KEY}`);
118
+ // Same "is paged" predicate as the formats/core readers, so the SDK
119
+ // handle path and the store readers cannot disagree on a malformed
120
+ // count (e.g. a non-finite value must read as unpaged everywhere).
121
+ return isPagedByPageCount(pc) ? pc : 0;
122
+ }
123
+ // The paging scheme a paged store was written under, off the same `meta` map
124
+ // as the page count (read by path, not `getMap`, for the same reason: a pure
125
+ // read that never materializes an empty `meta` root). A paged store predating
126
+ // the stamp reports the current scheme, so it stays editable; a store written
127
+ // under a newer scheme reports that number, and the edit path refuses it
128
+ // rather than hashing a key into the wrong page.
129
+ function readStorePagingScheme(source) {
130
+ const s = source.getByPath(`${META_KEY}/${PAGING_SCHEME_KEY}`);
131
+ return typeof s === 'number' && s > 0 ? s : CURRENT_PAGING_SCHEME;
132
+ }
133
+ // Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
134
+ // toJSON / close. Construction is cheap: the Loro subscriber attaches
135
+ // lazily (see `_ensureLoroSubscribed`).
136
+ export class Document {
137
+ _doc;
138
+ _rootKey;
139
+ // Server-minted `doc_id`, or `null` when constructed without one
140
+ // (test / offline path).
141
+ _docId;
142
+ _branchId;
143
+ // Active checkout state. `null` means the Document is on the live
144
+ // head of `_branchId`: edits are accepted, the WS stream applies
145
+ // remote ops directly to `_doc`. A non-null state means a historical
146
+ // version is being previewed: `_checkout.previewDoc` holds the
147
+ // materialised state at that version, reads go through it, and
148
+ // `mutate` throws. The LIVE `_doc` keeps receiving ops in the
149
+ // background; returning to head just clears the checkout state and
150
+ // the live state is already current.
151
+ _checkout;
152
+ // Bumped by every `_checkoutVersion` (before its await) and every
153
+ // `returnToHead`. A `_checkoutVersion` reads its token before awaiting the
154
+ // fetch and bails after if the token moved, so a return-to-live or a newer
155
+ // checkout issued during the fetch window wins instead of being overwritten
156
+ // when the stale fetch resolves. Without it, `returnToHead` sees
157
+ // `_checkout === null` mid-fetch, no-ops, and the resolving checkout strands
158
+ // the doc in the historical preview the user just left.
159
+ _checkoutGeneration = 0;
160
+ // True for Documents that have no live WS channel: the cold-link
161
+ // `Brass.openDocumentById(id, { version })` shape. Such Documents
162
+ // are frozen at the constructor-time version; `_checkoutVersion`
163
+ // and `returnToHead` reject. The live + checkout-on-demand path
164
+ // leaves this `false`.
165
+ _isFrozen;
166
+ // Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
167
+ // is a version-pinned open with no live channel). The anonymous
168
+ // link-guest open sets this: the Document keeps a live WS subscription
169
+ // and applies incoming remote ops, but local `mutate` is rejected, so a
170
+ // guest sees edits stream in while being unable to write.
171
+ _forcedReadOnly;
172
+ _root;
173
+ _entities;
174
+ // The UndoManager is constructed eagerly so the first call to
175
+ // `doc.mutate(...)` lands in the undo stack. Lazy construction
176
+ // (deferred to first `doc.undo()`) would miss every commit made
177
+ // before that point, which is not the behaviour callers expect.
178
+ // The UndoManager attaches its own `subscribeLocalUpdates` listener
179
+ // only; it does NOT fire on snapshot import, so the
180
+ // cold-load contract is unaffected (imports are not local commits).
181
+ _undo;
182
+ // One subscriber channel. Every event reaches every subscriber, whatever
183
+ // produced it (local mutate, remote import, undo, redo, checkout); the
184
+ // app reconciles by value and reads `origin.source` only when it has a
185
+ // non-render reason to know the cause. A throwing subscriber is swallowed
186
+ // so it cannot strand the others or the WS router.
187
+ _subs = createEmitter({ onError: () => { } });
188
+ // Subscribers registered via `subscribeUndoState`. Fired after
189
+ // every underlying Loro event when `canUndo()` or `canRedo()`
190
+ // changed value vs the last fire, so UI bound to undo/redo
191
+ // enabled state stays current without the consumer caring
192
+ // whether the originating event was a local mutate, undo,
193
+ // redo, or remote import.
194
+ _undoStateSubs = new Set();
195
+ _lastCanUndo = false;
196
+ _lastCanRedo = false;
197
+ _unsubLoro = null;
198
+ _unsubLocal = null;
199
+ // Local commits drive this hook with the resulting op blob; the hook
200
+ // returns a `clientOpId` tracked for `op_ack` correlation. In-flight
201
+ // op bytes (keyed by `clientOpId`) live in `_outstandingByClientId`;
202
+ // `applyRemoteOp` checks them to short-circuit self-originated
203
+ // round-trips.
204
+ _localOpHook = null;
205
+ // Materialises a historical version against the server; returns the
206
+ // snapshot bytes (or null) + tail op blobs + effective version.
207
+ _checkoutFetcher = null;
208
+ // The stream seq vector from the active preview, so a stream opened WHILE
209
+ // a preview is active can open at its historical seq too. Cleared on
210
+ // `returnToHead`.
211
+ _checkoutStreamSeqs = null;
212
+ // Transport-provided cursor for the branch's live head seq. A
213
+ // provider (not a stored number) so the Document never has to be
214
+ // notified of every op. `null` on the offline / test path; a
215
+ // read-only historical open reports its frozen `version` instead.
216
+ _currentSeqProvider = null;
217
+ _branchCtl = new BranchController({
218
+ docId: () => this._docId,
219
+ branchId: () => this._branchId,
220
+ isActive: () => this._isLive,
221
+ onBranchesChanged: () => {
222
+ this._fireSubscribers({ kind: 'branches' });
223
+ },
224
+ });
225
+ _thumbnails = new ThumbnailCapture({
226
+ isCaptureAllowed: () => this._isLive && this._checkout === null,
227
+ isAttached: () => this._isLive,
228
+ });
229
+ _checkpoints = new CheckpointScheduler({
230
+ docId: () => this._docId,
231
+ isAttached: () => this._isLive,
232
+ isAutoAllowed: () => this._isLive && this._checkout === null,
233
+ currentSeq: () => this.currentSeq,
234
+ sealUndoStep: () => {
235
+ if (this._undo)
236
+ this._undo.sealStep();
237
+ },
238
+ });
239
+ // Drive push hook. `null` on the offline / test path; `pushToDrive`
240
+ // then throws with a clear message rather than silently dropping.
241
+ _drivePusher = null;
242
+ // Drive divergence-resolution hook. Mirrors `_drivePusher`: `null` on
243
+ // the offline / test path, in which case `resolveDrive` throws with a
244
+ // clear message. Wired by `client.ts` to open the hosted resolve
245
+ // surface and route any new mirror doc through `openSiblingDoc`.
246
+ _driveResolver = null;
247
+ // Source-blob fetch hook. When supplied, `doc.sourceBlob()` routes
248
+ // through it (the namespace's `GET .../source-blob`). When absent
249
+ // (tests / offline), `sourceBlob` throws with a clear message rather
250
+ // than silently dropping. Wired by `client.ts` from
251
+ // `documents.sourceBlob`.
252
+ _sourceBlobFetcher = null;
253
+ // Cached doc name. `null` on offline / test docs.
254
+ _name = null;
255
+ // True once the reader has asked for the version-browsing surface without
256
+ // checking anything out (the hosted history dialog's browse-in-the-app
257
+ // outcome). The host's version bar latches on this as well as on a live
258
+ // checkout, so browsing can start at the live head.
259
+ _versionBrowsing = false;
260
+ _commandMenu = new CommandMenu({
261
+ docId: () => this._docId,
262
+ branchId: () => this._branchId,
263
+ readOnly: () => this.readOnly,
264
+ isLive: () => this._isLive,
265
+ canUndo: () => this.canUndo(),
266
+ canRedo: () => this.canRedo(),
267
+ undo: () => {
268
+ this.undo();
269
+ },
270
+ redo: () => {
271
+ this.redo();
272
+ },
273
+ checkoutVersion: (version) => this._checkoutVersion(version),
274
+ browseVersions: () => {
275
+ this._setVersionBrowsing(true);
276
+ },
277
+ branches: () => this._branchCtl.branches,
278
+ hasBranchProvider: () => this._branchCtl.hasProvider,
279
+ createBranch: (input) => this._branchCtl.create(input),
280
+ refreshBranches: () => this._refreshBranches(),
281
+ setName: (name) => this._setName(name),
282
+ onCommandsChanged: () => {
283
+ this._fireSubscribers({ kind: 'commands' });
284
+ },
285
+ });
286
+ // The clientOpId of every local op that has not yet been acked. The
287
+ // open-document layer drains this on `op_ack`. Tracking is keyed by
288
+ // clientOpId, not op-blob bytes, because clientOpId is what the
289
+ // server echoes back; the blob is opaque to Brass.
290
+ _outstandingByClientId = new Map();
291
+ // client_op_ids of the in-flight resync push (durability handshake). A
292
+ // push can be a single op or several chunks for a large divergence, so
293
+ // this is a set. The loop guard: while any chunk is unacked, the
294
+ // handshake skips a fresh push so a flurry of reconnects cannot re-push
295
+ // the same missing slice. Each id is removed by `resolveOutstandingOp`
296
+ // when its ack lands; the guard reopens once the set drains.
297
+ _resyncPushClientOpIds = new Set();
298
+ // Async-error sink, shared with the built-in commands. `_failOutstandingOp`
299
+ // surfaces a terminally-rejected op here so a "could not be saved" outcome
300
+ // reaches the app instead of being a silent forever-retry.
301
+ _errorReporter = null;
302
+ _closed = false;
303
+ // Set true when the Document is parked in the SDK's per-(docId, branchId)
304
+ // cache after an app-level `close()`. The underlying LoroDoc, UndoController,
305
+ // and WS subscription stay alive so the next `openDocumentById` for the
306
+ // same key returns this same instance with its undo stack intact. The
307
+ // user-facing API gates as if the Doc were closed: `mutate` throws, no
308
+ // events fire to app-side subscribers (`_subs`
309
+ // and the gone / work-status signal listeners are cleared on suspend so
310
+ // a stale handle from a prior session does not receive events from the
311
+ // next session). `_resume()`
312
+ // flips the flag back when the SDK hands the Doc to a new open() call.
313
+ _suspended = false;
314
+ // Live = neither closed nor parked. This is the public-surface gate;
315
+ // internal ingress (remote ops, status emits) deliberately checks
316
+ // `_closed` alone, because a parked doc keeps applying remote state.
317
+ get _isLive() {
318
+ return !this._closed && !this._suspended;
319
+ }
320
+ _assertOpen() {
321
+ if (!this._isLive)
322
+ throw new Error('Document is closed');
323
+ }
324
+ // Server-pushed lifecycle signals: `_gone` is fire-once-then-cache
325
+ // for `doc_gone` eviction; `_workStatus` is latest-retained,
326
+ // re-emit-per-phase for `doc_status`.
327
+ _gone = new GoneSignal();
328
+ _workStatus = new WorkStatusSignal();
329
+ // Drive sync state, latest-retained. `null` until the open factory
330
+ // seeds it (drive-linked docs only) and on docs with no drive link.
331
+ _driveStatus = new DriveStatusSignal();
332
+ // Document-scoped presence. `null` until the WS transport is wired
333
+ // on, and on the offline / test path where there is no transport.
334
+ presence = null;
335
+ constructor(loroDoc, options) {
336
+ this._doc = loroDoc;
337
+ this._rootKey = options?.rootKey ?? 'entities';
338
+ this._docId = options?.docId ?? null;
339
+ this._branchId = options?.branchId ?? null;
340
+ // A Document constructed with a `version` is a "frozen" cold-link
341
+ // open: the SDK materialised the historical state directly into
342
+ // `loroDoc` and there is no live WS channel. The Document is
343
+ // permanently in checkout against itself; `returnToHead` /
344
+ // `_checkoutVersion` are not meaningful on this shape.
345
+ const initialVersion = options?.version ?? null;
346
+ if (initialVersion !== null) {
347
+ this._isFrozen = true;
348
+ this._checkout = {
349
+ previewDoc: loroDoc,
350
+ previewRoot: loroDoc.getMap(this._rootKey),
351
+ version: initialVersion,
352
+ };
353
+ }
354
+ else {
355
+ this._isFrozen = false;
356
+ this._checkout = null;
357
+ }
358
+ // `readOnly` on a live (non-version) open is a forced read-only: the
359
+ // guest link view. A version open is already frozen, so it does not
360
+ // also need the forced flag.
361
+ this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
362
+ this._root = loroDoc.getMap(this._rootKey);
363
+ this._entities = wrapLoroMapProxy(this._root);
364
+ this._undo = new UndoController(this._doc, options?.undo);
365
+ }
366
+ // ---- Typed container handles (the primary read/write/subscribe surface) --
367
+ //
368
+ // Handles scope reads, writes, and subscriptions to one container in
369
+ // the underlying CRDT doc. The README leads with this surface; the
370
+ // doc-wide `mutate` / `subscribe` / `toJSON` methods below stay as a
371
+ // simpler-but-slower fallback for tiny docs and one-liner integrations.
372
+ //
373
+ // Identity: handles are cached per (key, kind), so
374
+ // `doc.map('entities') === doc.map('entities')` and bindings to the
375
+ // handle compare stable across renders.
376
+ // Cached root handles, keyed by kind + key so `doc.map('x')` and
377
+ // `doc.list('x')` coexist. One map, not one per kind, so a version checkout
378
+ // rebinds and each suspend / close tears down every root handle in a single
379
+ // traversal.
380
+ _rootHandles = new Map();
381
+ // Get a handle for a top-level LoroMap at `key`. The container is
382
+ // materialized in the underlying LoroDoc immediately (Loro's
383
+ // `getMap` is "get or create"); mutations through the handle do
384
+ // not need to create the container themselves.
385
+ map(key) {
386
+ this._assertOpen();
387
+ const cacheKey = `map:${key}`;
388
+ const existing = this._rootHandles.get(cacheKey);
389
+ if (existing)
390
+ return existing.handle;
391
+ // Source the LoroMap from the active source (live or checkout
392
+ // preview), same as `toJSON` does. Handles bound to a Document
393
+ // in checkout see the historical state via the preview doc.
394
+ const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
395
+ const loroMap = source.getMap(key);
396
+ // The page-count provider lets a handle over a large record store's
397
+ // `instances` map present its page containers transparently as a
398
+ // flat record map. It resolves the ACTIVE source at call time (not
399
+ // the one captured at handle creation): a checkout rebinds the
400
+ // handle's anchor to the preview doc, and the page count must come
401
+ // from that same doc or keyed reads hash into the wrong page (and a
402
+ // closure pinned to a preview doc would read a freed LoroDoc after
403
+ // returnToHead).
404
+ //
405
+ // Only the record store's paged root (`instances`) may report a page
406
+ // count. `readStorePageCount` reads the document-global
407
+ // `meta.page_count`, so handing it to every root map would make the
408
+ // sibling record-store roots (`meta`, `header`) and any app-owned
409
+ // root map read as paged once `instances` pages, routing their
410
+ // `get`/`has`/`mutate` through the page-hash path against a map that
411
+ // holds no page containers (`get` returns undefined for present keys;
412
+ // `mutate` writes a stray page container into a plain map). A non-
413
+ // record-store key gets a null provider and stays unpaged.
414
+ const handle = createRootMapHandle(loroMap, this._handleOwner, key === RECORDS_KEY
415
+ ? () => readStorePageCount(this._checkout !== null ? this._checkout.previewDoc : this._doc)
416
+ : undefined, key === RECORDS_KEY
417
+ ? () => readStorePagingScheme(this._checkout !== null ? this._checkout.previewDoc : this._doc)
418
+ : undefined);
419
+ this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
420
+ return handle;
421
+ }
422
+ // Get a handle for a top-level LoroList at `key`.
423
+ list(key) {
424
+ this._assertOpen();
425
+ const cacheKey = `list:${key}`;
426
+ const existing = this._rootHandles.get(cacheKey);
427
+ if (existing)
428
+ return existing.handle;
429
+ const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
430
+ const loroList = source.getList(key);
431
+ const handle = createRootListHandle(loroList, this._handleOwner);
432
+ this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
433
+ return handle;
434
+ }
435
+ // Get an ordered-map handle binding a keyed map at `mapKey` to a sibling
436
+ // order list at `orderKey`: adds append to the order list, deletes prune
437
+ // it, both in one commit, so an app cannot leave the two out of step.
438
+ // The record store every imported document carries is reached as
439
+ // `doc.orderedMap('instances', 'instance_order')`; the primitive itself
440
+ // is format-neutral (two root containers, no record-store knowledge).
441
+ // Built over the same `map(mapKey)` / `list(orderKey)` handles, so the
442
+ // paged-store presentation and subscriptions carry through.
443
+ _orderedMapHandles = new Map();
444
+ orderedMap(mapKey, orderKey) {
445
+ this._assertOpen();
446
+ const cacheKey = `${mapKey}\u0000${orderKey}`;
447
+ let h = this._orderedMapHandles.get(cacheKey);
448
+ if (!h) {
449
+ h = createOrderedMapHandle(this.map(mapKey), this.list(orderKey), this._handleOwner);
450
+ this._orderedMapHandles.set(cacheKey, h);
451
+ }
452
+ return h;
453
+ }
454
+ // Stable handle-owner closure: handles call back into this to
455
+ // participate in the standard commit + origin machinery. The bind
456
+ // happens once on first access; later mutations through any handle
457
+ // route through the same closure so the local-op hook, undo stack,
458
+ // and subscribe local-echo filter all fire correctly.
459
+ _handleOwnerCache = null;
460
+ get _handleOwner() {
461
+ if (this._handleOwnerCache)
462
+ return this._handleOwnerCache;
463
+ this._handleOwnerCache = {
464
+ assertWritable: () => {
465
+ this._assertOpen();
466
+ if (this._checkout !== null) {
467
+ throw new Error('Document is read-only: it is showing a historical version. ' +
468
+ 'Call `doc.returnToHead()` to edit.');
469
+ }
470
+ if (this._forcedReadOnly) {
471
+ throw new Error('Document is read-only: it was opened via a view-only share link.');
472
+ }
473
+ if (this._isLoadLocked()) {
474
+ throw new Error('Document is still loading its content; wait for the load to ' +
475
+ 'finish before editing (see `doc.onStatus`).');
476
+ }
477
+ },
478
+ ensureLocalUpdatesSubscribed: () => {
479
+ this._ensureLocalUpdatesSubscribed();
480
+ },
481
+ commitMutate: () => {
482
+ this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
483
+ this._doc.commit();
484
+ },
485
+ originOf: (event) => changeSourceOf(event),
486
+ };
487
+ return this._handleOwnerCache;
488
+ }
489
+ // Server-minted `doc_id`. Always a string for a Document returned
490
+ // from `Brass.openDocumentById` / `Brass.createDocument`; `null`
491
+ // only on the direct-construction (test / offline) path.
492
+ get id() {
493
+ return this._docId;
494
+ }
495
+ /**
496
+ * The branch this Document is open on, fixed for its lifetime (switching
497
+ * branches opens a new Document). `null` only on the offline / test path.
498
+ * The SDK reads it to scope WS / presence topics and thumbnail /
499
+ * checkpoint writes to the branch.
500
+ * @internal
501
+ */
502
+ get branchId() {
503
+ return this._branchId;
504
+ }
505
+ // True when the Document is currently showing a historical version
506
+ // (a checkout): `mutate` throws and edit affordances should be
507
+ // disabled. Reactive: flips on `_checkoutVersion(v)` /
508
+ // `returnToHead()`. `subscribe` fires when this changes, so apps
509
+ // bind it directly inside the subscribe handler.
510
+ get readOnly() {
511
+ // A doc that is still loading its first content (a drive-linked
512
+ // stub hydrating on first open) is read-only until the bytes
513
+ // arrive: editing it would write ops onto an empty doc that the
514
+ // incoming snapshot is about to replace. The SDK reloads the
515
+ // content automatically when the work finishes (see open-document's
516
+ // `reloadAfterHydration`); until then mutation is rejected. This is
517
+ // also what stops an app's "seed an empty doc" path from clobbering
518
+ // a doc that only looks empty because it has not loaded yet.
519
+ return (this._forcedReadOnly || this._checkout !== null || this._isLoadLocked());
520
+ }
521
+ // True when content-replacing work (hydration / extraction) is pending
522
+ // and editing must be locked so incoming bytes do not clobber local
523
+ // ops. A pending PUSH is outbound (it writes the doc out to the drive,
524
+ // nothing replaces local content), so the server marks it
525
+ // `blocking: false` and the doc stays editable while the importer
526
+ // runs. An older server omits `blocking`; absence is treated as
527
+ // blocking so hydration stays locked.
528
+ _isLoadLocked() {
529
+ const s = this._workStatus.status;
530
+ return s?.pending === true && s.blocking !== false;
531
+ }
532
+ /**
533
+ * The historical version this Document shows, or `null` for a live
534
+ * (head) open. Reactive alongside `readOnly`. The number is the
535
+ * server's effective version, which can round down from the
536
+ * requested version when compaction has TTL'd the exact op.
537
+ *
538
+ * An app that needs to know the doc is not editable reads the public
539
+ * `doc.readOnly` (true for a preview, a view-only link, or a load lock).
540
+ * @internal
541
+ */
542
+ get version() {
543
+ return this._checkout?.version ?? null;
544
+ }
545
+ /** @internal Whether the host should be showing its version-browsing
546
+ * surface for this document. True from a browse-in-the-app outcome and
547
+ * independent of `version`, so browsing can start at the live head; the
548
+ * host clears it when the reader closes that surface. */
549
+ get versionBrowsing() {
550
+ return this._versionBrowsing;
551
+ }
552
+ /** @internal Set by the host (on close) and by the browse-in-the-app
553
+ * command. Fires the `commands` signal so a host rendering off
554
+ * subscription re-reads it. */
555
+ _setVersionBrowsing(on) {
556
+ if (this._versionBrowsing === on)
557
+ return;
558
+ this._versionBrowsing = on;
559
+ this._fireSubscribers({ kind: 'commands' });
560
+ }
561
+ /** @internal The version this Document currently shows: the branch's live
562
+ * head seq for a head open (advanced by the WS op stream), or the frozen
563
+ * checkout version when a historical version is being previewed. The
564
+ * SDK's own anchors read it (a checkpoint seals at it, a thumbnail
565
+ * upload pins to it); no app-facing operation takes a seq, since version
566
+ * history is hosted chrome the app does not drive with numbers. */
567
+ get currentSeq() {
568
+ if (this._checkout !== null)
569
+ return this._checkout.version;
570
+ if (this._currentSeqProvider)
571
+ return this._currentSeqProvider();
572
+ return null;
573
+ }
574
+ /**
575
+ * Open a Document from a Loro snapshot (the SDK join path). Attaches
576
+ * the Loro subscriber lazily, AFTER import: subscribing at
577
+ * construction costs ~8 s on a 50k snapshot.
578
+ * @internal
579
+ */
580
+ static openFromSnapshot(snapshot, options) {
581
+ const loroDoc = new LoroDoc();
582
+ loroDoc.import(snapshot);
583
+ return new Document(loroDoc, options);
584
+ }
585
+ /**
586
+ * Construct an empty Document (empty-snapshot join case, and tests).
587
+ * @internal
588
+ */
589
+ static create(options) {
590
+ return new Document(new LoroDoc(), options);
591
+ }
592
+ // Apply a remote op blob produced by another peer (delivered via the
593
+ // WS `doc_op` server frame). Idempotent: Loro's import
594
+ // path dedupes by lamport, so re-applying a self-originated op is
595
+ // a no-op in terms of state. We still avoid the import work entirely
596
+ // when the caller can prove the op originated locally (matched by
597
+ // `clientOpId`); that saves the WASM round-trip on every echo and
598
+ // avoids firing the user-side `subscribe(cb)` listeners twice for
599
+ // the same mutation (optimistic mutate already fired them).
600
+ /** @internal */
601
+ applyRemoteOp(opBlob, opts) {
602
+ // A closed Document has freed its LoroDoc; a late transport closure (a
603
+ // reconnect/resync/reload that resolved after `close()`) must not import
604
+ // into freed WASM. Guard on `_closed` only, NOT `_suspended`: a parked
605
+ // doc keeps its LoroDoc alive and still applies remote ops.
606
+ if (this._closed)
607
+ return;
608
+ if (opts?.clientOpId && this._outstandingByClientId.has(opts.clientOpId)) {
609
+ // Echo of a local op: state is already up-to-date from the
610
+ // optimistic mutation; skip re-import to avoid double-fire on
611
+ // subscribers.
612
+ return;
613
+ }
614
+ this._doc.import(opBlob);
615
+ }
616
+ // Apply a snapshot blob over the top of an existing Document.
617
+ // Internal: the SDK calls this when the server delivers a fresh
618
+ // snapshot mid-session (e.g. after a compaction cycle). Persistence
619
+ // is server-authoritative, so there is no app-facing snapshot import.
620
+ /** @internal */
621
+ _applySnapshot(snapshot) {
622
+ // See `applyRemoteOp`: refuse the import on a closed (freed) doc so a late
623
+ // reload/resync closure can't touch freed WASM. `_closed` only; a parked
624
+ // doc still imports.
625
+ if (this._closed)
626
+ return;
627
+ this._doc.import(snapshot);
628
+ // The undo stack pre-snapshot referenced ops the new snapshot
629
+ // supersedes. Drop both stacks so undo doesn't try to revert
630
+ // into a state that no longer exists; the user's mental model
631
+ // here is "we just loaded fresh state", and silently retaining
632
+ // a now-broken redo entry would be the worst of both worlds.
633
+ if (this._undo)
634
+ this._undo.clear();
635
+ }
636
+ // Revert the most recent local commit. Returns `true` if an undo
637
+ // step was applied, `false` if the stack is empty. Remote peers'
638
+ // edits are not in this stack: Loro's UndoManager is local-peer-
639
+ // scoped, which is the user-visible behaviour every collaborative
640
+ // editor lands on (you don't get to undo your collaborator's typing).
641
+ //
642
+ // The reverted change is itself a local commit, so it ships over
643
+ // the WS transport like any other mutation and other peers see the
644
+ // state revert.
645
+ undo() {
646
+ this._assertOpen();
647
+ // During a checkout the UndoManager still tracks the live head, but reads
648
+ // go through the preview. Undoing would commit against the live `_doc` and
649
+ // ship over the WS transport while the previewing user sees nothing, a
650
+ // silent live edit. Match `canUndo()` (false during a checkout) and no-op.
651
+ if (this._checkout !== null)
652
+ return false;
653
+ // An undo produces a local commit exactly as `mutate` does, so it obeys
654
+ // the same read-only gates: a view-only guest link (`_forcedReadOnly`)
655
+ // and a pending content-replacing work-status (`_isLoadLocked()`), whose
656
+ // whole purpose is to keep local ops from racing the incoming bytes.
657
+ if (this._forcedReadOnly || this._isLoadLocked())
658
+ return false;
659
+ this._ensureLocalUpdatesSubscribed();
660
+ // Tag the commit the UndoManager is about to make so the resulting
661
+ // change reads back as `origin.source === 'undo'`, not `'local'`.
662
+ this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
663
+ return this._undo.undo();
664
+ }
665
+ // Re-apply the most recently undone local commit. Returns `true`
666
+ // if a redo step was applied, `false` if the stack is empty. The
667
+ // redo stack invalidates the moment a new local commit lands (the
668
+ // standard "fresh edit clears the redo branch" behaviour); remote
669
+ // edits do not clear redo, but Loro's UndoManager rebases redo
670
+ // entries against intervening remote state so the redone op
671
+ // composes correctly.
672
+ redo() {
673
+ this._assertOpen();
674
+ // No-op during a checkout, same reasoning as `undo()`.
675
+ if (this._checkout !== null)
676
+ return false;
677
+ // Same read-only gates as `undo()` / `mutate()`.
678
+ if (this._forcedReadOnly || this._isLoadLocked())
679
+ return false;
680
+ this._ensureLocalUpdatesSubscribed();
681
+ this._doc.setNextCommitOrigin(REDO_COMMIT_ORIGIN);
682
+ return this._undo.redo();
683
+ }
684
+ /**
685
+ * Seal the open undo group locally, so the NEXT local commit starts a
686
+ * fresh undo step. The doc-level path seals through checkpoints
687
+ * (`createCheckpoint` / the auto-checkpoint debounce); this is the
688
+ * boundary primitive a stream scope exposes directly, since streams
689
+ * carry no checkpoint machinery of their own.
690
+ * @internal
691
+ */
692
+ _sealUndoStep() {
693
+ if (this._undo)
694
+ this._undo.sealStep();
695
+ }
696
+ // Whether `undo()` would have an effect right now. Feeds the
697
+ // commands machinery and the undo-state notifier; apps consume it
698
+ // through `subscribeUndoState` snapshots.
699
+ canUndo() {
700
+ if (this._closed)
701
+ return false;
702
+ // During a checkout, the undo stack still tracks the live doc's
703
+ // history, but the UI is showing the historical preview and
704
+ // mutate is rejected. Surface that as "can't undo right now" so
705
+ // subscribeUndoState-bound buttons disable correctly.
706
+ if (this._checkout !== null)
707
+ return false;
708
+ // Fold the same read-only gates `undo()` enforces so a
709
+ // subscribeUndoState-bound button disables while the doc is a view-only
710
+ // guest link or load-locked, rather than inviting an undo the call
711
+ // would refuse.
712
+ if (this._forcedReadOnly || this._isLoadLocked())
713
+ return false;
714
+ return this._undo.canUndo();
715
+ }
716
+ // Whether `redo()` would have an effect right now. As above.
717
+ canRedo() {
718
+ if (this._closed)
719
+ return false;
720
+ if (this._checkout !== null)
721
+ return false;
722
+ if (this._forcedReadOnly || this._isLoadLocked())
723
+ return false;
724
+ return this._undo.canRedo();
725
+ }
726
+ // The plain-object proxy callers mutate. The mutation runs against
727
+ // the local LoroDoc immediately (optimistic UI), then commit + the
728
+ // `subscribeLocalUpdates` hook publish the resulting op blob over
729
+ // the WS transport when a `LocalOpSendHook` is installed.
730
+ //
731
+ // If no hook is installed (offline / migration script / unit tests),
732
+ // mutate still works but the op is not sent anywhere. This is the
733
+ // intentional escape hatch: `Brass.openDocumentById` wires the hook;
734
+ // `Document.create()` and direct construction stay
735
+ // hook-free so the class is usable without taking the WS dependency.
736
+ // `E` defaults to `EntityProxy` (the untyped Record<string, unknown>)
737
+ // for backwards-compat with existing callers. Apps with a typed
738
+ // entity shape pass it as a type argument:
739
+ //
740
+ // doc.mutate<MyEntity>((d) => {
741
+ // d.entities[id] = myTypedEntity;
742
+ // });
743
+ //
744
+ // The mutator's `entities` field has the same proxy under the hood
745
+ // regardless of `E`; the generic exists purely to widen the type at
746
+ // the assignment site so closed interfaces don't need an
747
+ // `as Record<string, unknown>` cast.
748
+ mutate(fn) {
749
+ this._assertOpen();
750
+ if (this._checkout !== null) {
751
+ throw new Error('Document is read-only: it is showing a historical version. ' +
752
+ 'Call `doc.returnToHead()` to edit.');
753
+ }
754
+ // A view-only share-link (guest) open is a live head held read-only:
755
+ // remote ops still stream in, but a local edit must be refused. The
756
+ // `_checkout` guard above only covers the version-pinned case, so this
757
+ // is a separate branch.
758
+ if (this._forcedReadOnly) {
759
+ throw new Error('Document is read-only: it was opened via a view-only share link.');
760
+ }
761
+ if (this._isLoadLocked()) {
762
+ throw new Error('Document is still loading its content; wait for the load to ' +
763
+ 'finish before editing (see `doc.onStatus`).');
764
+ }
765
+ this._ensureLocalUpdatesSubscribed();
766
+ // A fresh proxy for the call, because the batcher rides it: the
767
+ // long-lived `this._entities` must stay batcher-free for the reads
768
+ // that go through it outside a mutation.
769
+ const batcher = new MutateBatcher(() => {
770
+ this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
771
+ this._doc.commit();
772
+ });
773
+ fn({
774
+ entities: wrapLoroMapProxy(this._root, batcher),
775
+ });
776
+ // Stamp the commit so `subscribe`'s default path can identify
777
+ // and suppress the local-mutate echo. `setNextCommitOrigin`
778
+ // applies to the very next commit, and `commit()` consumes it.
779
+ this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
780
+ this._doc.commit();
781
+ }
782
+ // Install / replace the local-op send hook; `null` detaches it.
783
+ //
784
+ // The hook is invoked synchronously from Loro's
785
+ // `subscribeLocalUpdates` callback, which fires immediately after
786
+ // `commit()` returns. The hook must be safe to call multiple times
787
+ // per `mutate()` call (a single mutation can produce multiple update
788
+ // batches if Loro splits them); each call gets its own clientOpId so
789
+ // the outstanding-op map stays correct.
790
+ /** @internal */
791
+ setLocalOpHook(hook) {
792
+ this._localOpHook = hook;
793
+ // Detach the underlying Loro subscriber when the hook is removed
794
+ // so a hook-free Document does not pay the per-commit callback
795
+ // cost. `_ensureLocalUpdatesSubscribed` re-attaches lazily.
796
+ if (!hook && this._unsubLocal) {
797
+ this._unsubLocal();
798
+ this._unsubLocal = null;
799
+ }
800
+ }
801
+ // Install / replace the transport's live-head-seq provider; `null`
802
+ // detaches it.
803
+ /** @internal */
804
+ setCurrentSeqProvider(provider) {
805
+ this._currentSeqProvider = provider;
806
+ }
807
+ // Install the historical-materialise fetcher used by
808
+ // `_checkoutVersion`. A Document without one rejects the checkout
809
+ // with a clear error.
810
+ /** @internal */
811
+ setCheckoutFetcher(fetcher) {
812
+ this._checkoutFetcher = fetcher;
813
+ }
814
+ /** @internal Whether a historical-materialise fetcher is installed, so a
815
+ * stream can tell whether it can preview (a guest line has none). */
816
+ _hasCheckoutFetcher() {
817
+ return this._checkoutFetcher !== null;
818
+ }
819
+ /** @internal Whether this Document is currently showing a historical
820
+ * preview rather than its live head. */
821
+ _isCheckedOut() {
822
+ return this._checkout !== null;
823
+ }
824
+ // Switch the Document to a historical version, in place. @internal
825
+ // (the hosted `history` command drives this). After the promise
826
+ // resolves, `doc.readOnly` is `true`,
827
+ // `doc.version` is the server's effective version (which may round
828
+ // down from the requested one when compaction has TTL'd the exact
829
+ // op), and `mutate` throws. `subscribe` fires once after the swap so
830
+ // the app's render loop redraws, and the app returns to live through
831
+ // the public `returnToHead()`.
832
+ //
833
+ // The live WS stream keeps applying remote ops to the underlying
834
+ // live Document in the background. `returnToHead()` clears the
835
+ // checkout in O(1); the live state is already current.
836
+ async _checkoutVersion(version) {
837
+ this._assertOpen();
838
+ if (this._isFrozen) {
839
+ throw new Error('_checkoutVersion: this Document was opened at a fixed version ' +
840
+ 'and has no live channel.');
841
+ }
842
+ if (!Number.isInteger(version) || version < 0) {
843
+ throw new Error(`_checkoutVersion: invalid version ${version}`);
844
+ }
845
+ if (this._docId === null) {
846
+ throw new Error('_checkoutVersion: Document has no server identity');
847
+ }
848
+ if (this._branchId === null) {
849
+ throw new Error('_checkoutVersion: Document has no branch');
850
+ }
851
+ if (!this._checkoutFetcher) {
852
+ throw new Error('_checkoutVersion: no fetcher attached. Opened via the SDK factory?');
853
+ }
854
+ const generation = ++this._checkoutGeneration;
855
+ const { snapshotBytes, tailOpBlobs, effectiveVersion, streamSeqs } = await this._checkoutFetcher(this._docId, version, this._branchId);
856
+ if (this._closed || this._suspended)
857
+ return;
858
+ // A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
859
+ // was in flight. Honour the latest intent: drop this stale result rather
860
+ // than clobbering the live head (or a newer checkout) the user asked for.
861
+ if (generation !== this._checkoutGeneration)
862
+ return;
863
+ this._checkoutStreamSeqs = streamSeqs ?? {};
864
+ // Materialise the preview into a fresh LoroDoc. The previous
865
+ // checkout's preview LoroDoc, if any, is freed.
866
+ const previewDoc = new LoroDoc();
867
+ if (snapshotBytes !== null)
868
+ previewDoc.import(snapshotBytes);
869
+ for (const blob of tailOpBlobs)
870
+ previewDoc.import(blob);
871
+ const previewRoot = previewDoc.getMap(this._rootKey);
872
+ const old = this._checkout;
873
+ this._checkout = {
874
+ previewDoc,
875
+ previewRoot,
876
+ version: effectiveVersion,
877
+ };
878
+ // Re-point the handles the app already holds at the preview doc and
879
+ // redraw them: the active source changed from the live doc to the
880
+ // preview, and an app renders off its `doc.map(key).subscribe(...)` /
881
+ // `doc.orderedMap(...)` handle (the primary read surface). Rebinding
882
+ // re-fires those subscribers with the historical container so the model
883
+ // updates in place, the same way a remote op or undo would. `doc.map` /
884
+ // `doc.list` reads follow the anchor, so they read the preview too.
885
+ this._rebindRootHandles(previewDoc);
886
+ // Free the previous preview only AFTER the rebind. `_rebindRootHandles`
887
+ // runs each active subscriber's `detach()`, which unsubscribes against the
888
+ // container captured at attach time (the OLD preview's); freeing first
889
+ // would run that detach against a freed LoroDoc. This is the same
890
+ // teardown-before-free order `close()` documents.
891
+ if (old && old.previewDoc !== this._doc) {
892
+ try {
893
+ old.previewDoc.free();
894
+ }
895
+ catch {
896
+ // ignore; already freed
897
+ }
898
+ }
899
+ this._fireSubscribers({ kind: 'checkout', version: effectiveVersion });
900
+ this._maybeFireUndoState();
901
+ // Preview every opened stream at the same point in time, so a
902
+ // partitioned document's history shows a consistent historical state
903
+ // across streams rather than a historical default over live streams.
904
+ await this._checkoutOpenStreams();
905
+ }
906
+ // Re-materialize each opened non-default stream at its seq recorded by the
907
+ // active preview's checkpoint. A stream with no recorded seq at this
908
+ // version (opened after the checkpoint, or before any checkpoint recorded
909
+ // one) returns to its own head, so it shows its earliest-known rather than
910
+ // a mismatched live state. Best-effort per stream: a stream whose inner
911
+ // has no checkout fetcher (a guest line, which has no token to fetch
912
+ // at-version) stays live. Failures are swallowed so one stream cannot
913
+ // break the doc-level preview.
914
+ async _checkoutOpenStreams() {
915
+ const seqs = this._checkoutStreamSeqs ?? {};
916
+ for (const [name, openPromise] of this._streamOpens) {
917
+ try {
918
+ const stream = await openPromise;
919
+ const lineId = this._streamManifest?.get(name)?.line_id;
920
+ const seq = lineId !== undefined ? seqs[lineId] : undefined;
921
+ await stream._previewAt(seq);
922
+ }
923
+ catch {
924
+ // A stream that failed to open or preview is left as-is.
925
+ }
926
+ }
927
+ }
928
+ /**
929
+ * Return to the live head. After this resolves, `readOnly` is
930
+ * `false`, `version` is `null`, `toJSON()` reads the live state,
931
+ * and `mutate` is accepted again. The live state has been kept
932
+ * current by the WS transport while the checkout was active, so
933
+ * there is no fetch involved. `subscribe` fires once.
934
+ *
935
+ * Kept as a method (not `_`-prefixed) only so the version-preview banner
936
+ * (`mountToolbar` / `mountHeader` / `mountVersionPreview`), which lives in
937
+ * the same package, calls it by name.
938
+ * @internal
939
+ */
940
+ returnToHead() {
941
+ this._assertOpen();
942
+ if (this._isFrozen) {
943
+ throw new Error('returnToHead: this Document was opened at a fixed version ' +
944
+ 'and has no live channel to return to.');
945
+ }
946
+ // Bump first, unconditionally: a checkout fetch may be in flight with
947
+ // `_checkout` still null, and this is the signal that supersedes it when
948
+ // it resolves.
949
+ this._checkoutGeneration += 1;
950
+ if (this._checkout === null)
951
+ return;
952
+ const old = this._checkout;
953
+ this._checkout = null;
954
+ // Re-point the app's handles back at the live doc and redraw them,
955
+ // symmetrically with the checkout path, so the model returns to the live
956
+ // state in place.
957
+ this._rebindRootHandles(this._doc);
958
+ // Free the preview only after the rebind: `_rebindRootHandles` detaches
959
+ // each subscriber against the preview container it was attached to, so
960
+ // freeing first would be a use-after-free (see `_checkoutVersion`).
961
+ if (old.previewDoc !== this._doc) {
962
+ try {
963
+ old.previewDoc.free();
964
+ }
965
+ catch {
966
+ // ignore
967
+ }
968
+ }
969
+ this._fireSubscribers({ kind: 'return-to-head' });
970
+ this._maybeFireUndoState();
971
+ // Return every opened stream to its live head too, symmetric with the
972
+ // checkout path. Fire-and-forget (returnToHead is synchronous and has no
973
+ // fetch): a stream not previewing is a no-op.
974
+ this._checkoutStreamSeqs = null;
975
+ for (const openPromise of this._streamOpens.values()) {
976
+ void openPromise
977
+ .then((stream) => stream._previewAt(undefined))
978
+ .catch(() => {
979
+ // A stream that failed to open has nothing to return.
980
+ });
981
+ }
982
+ }
983
+ // Re-point every cached root handle at the corresponding container in
984
+ // `source` (the live doc or a checkout preview) and resync its subscribers.
985
+ // The app holds these same handle objects, so this is what makes a version
986
+ // checkout / return redraw the model without the app re-acquiring handles.
987
+ // `getMap` / `getList` are get-or-create, so a container absent in the
988
+ // preview resolves to an empty one and its subscribers redraw to empty.
989
+ _rebindRootHandles(source) {
990
+ // Two passes, not one per handle: rebind every anchor to the new source
991
+ // first, then resync. A composed handle can read a sibling root while
992
+ // firing (the ordered map reads its order list during its map redraw), so
993
+ // every anchor must already point at the new source before any handle
994
+ // fires, or the redraw reads a half-swapped map/order pair.
995
+ for (const entry of this._rootHandles.values()) {
996
+ if (entry.kind === 'map') {
997
+ rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
998
+ }
999
+ else {
1000
+ rebindRootListAnchor(entry.handle, source.getList(entry.key));
1001
+ }
1002
+ }
1003
+ for (const entry of this._rootHandles.values()) {
1004
+ resyncRootHandle(entry.handle);
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Fork a new branch from a historical version and switch to it. Drives
1009
+ * the hosted version-preview bar's "Branch from here".
1010
+ * @internal
1011
+ */
1012
+ _forkFromVersion(fromVersion, name) {
1013
+ return this._commandMenu.forkFromVersion(fromVersion, name);
1014
+ }
1015
+ // ---------------------------------------------------------------------
1016
+ // Branches
1017
+ // ---------------------------------------------------------------------
1018
+ // Internal: the current branch's display name when the doc is on a
1019
+ // NON-default branch, else null. The SDK's own chrome (the toolbar
1020
+ // branch chip, the page title) reads this to tell the user they are on
1021
+ // a branch. @internal
1022
+ _currentBranchName() {
1023
+ const branchId = this._branchId;
1024
+ const defaultBranchId = this._branchCtl.defaultBranchId;
1025
+ if (!branchId || !defaultBranchId || branchId === defaultBranchId) {
1026
+ return null;
1027
+ }
1028
+ return (this._branchCtl.branches.find((b) => b.branch_id === branchId)?.name ??
1029
+ null);
1030
+ }
1031
+ // Internal: the doc's default-branch id once the list has loaded, else
1032
+ // null. The session's URL sync reads this to keep `?branch=` off the
1033
+ // URL for the default branch. Not app-facing.
1034
+ /** @internal */
1035
+ _defaultBranchId() {
1036
+ return this._branchCtl.defaultBranchId;
1037
+ }
1038
+ // Internal: set the branches list. Fires `doc.subscribe` if the
1039
+ // value changed.
1040
+ /** @internal */
1041
+ _setBranches(branches, defaultBranchId) {
1042
+ this._branchCtl.set(branches, defaultBranchId);
1043
+ }
1044
+ // Install the branch provider (HTTP capability); `null` detaches it.
1045
+ // Wired by the SDK factory; the hosted branch chrome reaches the wire
1046
+ // through this controller.
1047
+ /** @internal */
1048
+ setBranchProvider(provider) {
1049
+ this._branchCtl.setProvider(provider);
1050
+ }
1051
+ // Internal: re-fetch the branches list. Called by the factory on open
1052
+ // (to populate the name the chip renders) and by the hosted branch
1053
+ // dialog after a mutation. Not app-facing.
1054
+ /** @internal */
1055
+ async _refreshBranches() {
1056
+ return this._branchCtl.refresh();
1057
+ }
1058
+ // ---------------------------------------------------------------------
1059
+ // Thumbnail auto-capture
1060
+ // ---------------------------------------------------------------------
1061
+ // Register a canvas the SDK screenshots as the document's
1062
+ // thumbnail. Capture is debounced after each local edit; remote
1063
+ // edits and read-only / checkout state do not trigger a capture
1064
+ // (a viewer or a passive collaborator never re-uploads someone
1065
+ // else's frame). Pass the canvas element directly, or a getter
1066
+ // for apps that build / rebuild it lazily.
1067
+ //
1068
+ // Returns an unregister handle. Re-registering replaces; the
1069
+ // previous canvas is forgotten.
1070
+ useCanvas(source) {
1071
+ // Arm the lazy Loro subscriber so local-commit events reach the
1072
+ // capture collaborator's `schedule()`. Idempotent.
1073
+ this._ensureLoroSubscribed();
1074
+ return this._thumbnails.useCanvas(source);
1075
+ }
1076
+ // Install the thumbnail upload hook; `null` detaches.
1077
+ /** @internal */
1078
+ setThumbnailUploader(uploader) {
1079
+ this._thumbnails.setUploader(uploader);
1080
+ }
1081
+ // Install the checkpoint-create hook; `null` detaches.
1082
+ /** @internal */
1083
+ setCheckpointCreator(creator) {
1084
+ this._checkpoints.setCreator(creator);
1085
+ }
1086
+ // Install the drive-push hook; `null` detaches.
1087
+ /** @internal */
1088
+ setDrivePusher(pusher) {
1089
+ this._drivePusher = pusher;
1090
+ }
1091
+ // Install the drive-resolve hook; `null` detaches. Mirrors
1092
+ // `setDrivePusher`.
1093
+ /** @internal */
1094
+ setDriveResolver(resolver) {
1095
+ this._driveResolver = resolver;
1096
+ }
1097
+ // Install the source-blob fetch hook. Mirrors `setDrivePusher`.
1098
+ /** @internal */
1099
+ setSourceBlobFetcher(fetcher) {
1100
+ this._sourceBlobFetcher = fetcher;
1101
+ }
1102
+ // Publish this document's current state back to its drive source.
1103
+ // Lets an app code one line on save / on a commit button to make its
1104
+ // edits visible to people working outside Brass on the same source:
1105
+ //
1106
+ // await doc.pushToDrive({ message: 'Wall move' });
1107
+ //
1108
+ // The platform publishes the doc's current state, overwriting the
1109
+ // source (unknown drive fields preserved), and marks its own write so
1110
+ // the inbound change-event drops it (no echo loop). It does not merge
1111
+ // concurrent drive-side edits. Throws on auth / link / write
1112
+ // failures; resolves with the new external ref and the published
1113
+ // entity count.
1114
+ async pushToDrive(input) {
1115
+ this._assertOpen();
1116
+ if (this._docId === null) {
1117
+ throw new Error('pushToDrive: Document has no server identity');
1118
+ }
1119
+ if (this._drivePusher === null) {
1120
+ throw new Error('pushToDrive: no pusher attached. Opened via the SDK factory?');
1121
+ }
1122
+ return await this._drivePusher(input ?? {});
1123
+ }
1124
+ // Resolve a `behind` / `diverged` drive-linked doc by opening the
1125
+ // hosted resolution surface, so an app can offer pull (take the source)
1126
+ // / keep both alongside the publish-over that `pushToDrive` does:
1127
+ //
1128
+ // if (doc.driveStatus?.state === 'diverged') await doc.resolveDrive();
1129
+ //
1130
+ // Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
1131
+ // takes over the drive link (take-source trashes this doc, keep-both
1132
+ // detaches it into a standalone fork). For a session-backed app the
1133
+ // SDK re-opens onto that new doc automatically (the resolver routes it
1134
+ // through the same `openSiblingDoc` path the history fork uses); the
1135
+ // returned `newDocId` lets a multi-doc app do its own switch. Resolves
1136
+ // with the outcome, or `null` when the user dismissed the dialog.
1137
+ // Throws when there is no doc identity or no resolver is attached.
1138
+ async resolveDrive() {
1139
+ this._assertOpen();
1140
+ if (this._docId === null) {
1141
+ throw new Error('resolveDrive: Document has no server identity');
1142
+ }
1143
+ if (this._driveResolver === null) {
1144
+ throw new Error('resolveDrive: no resolver attached. Opened via the SDK factory?');
1145
+ }
1146
+ return await this._driveResolver();
1147
+ }
1148
+ // Fetch the original file behind an opaque-blob document: a short-lived
1149
+ // presigned GET plus the file's name / content type / size. An app that
1150
+ // knows the format fetches the bytes from `url`, parses them, and writes
1151
+ // the structured content into this document through the normal handle
1152
+ // surface (`doc.map('instances').mutate(...)`):
1153
+ //
1154
+ // const src = await doc.sourceBlob();
1155
+ // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
1156
+ // const records = parseMyFormat(bytes); // app's own parser
1157
+ // doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
1158
+ //
1159
+ // A source file is a set of one or more streams: `src.streams` lists
1160
+ // every stream (primary first, so a single-stream file has one entry),
1161
+ // each with its own URL, so a multi-stream format (a glTF with its
1162
+ // `.bin` buffer) fetches only the streams it needs.
1163
+ //
1164
+ // Rejects with a `BrassError` when the document is not a stored opaque
1165
+ // blob (it has no original file to hand back) or the caller cannot
1166
+ // reach it (both 404).
1167
+ async sourceBlob() {
1168
+ this._assertOpen();
1169
+ if (this._docId === null) {
1170
+ throw new Error('sourceBlob: Document has no server identity');
1171
+ }
1172
+ if (this._sourceBlobFetcher === null) {
1173
+ throw new Error('sourceBlob: no fetcher attached. Opened via the SDK factory?');
1174
+ }
1175
+ return await this._sourceBlobFetcher();
1176
+ }
1177
+ // ---------------------------------------------------------------------
1178
+ // Streams
1179
+ // ---------------------------------------------------------------------
1180
+ // Stream wiring, installed by the SDK open factory; `null` on
1181
+ // offline / test docs (stream methods then throw with a clear
1182
+ // message). The manifest cache maps stream name -> info; opens are
1183
+ // cached per name so `doc.stream('hvac')` is one join + subscribe
1184
+ // however many times it is called.
1185
+ _streamRuntime = null;
1186
+ _streamManifest = null;
1187
+ _streamOpens = new Map();
1188
+ _ownStream = null;
1189
+ // Which stream this Document's own handles read. An open names a stream
1190
+ // and the usual one has a default, so `default` is a name here rather
1191
+ // than a case: opening at `geometry` makes `doc.map(...)` read geometry
1192
+ // and `doc.stream('geometry')` resolve to this Document.
1193
+ _openedStreamName = DEFAULT_STREAM_NAME;
1194
+ // Install the stream runtime; `null` detaches. Mirrors `setDrivePusher`.
1195
+ /** @internal */
1196
+ setStreamRuntime(runtime) {
1197
+ this._streamRuntime = runtime;
1198
+ }
1199
+ /** @internal Name the stream this Document was opened at. Set by the SDK
1200
+ * open factory; defaults to `default`. */
1201
+ setOpenedStreamName(name) {
1202
+ this._openedStreamName = name;
1203
+ }
1204
+ /** The stream this Document's own handles read. */
1205
+ get openedStream() {
1206
+ return this._openedStreamName;
1207
+ }
1208
+ // Open one stream of this document, by name. Only the streams an app
1209
+ // touches are downloaded, imported, and subscribed, so a partitioned
1210
+ // document opens at the cost of what is used, not its total size:
1211
+ //
1212
+ // const hvac = await doc.stream('hvac');
1213
+ // const records = hvac.orderedMap('instances', 'instance_order');
1214
+ //
1215
+ // The name this Document was opened at (`'default'` unless the open said
1216
+ // otherwise) resolves to its own state, the same containers
1217
+ // `doc.map(...)` reads, with no network. That is what lets code iterate
1218
+ // stream names over partitioned and unpartitioned documents with one
1219
+ // path. Repeat calls return the same open (one join + subscribe per
1220
+ // stream per Document).
1221
+ //
1222
+ // Opening READS. A name the document does not hold rejects rather than
1223
+ // being created, so code that only reads cannot mint a stream by
1224
+ // probing for one; `getOrCreateStream` is the call that writes.
1225
+ stream(name) {
1226
+ // Sync method returning a Promise: reject rather than throw, so a
1227
+ // caller chaining `.catch()` without `await` still sees the failure.
1228
+ if (!this._isLive) {
1229
+ return Promise.reject(new Error('Document is closed'));
1230
+ }
1231
+ // The stream this Document already IS. No network: its state is the
1232
+ // state these handles read.
1233
+ if (name === this._openedStreamName) {
1234
+ this._ownStream ??= new DocumentStream(name, this, false);
1235
+ return Promise.resolve(this._ownStream);
1236
+ }
1237
+ const cached = this._streamOpens.get(name);
1238
+ if (cached)
1239
+ return cached;
1240
+ const runtime = this._streamRuntime;
1241
+ if (runtime === null) {
1242
+ return Promise.reject(new Error('stream: no runtime attached. Opened via the SDK factory?'));
1243
+ }
1244
+ const open = (async () => {
1245
+ const info = await this._resolveStreamInfo(runtime, name);
1246
+ if (!info) {
1247
+ throw new Error(`This document has no stream named "${name}". ` +
1248
+ `doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`);
1249
+ }
1250
+ // Only a CRDT stream opens as live collaborative state. Any other
1251
+ // value fails loudly here rather than joining a line that is not
1252
+ // one, so a format added after this SDK shipped is refused by the
1253
+ // version that cannot read it.
1254
+ if (info.format !== 'crdt') {
1255
+ throw new Error(`Stream "${name}" has format "${info.format}", which this ` +
1256
+ 'SDK version cannot open as a live stream.');
1257
+ }
1258
+ const inner = await runtime.openLine(info.line_id);
1259
+ const stream = new DocumentStream(name, inner, true);
1260
+ // Opened while a version preview is active: bring the new stream to
1261
+ // the same point in time rather than showing it live under a
1262
+ // historical default.
1263
+ if (this._checkout !== null) {
1264
+ await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id]);
1265
+ }
1266
+ return stream;
1267
+ })();
1268
+ this._streamOpens.set(name, open);
1269
+ // A failed open (network, deleted stream) must not poison the cache:
1270
+ // drop it so the next call retries.
1271
+ open.catch(() => {
1272
+ if (this._streamOpens.get(name) === open)
1273
+ this._streamOpens.delete(name);
1274
+ });
1275
+ return open;
1276
+ }
1277
+ // Open the document's stream of that name, making it when the document
1278
+ // does not hold it yet. This is the call that WRITES: `stream` only
1279
+ // reads, so no read path can bring a stream into being.
1280
+ //
1281
+ // Two collaborators reaching for the same name converge on one stream
1282
+ // rather than one of them failing, so an app reaches for the stream it
1283
+ // expects on every open without checking first.
1284
+ async getOrCreateStream(name) {
1285
+ this._assertOpen();
1286
+ // The stream this Document already IS needs no create: it exists by the
1287
+ // time the document is open. Asking the server would spend a
1288
+ // branch-write budget on a reach whose answer is already in hand, which
1289
+ // matters because an app loops its declared stream names on every open.
1290
+ if (name === this._openedStreamName)
1291
+ return this.stream(name);
1292
+ const runtime = this._streamRuntime;
1293
+ if (runtime === null) {
1294
+ throw new Error('getOrCreateStream: no runtime attached. Opened via the SDK factory?');
1295
+ }
1296
+ let info;
1297
+ try {
1298
+ info = await runtime.create(name);
1299
+ }
1300
+ catch (err) {
1301
+ // Losing the create means someone else made this stream between our
1302
+ // read and our write, which is the same outcome we wanted, so resolve
1303
+ // theirs. Recognising the loss by re-reading rather than by matching a
1304
+ // status code keeps the app out of the race entirely: a rejection that
1305
+ // is NOT the race (no permission, doc gone) finds no stream and
1306
+ // rethrows untouched.
1307
+ const existing = await this._resolveStreamInfo(runtime, name);
1308
+ if (!existing)
1309
+ throw err;
1310
+ info = existing;
1311
+ }
1312
+ (this._streamManifest ??= new Map()).set(info.name, info);
1313
+ return this.stream(info.name);
1314
+ }
1315
+ // Every stream the document holds, `default` included (it is an ordinary
1316
+ // manifest entry). Fetches fresh so a stream a collaborator just created
1317
+ // is visible.
1318
+ async listStreams() {
1319
+ this._assertOpen();
1320
+ const runtime = this._streamRuntime;
1321
+ if (runtime === null) {
1322
+ throw new Error('listStreams: no runtime attached. Opened via the SDK factory?');
1323
+ }
1324
+ const streams = await runtime.list();
1325
+ this._streamManifest = new Map(streams.map((s) => [s.name, s]));
1326
+ return streams;
1327
+ }
1328
+ async _resolveStreamInfo(runtime, name) {
1329
+ const cached = this._streamManifest?.get(name);
1330
+ if (cached)
1331
+ return cached;
1332
+ const streams = await runtime.list();
1333
+ this._streamManifest = new Map(streams.map((s) => [s.name, s]));
1334
+ return this._streamManifest.get(name);
1335
+ }
1336
+ // Close every opened stream's own transport + state. Streams opened
1337
+ // while this Document was live do not outlive it.
1338
+ _closeStreams() {
1339
+ for (const open of this._streamOpens.values()) {
1340
+ void open.then((s) => s._close()).catch(() => {
1341
+ // A failed open has nothing to close.
1342
+ });
1343
+ }
1344
+ this._streamOpens.clear();
1345
+ this._streamManifest = null;
1346
+ }
1347
+ // Seal a deliberate checkpoint on this document's version-
1348
+ // history timeline. Compaction snapshots continue to appear as
1349
+ // no-action fallback markers, so an app that never calls this still
1350
+ // gets a usable history.
1351
+ //
1352
+ // Anchors to `doc.currentSeq` synchronously, so a concurrent
1353
+ // editor's op landing mid-call cannot shift the anchor. Pass an
1354
+ // explicit `version` to seal at a different point in history (the
1355
+ // server validates it's `<=` the branch's live head).
1356
+ async createCheckpoint() {
1357
+ return this._checkpoints.create();
1358
+ }
1359
+ // Configure the implicit auto-checkpoint debounce. Default is 60s
1360
+ // after the last local edit; pass `0` to disable. Apps in test
1361
+ // contexts (deterministic clocks) or apps with a different "natural
1362
+ // pause" rhythm (a slow turn-based tool, a CAD modeler with multi-
1363
+ // second operations) tune this. Returning the prior value lets a
1364
+ // test or scoped override restore.
1365
+ setAutoCheckpointIntervalMs(intervalMs) {
1366
+ return this._checkpoints.setAutoIntervalMs(intervalMs);
1367
+ }
1368
+ // ---------------------------------------------------------------------
1369
+ // File-menu commands (per-doc)
1370
+ // ---------------------------------------------------------------------
1371
+ // The doc's display name. `null` on offline / test docs.
1372
+ get name() {
1373
+ return this._name;
1374
+ }
1375
+ // Internal: set the cached name. Fires a `'commands'` subscribe
1376
+ // event so apps re-render the menu trigger without an app-side
1377
+ // refresh.
1378
+ /** @internal */
1379
+ _setName(name) {
1380
+ if (this._name === name)
1381
+ return;
1382
+ this._name = name;
1383
+ this._fireSubscribers({ kind: 'commands' });
1384
+ }
1385
+ /** @internal */
1386
+ setHostedUiProvider(provider) {
1387
+ this._commandMenu.setHostedUiProvider(provider);
1388
+ }
1389
+ // The per-doc file menu, as data. Each Document carries its own list
1390
+ // with its own `running` state; multi-doc apps render one menu per
1391
+ // pane. The app filters by `surface` ('menu' | 'toolbar') to place
1392
+ // each command into its own chrome.
1393
+ get commands() {
1394
+ return this._commandMenu.commands;
1395
+ }
1396
+ // Invoke a command by id. A convenience for keyboard shortcuts
1397
+ // (`Ctrl-Z` -> `doc.run('undo')`). No-op for an unknown id.
1398
+ async run(commandId) {
1399
+ return this._commandMenu.run(commandId);
1400
+ }
1401
+ // Optional error sink for the doc's built-in commands. `null` falls
1402
+ // back to `console.error`.
1403
+ /** @internal */
1404
+ setErrorReporter(reporter) {
1405
+ this._errorReporter = reporter;
1406
+ this._commandMenu.setErrorReporter(reporter);
1407
+ }
1408
+ _reportError(err) {
1409
+ if (this._errorReporter)
1410
+ this._errorReporter(err);
1411
+ else
1412
+ console.error('[Brass]', err);
1413
+ }
1414
+ // Fire all `subscribe` listeners with a synthetic event. Used for
1415
+ // metadata-only changes (checkout, return-to-head, branch switch,
1416
+ // branches list update, saving count change, commands change)
1417
+ // that don't originate from a Loro commit but still need the app
1418
+ // to re-read state. Apps typically ignore the event arg and just
1419
+ // call `toJSON()` in the handler; the discriminant is there for
1420
+ // the rare consumer that wants to branch on the reason.
1421
+ _fireSubscribers(event) {
1422
+ this._ensureLoroSubscribed();
1423
+ this._subs.emit(event);
1424
+ }
1425
+ // Saving indicator: number of locally-issued ops not yet acked by
1426
+ // the server. Reactive: `subscribe` fires when this count changes
1427
+ // (an op is sent or an `op_ack` lands). Apps bind directly inside
1428
+ // the subscribe handler:
1429
+ //
1430
+ // doc.subscribe(() => {
1431
+ // savingEl.textContent =
1432
+ // doc.savingCount === 0 ? 'All changes saved'
1433
+ // : `Saving ${doc.savingCount} change${doc.savingCount === 1 ? '' : 's'}...`;
1434
+ // });
1435
+ get savingCount() {
1436
+ return this._outstandingByClientId.size;
1437
+ }
1438
+ // Internal: forget an outstanding op once the server has acked it.
1439
+ // Returns true if the clientOpId was tracked (lets the caller detect
1440
+ // a stale ack).
1441
+ /** @internal */
1442
+ resolveOutstandingOp(clientOpId) {
1443
+ const wasTracked = this._outstandingByClientId.delete(clientOpId);
1444
+ // Release this chunk from the resync-push loop guard once its op is
1445
+ // acked: the pushed op is now in the log, so the next handshake's
1446
+ // tail covers it and a fresh push is welcome (it would be a genuine
1447
+ // new hole, not a re-push of this one). The guard fully reopens only
1448
+ // once every chunk of the push has drained.
1449
+ this._resyncPushClientOpIds.delete(clientOpId);
1450
+ if (wasTracked)
1451
+ this._fireSubscribers({ kind: 'saving-count' });
1452
+ return wasTracked;
1453
+ }
1454
+ // Internal: forget an outstanding op the server TERMINALLY rejected (an
1455
+ // `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
1456
+ // server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
1457
+ // transport clears its resend timer; this drops the tracker entry so
1458
+ // `savingCount` stops counting a dead op, and surfaces the failure so the
1459
+ // app can react (the optimistic local commit stays applied but never
1460
+ // persists, so the app's cue is to reload). Returns true if it was tracked.
1461
+ /** @internal */
1462
+ _failOutstandingOp(clientOpId, message) {
1463
+ const wasTracked = this._outstandingByClientId.delete(clientOpId);
1464
+ this._resyncPushClientOpIds.delete(clientOpId);
1465
+ if (wasTracked) {
1466
+ this._fireSubscribers({ kind: 'saving-count' });
1467
+ this._reportError(new Error(message));
1468
+ }
1469
+ return wasTracked;
1470
+ }
1471
+ _ensureLocalUpdatesSubscribed() {
1472
+ if (this._unsubLocal)
1473
+ return;
1474
+ if (!this._localOpHook)
1475
+ return;
1476
+ // Loro's `subscribeLocalUpdates` fires after every local commit
1477
+ // with the op-blob bytes for that commit. Importing a remote op
1478
+ // does NOT trigger this callback, so we don't risk feeding the
1479
+ // same op back to the server in a loop.
1480
+ this._unsubLocal = this._doc.subscribeLocalUpdates((bytes) => {
1481
+ const hook = this._localOpHook;
1482
+ if (!hook)
1483
+ return;
1484
+ // Copy: Loro reuses its internal buffer across callback calls in
1485
+ // some builds. Hand the consumer a stable view so the eventual
1486
+ // WS send sees the correct bytes regardless of when the send
1487
+ // actually flushes (the WS client may queue the frame).
1488
+ const copy = new Uint8Array(bytes.byteLength);
1489
+ copy.set(bytes);
1490
+ const clientOpId = hook(copy);
1491
+ this._outstandingByClientId.set(clientOpId, copy);
1492
+ // The local mutate has already fired `subscribe` (the Loro
1493
+ // event), but `savingCount` just incremented; fire one more
1494
+ // synthetic event so a subscribe handler bound on `savingCount`
1495
+ // sees the change.
1496
+ this._fireSubscribers({ kind: 'saving-count' });
1497
+ });
1498
+ }
1499
+ // Lazy-attach the underlying LoroDoc subscriber. **Do not** wire this
1500
+ // in the constructor or before the initial snapshot import: with a
1501
+ // subscriber attached, snapshot-import of a 50k doc takes ~8.5 s
1502
+ // because Loro walks the full event tree to deliver per-container
1503
+ // deltas. Lazy-attach drops 50k cold-load to ~280 ms.
1504
+ _ensureLoroSubscribed() {
1505
+ // Never re-attach after close: `close()` nulls `_unsubLoro` and then
1506
+ // frees the underlying LoroDoc, so subscribing here would call into freed
1507
+ // WASM. Not every `_fireSubscribers` caller is transport-driven or
1508
+ // guarded: `_setName` runs from an open-time `refreshName(...).then(...)` with no
1509
+ // liveness re-check, and a default-branch `close()` frees the doc
1510
+ // immediately (no park), so a name fetch resolving after close reaches
1511
+ // here on a freed doc. The `_unsubLoro` short-circuit alone does not cover
1512
+ // it: close set it to null.
1513
+ if (this._closed)
1514
+ return;
1515
+ if (this._unsubLoro)
1516
+ return;
1517
+ this._unsubLoro = this._doc.subscribe((event) => {
1518
+ // One channel: every event reaches every subscriber, whatever
1519
+ // produced it. The app reconciles by value and reads the cause off
1520
+ // the event only when it has a non-render reason to.
1521
+ this._subs.emit(event);
1522
+ const isLocalCommit = event.by === 'local';
1523
+ this._maybeFireUndoState();
1524
+ // A local commit (mutate / undo / redo) schedules a debounced
1525
+ // thumbnail capture, when the app has registered a canvas via
1526
+ // `useCanvas`. Remote ops never trigger a capture: a viewer or
1527
+ // a passive collaborator must not re-upload a screenshot of
1528
+ // someone else's edit.
1529
+ if (isLocalCommit) {
1530
+ this._thumbnails.schedule();
1531
+ // Same reasoning for the implicit auto-checkpoint: only the
1532
+ // peer that made the edit seals the marker. Without this an
1533
+ // N-way session would write N redundant CHECKPOINT# rows per
1534
+ // idle period.
1535
+ this._checkpoints.scheduleAuto();
1536
+ }
1537
+ });
1538
+ }
1539
+ // Subscribe to document events. Returns an unsubscribe handle.
1540
+ // Lazy-attaches the underlying Loro subscriber on first call so
1541
+ // construction stays cheap.
1542
+ //
1543
+ // `cb` fires for EVERY change, whatever produced it: a local `mutate()`,
1544
+ // a remote `import`, `undo`, `redo`, `checkout`, and the SDK's synthetic
1545
+ // metadata events (saving-count, branches, commands). One channel; the
1546
+ // app re-reads / reconciles in the handler and branches on the event only
1547
+ // when it needs the cause. CRDT-origin events keep their Loro shape and
1548
+ // carry `by` / `origin` so the cause is recoverable; the engine-neutral
1549
+ // `origin.source` lives on the handle surface.
1550
+ subscribe(cb) {
1551
+ this._assertOpen();
1552
+ this._ensureLoroSubscribed();
1553
+ return this._subs.on(cb);
1554
+ }
1555
+ // Subscribe to changes in undo/redo enabled state. Fires once
1556
+ // synchronously on registration with the current values, then
1557
+ // again whenever either value flips (after a local mutate, an
1558
+ // undo / redo, or a remote import that landed an op of this
1559
+ // peer's). The push covers local mutates, which the regular
1560
+ // `subscribe(cb)` suppresses as local echo, so a button bound
1561
+ // here stays current while the user types, regardless of what
1562
+ // produced the change.
1563
+ //
1564
+ // Returns an unsubscribe handle. The callback is invoked with
1565
+ // `{ canUndo, canRedo }`; only fires when at least one value
1566
+ // differs from the prior fire.
1567
+ //
1568
+ // Wire it once in your editor bind step:
1569
+ //
1570
+ // const stop = doc.subscribeUndoState(({ canUndo, canRedo }) => {
1571
+ // undoBtn.disabled = !canUndo;
1572
+ // redoBtn.disabled = !canRedo;
1573
+ // });
1574
+ //
1575
+ // Lifecycle: throws on a closed Document; cleared on close /
1576
+ // suspend like every other app-side subscriber.
1577
+ subscribeUndoState(cb) {
1578
+ this._assertOpen();
1579
+ this._ensureLoroSubscribed();
1580
+ this._undoStateSubs.add(cb);
1581
+ // Fire immediately with current state so the consumer does
1582
+ // not need separate initial-paint code. Capture the values
1583
+ // BEFORE registering as the baseline, so the first underlying
1584
+ // event fires the subscriber only when the value actually
1585
+ // changes from this initial fire.
1586
+ const state = {
1587
+ canUndo: this.canUndo(),
1588
+ canRedo: this.canRedo(),
1589
+ };
1590
+ this._lastCanUndo = state.canUndo;
1591
+ this._lastCanRedo = state.canRedo;
1592
+ try {
1593
+ cb(state);
1594
+ }
1595
+ catch {
1596
+ // A throwing initial callback must not strand the registration;
1597
+ // returning the unsub handle lets the app recover.
1598
+ }
1599
+ return () => {
1600
+ this._undoStateSubs.delete(cb);
1601
+ };
1602
+ }
1603
+ // Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
1604
+ // Called from `_ensureLoroSubscribed`'s event dispatch.
1605
+ _maybeFireUndoState() {
1606
+ if (this._undoStateSubs.size === 0)
1607
+ return;
1608
+ const canUndo = this.canUndo();
1609
+ const canRedo = this.canRedo();
1610
+ if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
1611
+ return;
1612
+ }
1613
+ this._lastCanUndo = canUndo;
1614
+ this._lastCanRedo = canRedo;
1615
+ const state = { canUndo, canRedo };
1616
+ for (const cb of this._undoStateSubs) {
1617
+ try {
1618
+ cb(state);
1619
+ }
1620
+ catch {
1621
+ // A throwing subscriber must not affect the others.
1622
+ }
1623
+ }
1624
+ }
1625
+ // Register a callback that fires exactly once when the server
1626
+ // tells this Document its underlying doc has been evicted (today:
1627
+ // soft-delete via the hosted Delete tab or the underlying
1628
+ // `DELETE /documents/:docId` API; future: revoked membership,
1629
+ // organization deletion cascade). Late-attached listeners fire
1630
+ // synchronously on registration if the gone event already happened.
1631
+ //
1632
+ // The Document is NOT auto-closed; callers may want to render a
1633
+ // "the document was deleted" overlay before closing. Disable any
1634
+ // pending mutations in the listener and call `close()` when ready.
1635
+ // Mutations after `_markGone` still mutate the local CRDT and call
1636
+ // the `LocalOpHook`, but the server will reject those op frames
1637
+ // (the SUB# row was evicted at fanout time); the SDK consumer
1638
+ // sees the rejection via the underlying transport's error channel.
1639
+ onGone(cb) {
1640
+ // Guard like `subscribe` / `subscribeUndoState`: `_suspend` clears these
1641
+ // signals' listeners so a stale handle from a parked session does not
1642
+ // receive the next session's events. Without this throw a listener wired
1643
+ // onto a closed/suspended handle would attach and survive into the resumed
1644
+ // open (which reuses the same cached Document), the exact cross-session
1645
+ // leak `_suspend` sets out to prevent.
1646
+ this._assertOpen();
1647
+ return this._gone.on(cb);
1648
+ }
1649
+ // The latest opaque work-status for this document, or `null` when no
1650
+ // slow server-side work is in flight. An app renders `message`
1651
+ // verbatim, draws a bar from `progress` when present, and shows a
1652
+ // waiting state while `pending`. When a freshly-opened drive-linked
1653
+ // doc is still hydrating, `open()` surfaces the initial `pending`
1654
+ // status here; subsequent phases arrive via `onStatus`.
1655
+ get status() {
1656
+ return this._workStatus.status;
1657
+ }
1658
+ // Subscribe to opaque work-status updates. The callback fires on
1659
+ // every phase the server pushes; when `status.pending` flips to
1660
+ // `false` the work is terminal (a success the app reacts to by
1661
+ // re-opening the doc to load the now-available content, or a failure
1662
+ // whose `message` the app shows). Fires once on attach with the
1663
+ // current status when one is already known, so a late subscriber is
1664
+ // not stranded. Returns an unsubscribe.
1665
+ //
1666
+ // The app must not parse `message` or infer phases from it: the
1667
+ // server owns the wording and the lifecycle. This surface exists so
1668
+ // the app can *display* progress, not understand it.
1669
+ onStatus(cb) {
1670
+ // See `onGone`: guard the cross-session listener leak.
1671
+ this._assertOpen();
1672
+ return this._workStatus.on(cb);
1673
+ }
1674
+ // Internal: store the latest status and fire listeners. Not
1675
+ // one-shot: every phase re-emits. A terminal status (`pending:
1676
+ // false`) is retained so a late `onStatus` subscriber still sees the
1677
+ // final state.
1678
+ /** @internal */
1679
+ _emitStatus(status) {
1680
+ // A late reload/reconcile closure that resolved after `close()` must not
1681
+ // re-emit onto a torn-down doc (its undo stack is freed below in `close`).
1682
+ if (this._closed)
1683
+ return;
1684
+ this._workStatus.emit(status);
1685
+ // A blocking work-status is one of the inputs to `canUndo` / `canRedo`
1686
+ // (via `_isLoadLocked`), so a status that engages or releases the load
1687
+ // lock flips the undo/redo availability with no accompanying commit.
1688
+ // Refresh the undo-state notifier here so a subscribeUndoState-bound
1689
+ // button tracks the lock, not only CRDT edits.
1690
+ this._maybeFireUndoState();
1691
+ }
1692
+ // The current drive sync state of this document, or `null` when the
1693
+ // doc is not linked to a drive source. The read-side companion to
1694
+ // `doc.pushToDrive(...)`: an app reads `canPush` / `state` to decide
1695
+ // whether to surface a "Publish" affordance, and renders the
1696
+ // server-authored `label` / `detail` as the wording. Seeded on open
1697
+ // (for a drive-linked doc) and kept live as edits, refreshes, and
1698
+ // pushes move the state.
1699
+ get driveStatus() {
1700
+ return this._driveStatus.status;
1701
+ }
1702
+ // Subscribe to drive sync-state changes. Fires once on attach with
1703
+ // the current state when one is already known, then on every change
1704
+ // (an edit flipping the doc to `ahead`, a push re-syncing it, the
1705
+ // source moving it to `behind`). De-duped server-state-side: a
1706
+ // METADATA change that did not move the sync state does not re-fire.
1707
+ // Returns an unsubscribe.
1708
+ //
1709
+ // As with `onStatus`, the app must not parse `label` / `detail`: the
1710
+ // server owns the wording. Branch only on `state` / `canPush`.
1711
+ onDriveStatus(cb) {
1712
+ // See `onGone`: guard the cross-session listener leak.
1713
+ this._assertOpen();
1714
+ return this._driveStatus.on(cb);
1715
+ }
1716
+ // Internal: store the latest drive state and fire listeners,
1717
+ // skipping a no-op re-emit.
1718
+ /** @internal */
1719
+ _emitDriveStatus(status) {
1720
+ if (this._closed)
1721
+ return;
1722
+ this._driveStatus.emit(status);
1723
+ }
1724
+ // Internal: mark the doc evicted. Idempotent (multiple frames arrive
1725
+ // for the same doc when both `doc:<id>` and `presence:doc:<id>`
1726
+ // topics fan out; we collapse them so the user-side `onGone`
1727
+ // listener fires exactly once).
1728
+ /** @internal */
1729
+ _markGone(reason) {
1730
+ // An evicted doc's outstanding local ops can never ack (the transport
1731
+ // cancels their resend timers on `doc_gone`), so stop counting them as
1732
+ // "saving": without this the doc renders "no longer available" and
1733
+ // "Saving N changes…" at once until `close()`.
1734
+ if (this._outstandingByClientId.size > 0) {
1735
+ this._outstandingByClientId.clear();
1736
+ this._resyncPushClientOpIds.clear();
1737
+ this._fireSubscribers({ kind: 'saving-count' });
1738
+ }
1739
+ this._gone.mark(reason);
1740
+ }
1741
+ // Plain-JS projection of the current state.
1742
+ toJSON() {
1743
+ // When in checkout, read from the preview LoroDoc so the app
1744
+ // renders the historical state. Returning to head clears
1745
+ // `_checkout` and reads go back to the live `_doc`.
1746
+ const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
1747
+ return source.toJSON();
1748
+ }
1749
+ /**
1750
+ * Snapshot bytes; inverse of `openFromSnapshot`, for round-trip tests.
1751
+ * @internal
1752
+ */
1753
+ _exportSnapshot() {
1754
+ return this._doc.export({ mode: 'snapshot' });
1755
+ }
1756
+ // Durability handshake (see `wireDocTransport`'s `doc_init` handler).
1757
+ //
1758
+ // Run the handshake and push the missing slice if there is one. Shared
1759
+ // by the `doc_init` (reconnect) path and the `doc_ops_dropped`
1760
+ // (prompt-notification) path, so the loop guard lives here rather than
1761
+ // in either caller. No-op when the durable frontier already covers the
1762
+ // local doc, while a prior resync push is still unacked, or when the
1763
+ // missing slice is too large to ship as one op (see the cap below).
1764
+ /** @internal */
1765
+ maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
1766
+ // `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
1767
+ // closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
1768
+ // closure can't touch freed WASM.
1769
+ if (this._closed)
1770
+ return;
1771
+ // Loop guard: skip while any chunk of a prior push is still unacked,
1772
+ // so a flurry of reconnects cannot re-push the same missing slice.
1773
+ for (const id of this._resyncPushClientOpIds) {
1774
+ if (this._outstandingByClientId.has(id))
1775
+ return;
1776
+ }
1777
+ const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs);
1778
+ if (chunks === null)
1779
+ return;
1780
+ for (const chunk of chunks) {
1781
+ const id = this.injectLocalUpdate(chunk);
1782
+ if (id !== null)
1783
+ this._resyncPushClientOpIds.add(id);
1784
+ }
1785
+ }
1786
+ // Given the server's persisted snapshot version vector and the op
1787
+ // blobs the server is also serving on the join tail, decide whether
1788
+ // this client holds CRDT knowledge the durable server state lacks,
1789
+ // and if so return the update blob that fills exactly those holes.
1790
+ //
1791
+ // The durable frontier is the snapshot's version vector advanced by
1792
+ // every tail op the server delivered (those ops are durable in the
1793
+ // log even though no snapshot has absorbed them yet). Comparing the
1794
+ // local doc against the SNAPSHOT vector alone would falsely flag the
1795
+ // whole durable tail as missing and push it back on every join, which
1796
+ // then grows the log and loops; folding the tail in is what keeps the
1797
+ // handshake a no-op in the common "server already has everything"
1798
+ // case. `decodeImportBlobMeta` reads each blob's end vector from its
1799
+ // header without a full import, so this stays cheap.
1800
+ //
1801
+ // Returns the chunks to resync-push, or null when the durable state
1802
+ // already covers everything local holds (or a malformed vector / tail
1803
+ // blob, which degrades the handshake to a no-op and warns rather than
1804
+ // throwing out of the frame handler that drives the live transport).
1805
+ //
1806
+ // The common case is one chunk: an `update` export from the durable
1807
+ // frontier carries only the missing ops plus their dependencies, never
1808
+ // the whole history, and a realistic gap is far under the per-frame
1809
+ // cap. When the missing slice exceeds the cap (a BIM-sized edit set:
1810
+ // a geometry import or bulk parametric change discarded then healed, or
1811
+ // a large offline divergence on reconnect), it is split into multiple
1812
+ // chunks via `updates-in-range` spans, each under the cap, so even a
1813
+ // large divergence heals instead of being skipped. Import is idempotent
1814
+ // and order-independent (Loro buffers a chunk whose dependency is in a
1815
+ // sibling chunk until it arrives), so the chunks need no ordering.
1816
+ /** @internal */
1817
+ computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
1818
+ let durable;
1819
+ try {
1820
+ durable = VersionVector.decode(persistedSnapshotVersionVector);
1821
+ for (const blob of durableTailBlobs) {
1822
+ const meta = decodeImportBlobMeta(blob, false);
1823
+ durable = mergeVersionVectors(durable, meta.partialEndVersionVector);
1824
+ }
1825
+ }
1826
+ catch (err) {
1827
+ console.warn('[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob', err);
1828
+ return null;
1829
+ }
1830
+ const local = this._doc.version();
1831
+ const cmp = local.compare(durable);
1832
+ // `compare` returns -1/0 when local is at or behind the durable
1833
+ // frontier (nothing to push), 1 when local is strictly ahead, and
1834
+ // undefined when the two are concurrent (local holds ops the server
1835
+ // lacks AND vice versa). Push in the ahead and concurrent cases; the
1836
+ // export from `durable` carries only the local-only ops either way.
1837
+ if (cmp !== undefined && cmp <= 0)
1838
+ return null;
1839
+ // Common case: the whole missing slice fits in one frame. Keep this
1840
+ // path byte-identical to a plain `update` export so the no-chunking
1841
+ // case is unchanged.
1842
+ const full = this._doc.export({ mode: 'update', from: durable });
1843
+ if (full.byteLength <= RESYNC_PUSH_MAX_BYTES)
1844
+ return [full];
1845
+ // Large divergence: split the per-peer missing ranges into chunks.
1846
+ return this.chunkMissingSlice(local, durable);
1847
+ }
1848
+ // The per-peer ranges local holds beyond `durable`, as half-open
1849
+ // [from, to) counter ranges. `export({ mode: 'update', from })` covers
1850
+ // exactly these ops; we re-express them as `updates-in-range` spans so
1851
+ // they can be split.
1852
+ missingRanges(local, durable) {
1853
+ const durableMap = durable.toJSON();
1854
+ const ranges = [];
1855
+ for (const [peer, to] of local.toJSON()) {
1856
+ const from = durableMap.get(peer) ?? 0;
1857
+ if (to > from)
1858
+ ranges.push({ peer, from, to });
1859
+ }
1860
+ return ranges;
1861
+ }
1862
+ // Recursively export the given ranges as one `updates-in-range` blob,
1863
+ // splitting by op count until each blob is under the per-frame cap. An
1864
+ // irreducible single op (one that alone exceeds the cap) is emitted up
1865
+ // to the hard ceiling, or skipped with a warning beyond it so the loop
1866
+ // never wedges on an un-sendable frame.
1867
+ chunkMissingSlice(local, durable) {
1868
+ const pack = (ranges) => {
1869
+ const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
1870
+ if (total === 0)
1871
+ return [];
1872
+ const blob = this._doc.export({
1873
+ mode: 'updates-in-range',
1874
+ spans: ranges.map((r) => ({
1875
+ id: { peer: r.peer, counter: r.from },
1876
+ len: r.to - r.from,
1877
+ })),
1878
+ });
1879
+ if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
1880
+ return [blob];
1881
+ if (total === 1) {
1882
+ // A single op that cannot be split further.
1883
+ if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES)
1884
+ return [blob];
1885
+ console.warn(`[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`);
1886
+ return [];
1887
+ }
1888
+ const [first, second] = splitRangesByLength(ranges, total);
1889
+ return [...pack(first), ...pack(second)];
1890
+ };
1891
+ return pack(this.missingRanges(local, durable));
1892
+ }
1893
+ // Feed an op blob into the local-op send path as if it were a fresh
1894
+ // local commit: ship it through the transport's send hook (which mints
1895
+ // a client_op_id, sends the frame, and arms the retry tracker) and
1896
+ // record it in the outstanding-op map so `savingCount` reflects it and
1897
+ // its `op_ack` resolves it. Used by the resync push, whose blob is an
1898
+ // export rather than a Loro-emitted local update, so it does not arrive
1899
+ // through `subscribeLocalUpdates`. Returns the minted client_op_id, or
1900
+ // null when no send hook is wired (offline / test path).
1901
+ /** @internal */
1902
+ injectLocalUpdate(blob) {
1903
+ this._ensureLocalUpdatesSubscribed();
1904
+ const hook = this._localOpHook;
1905
+ if (!hook)
1906
+ return null;
1907
+ const copy = new Uint8Array(blob.byteLength);
1908
+ copy.set(blob);
1909
+ const clientOpId = hook(copy);
1910
+ this._outstandingByClientId.set(clientOpId, copy);
1911
+ this._fireSubscribers({ kind: 'saving-count' });
1912
+ return clientOpId;
1913
+ }
1914
+ // Internal: park the Document in the SDK cache. Clears app-side
1915
+ // listeners so a stale handle from this session does not receive
1916
+ // events from the next session, but keeps the LoroDoc,
1917
+ // UndoController, outstanding-op map, and the underlying WS subscribe
1918
+ // alive. `mutate()` and `subscribe()` throw while suspended; the next
1919
+ // `openDocumentById` for the same (docId, branchId) calls `_resume()`
1920
+ // and the user gets a fresh session over the same state. App code
1921
+ // calls `close()`, not this method.
1922
+ /** @internal */
1923
+ _suspend() {
1924
+ if (this._closed || this._suspended)
1925
+ return;
1926
+ this._suspended = true;
1927
+ // A pending auto-checkpoint would otherwise fire against a parked
1928
+ // Document and silently no-op via the scheduler's guard.
1929
+ // Cancelling here keeps the timer count clean across long-lived
1930
+ // caches and avoids the no-op fetch flush.
1931
+ this._checkpoints.cancelAuto();
1932
+ this._subs.clear();
1933
+ this._undoStateSubs.clear();
1934
+ this._gone.clearListeners();
1935
+ this._workStatus.clearListeners();
1936
+ this._driveStatus.clearListeners();
1937
+ // Presence subscribes its OWN WS topic and keeps it alive across a park
1938
+ // (resume does not re-create the controller), so drop its app-side
1939
+ // change listeners here the same way as the sets above: a parked doc
1940
+ // must not fire the app's `presence.on('change')` callback on remote
1941
+ // cursor frames for a doc the app has closed. `leave()` would be wrong,
1942
+ // it terminally unsubscribes and the resume path never rebuilds it.
1943
+ this.presence?._suspendListeners();
1944
+ // Tear down the app-side handle subscriptions, THEN drop the caches.
1945
+ // A handle subscribes DIRECTLY to its Loro container (`handles.ts`
1946
+ // `_attachChange`), not through `_unsubLoro`, and a parked doc keeps
1947
+ // applying remote ops to those live containers (the WS stays attached),
1948
+ // so a subscription an app registered before `close()` would keep firing
1949
+ // the app's callback on a doc it has closed. Detaching first stops that;
1950
+ // fresh handles are minted on resume + next access.
1951
+ for (const entry of this._rootHandles.values()) {
1952
+ teardownRootHandle(entry.handle);
1953
+ }
1954
+ this._rootHandles.clear();
1955
+ // Drop the ordered-map cache too. Each wrapper holds the specific root
1956
+ // map / list handles it was built from; leaving it while the root caches
1957
+ // are cleared would strand those delegates outside `_rebindRootHandles`,
1958
+ // so a later version checkout would not rebind them and the ordered map
1959
+ // would keep reading live-head state during a historical preview.
1960
+ this._orderedMapHandles.clear();
1961
+ }
1962
+ // Internal: pair of `_suspend`. The SDK calls this when handing a
1963
+ // cached Document to a new openDocumentById call. App code does
1964
+ // not invoke this directly.
1965
+ /** @internal */
1966
+ _resume() {
1967
+ this._suspended = false;
1968
+ // Pair of the `_suspend` presence gate: reopen the presence controller's
1969
+ // outbound path and re-advertise this client's cursor on the resumed open.
1970
+ // `_suspend` only closed the gate; without this the parked doc's presence
1971
+ // would stay silent for the life of the resumed session.
1972
+ this.presence?._resumeListeners();
1973
+ }
1974
+ // Internal: whether the Document is currently parked. The SDK's
1975
+ // open-document layer reads this to gate transport-level work
1976
+ // (an op-stream frame arriving for a non-parked, non-closed doc
1977
+ // routes normally; for a parked doc the WS is still attached and
1978
+ // the frame applies to state but does not run app-side callbacks).
1979
+ /** @internal */
1980
+ get _isSuspended() {
1981
+ return this._suspended;
1982
+ }
1983
+ close() {
1984
+ if (this._closed)
1985
+ return;
1986
+ this._closeStreams();
1987
+ this._closed = true;
1988
+ this._suspended = false;
1989
+ if (this._unsubLoro) {
1990
+ this._unsubLoro();
1991
+ this._unsubLoro = null;
1992
+ }
1993
+ if (this._unsubLocal) {
1994
+ this._unsubLocal();
1995
+ this._unsubLocal = null;
1996
+ }
1997
+ if (this._undo) {
1998
+ this._undo.free();
1999
+ this._undo = null;
2000
+ }
2001
+ this._thumbnails.cancel();
2002
+ this._checkpoints.cancelAuto();
2003
+ this._localOpHook = null;
2004
+ this._currentSeqProvider = null;
2005
+ this._outstandingByClientId.clear();
2006
+ this._resyncPushClientOpIds.clear();
2007
+ this._subs.clear();
2008
+ this._undoStateSubs.clear();
2009
+ // Drop the signal listeners too, mirroring `_suspend()`. Their frames
2010
+ // can no longer fire once the transport is torn down, but clearing
2011
+ // them keeps `close()` from retaining an app's callbacks (and the
2012
+ // closures they hold) for the Document's remaining lifetime.
2013
+ this._gone.clearListeners();
2014
+ this._workStatus.clearListeners();
2015
+ this._driveStatus.clearListeners();
2016
+ // Detach handle subscriptions before freeing the doc (below): a handle's
2017
+ // per-container Loro subscription outlives the cache reference, and
2018
+ // `_doc.free()` must not run with subscriptions still attached.
2019
+ for (const entry of this._rootHandles.values()) {
2020
+ teardownRootHandle(entry.handle);
2021
+ }
2022
+ this._rootHandles.clear();
2023
+ this._orderedMapHandles.clear();
2024
+ // Free a live historical-checkout preview: it is a distinct LoroDoc
2025
+ // (allocated by `_checkoutVersion`), so closing while previewing without a
2026
+ // prior `returnToHead()` would otherwise leak its WASM heap. The guard
2027
+ // mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
2028
+ // has `previewDoc === _doc`, where `_doc.free()` below already covers it
2029
+ // and an unguarded free would double-free.
2030
+ if (this._checkout && this._checkout.previewDoc !== this._doc) {
2031
+ try {
2032
+ this._checkout.previewDoc.free();
2033
+ }
2034
+ catch {
2035
+ // ignore; already freed
2036
+ }
2037
+ }
2038
+ this._checkout = null;
2039
+ // Loro's WASM has a `free()` to drop the heap-allocated doc state.
2040
+ // Calling it after `close()` invalidates the doc, which is what we
2041
+ // want; future mutate/subscribe calls throw above.
2042
+ this._doc.free();
2043
+ }
2044
+ }
2045
+ //# sourceMappingURL=documents.js.map