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