@brass-build/client 0.4.1 → 0.5.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 (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. package/src/ws-frame-limit.ts +1 -0
package/src/documents.ts CHANGED
@@ -9,16 +9,63 @@
9
9
  // `Object.keys`; doing `loroMap.keys().includes(key)` there makes
10
10
  // `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
11
11
 
12
+ import type { ImportReport } from './import-report.js';
13
+ import { INPUTS_READ_ROOT, INPUTS_REFS_ROOT } from './inputs.js';
14
+ import { importTailOp } from './tail-op-import.js';
15
+ import {
16
+ InputStatusHub,
17
+ type InputStatus,
18
+ type InputsLocalRoots,
19
+ type InputsStatusRuntime,
20
+ } from './inputs-status.js';
21
+ import {
22
+ InputFollowers,
23
+ type InputDocumentHandlers,
24
+ type InputDocumentOptions,
25
+ } from './input-documents.js';
12
26
  import {
13
27
  LoroDoc,
14
28
  LoroMap,
15
29
  type LoroText,
16
30
  type PeerID,
31
+ type Value,
17
32
  VersionVector,
18
33
  decodeImportBlobMeta,
19
34
  } from './wasm-runtime.js';
20
35
 
21
- import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
36
+ import {
37
+ NON_UNDOABLE_COMMIT_ORIGIN,
38
+ PAGING_COMMIT_ORIGIN,
39
+ UndoController,
40
+ UndoGroup,
41
+ } from './undo.js';
42
+ import { joinStampOf } from './join-stamp.js';
43
+ import {
44
+ decodeInputsRoots,
45
+ inputReadClearWrites,
46
+ inputReadMarkFor,
47
+ inputReadMarkWrites,
48
+ inputRemovalMark,
49
+ inputRefsWrites,
50
+ inputStampFromUpdatedAt,
51
+ isInputDocId,
52
+ isInputKey,
53
+ parseInputReference,
54
+ type InputReadMark,
55
+ type InputReferenceWire as WireReference,
56
+ type InputsRootWrites,
57
+ } from './inputs.js';
58
+ import {
59
+ ProvisionalInputsTracker,
60
+ isReservedRootName,
61
+ latestReadMark,
62
+ type InputsWrite,
63
+ type MarkReadEntry,
64
+ type MarkReadHandle,
65
+ type MarkReadOptions,
66
+ type MarkReadStatus,
67
+ type ProvisionalInputsPoster,
68
+ } from './references.js';
22
69
  import type { DocSyncDiagnostics, UndoControllerOptions } from './types.js';
23
70
  import {
24
71
  createOrderedMapHandle,
@@ -37,26 +84,30 @@ import type {
37
84
  HandleOwner,
38
85
  ListHandle,
39
86
  MapHandle,
87
+ MutateOptions,
40
88
  OrderedMapHandle,
41
89
  TextHandle,
42
90
  TextSubscriber,
43
91
  } from './handle-types.js';
92
+ import { appCallbackError } from './subscriber-delivery.js';
44
93
  import {
45
94
  flattenPagedLookup,
46
95
  isPagedMarker,
96
+ isPagedMarkerEntry,
47
97
  META_KEY,
48
98
  pagedRootKey,
99
+ RECORDS_KEY,
100
+ rootCarriesMarkers,
49
101
  } from './record-paging.js';
50
102
  import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
51
103
  import type {
52
104
  BranchSummary,
53
- DocumentCheckpoint,
54
105
  DocumentSourceBlob,
55
106
  DocumentStreamInfo,
56
107
  PushToDriveInput,
57
108
  PushToDriveResult,
58
109
  } from './documents-namespace.js';
59
- import type { DriveResolveResult } from './hosted-ui.js';
110
+ import type { DriveResolveResult, InputReference } from './hosted-ui.js';
60
111
  import {
61
112
  GoneSignal,
62
113
  WorkStatusSignal,
@@ -86,6 +137,9 @@ import {
86
137
  type CheckpointCreator,
87
138
  } from './document-checkpoints.js';
88
139
  import { whenSaved } from './when-saved.js';
140
+ import { BrassError } from './errors.js';
141
+ import type { OpenedDocument } from './open-document.js';
142
+ import type { OpenModeOptions, ReadOnlyDocument } from './read-only-document.js';
89
143
  import {
90
144
  BranchController,
91
145
  type BranchProvider,
@@ -100,6 +154,15 @@ import {
100
154
  } from './document-commands.js';
101
155
  import { DocumentStream, type StreamRuntime } from './document-streams.js';
102
156
  import { createEmitter } from './emitter.js';
157
+ import {
158
+ CONVERSION_PEER_ID,
159
+ SealedContent,
160
+ type SealedContentJson,
161
+ type SealedStore,
162
+ } from './sealed-store.js';
163
+ import { sealedRoots, type SealedRoots } from './sealed-handles.js';
164
+ import { StableHandles } from './stable-handles.js';
165
+ import type { JoinContent } from './join-content.js';
103
166
 
104
167
  // The stream name `doc.stream()` resolves to the document's own default
105
168
  // state; mirrors the server's reserved name.
@@ -190,6 +253,22 @@ function rootKindOf(containerId: string): KnownRootKind | null {
190
253
 
191
254
  // Maps count entries, lists and text count length. Both are the container's
192
255
  // own counter rather than a projection of what it holds.
256
+ /** Whether a root map holds nothing but paged markers, which its handle hides,
257
+ * so the collection reads as empty. */
258
+ function holdsOnlyMarkers(rootKey: string, container: unknown): boolean {
259
+ if (!rootCarriesMarkers(rootKey) || !(container instanceof LoroMap)) return false;
260
+ return container.keys().every((key) => isPagedMarkerEntry(rootKey, key));
261
+ }
262
+
263
+ // A root's projected value when the collection holds nothing: a map, list
264
+ // or text with no content.
265
+ function projectsEmpty(value: unknown): boolean {
266
+ if (value === '') return true;
267
+ if (Array.isArray(value)) return value.length === 0;
268
+ if (value !== null && typeof value === 'object') return Object.keys(value).length === 0;
269
+ return false;
270
+ }
271
+
193
272
  function rootIsEmpty(container: unknown): boolean {
194
273
  const sized = container as { size?: number; length?: number };
195
274
  if (typeof sized.size === 'number') return sized.size === 0;
@@ -247,6 +326,26 @@ const REDO_COMMIT_ORIGIN = 'brass.redo';
247
326
  // this (it reconciles by value); it backs a change's `origin.source` for
248
327
  // attribution and the SDK's own local-vs-remote side-effect gating
249
328
  // (thumbnail capture, auto-checkpoint).
329
+ // A reserved root map as plain entries, the view the write rules diff against.
330
+ function rootView(map: LoroMap): Record<string, unknown> {
331
+ return map.toJSON() as Record<string, unknown>;
332
+ }
333
+
334
+ // Applies one reserved root's writes per entry. Returns whether anything moved.
335
+ function applyRootWrites<V>(map: LoroMap, writes: InputsRootWrites<V>): boolean {
336
+ for (const key of writes.delete) map.delete(key);
337
+ for (const [key, value] of Object.entries(writes.set)) map.set(key, value as unknown as Value);
338
+ return writes.delete.length > 0 || Object.keys(writes.set).length > 0;
339
+ }
340
+
341
+ // The reserved roots hold the document's references and read marks, which
342
+ // only `dependsOn` and `markRead` write.
343
+ function assertAppRootName(key: string): void {
344
+ if (isReservedRootName(key)) {
345
+ throw new TypeError(`"${key}" is reserved for Brass; choose another collection name`);
346
+ }
347
+ }
348
+
250
349
  function changeSourceOf(event: unknown): ChangeSource {
251
350
  const by = (event as { by?: unknown }).by;
252
351
  if (by === 'import') return 'remote';
@@ -322,24 +421,84 @@ export type ResyncPushOutcome =
322
421
  | 'undecodable'
323
422
  | 'closed';
324
423
 
325
- // Element-wise max of two Loro version vectors. `VersionVector` exposes
326
- // no public join, so round-trip through its `Map<PeerID, number>` JSON
327
- // form. Counters are exclusive ends, so the larger is more advanced.
328
- // Used by the durability handshake to fold the durable tail into the
329
- // snapshot's version vector.
330
- function mergeVersionVectors(
331
- a: VersionVector,
332
- b: VersionVector,
333
- ): VersionVector {
334
- const merged = a.toJSON();
335
- for (const [peer, counter] of b.toJSON()) {
336
- const current = merged.get(peer);
337
- if (current === undefined || counter > current) merged.set(peer, counter);
338
- }
339
- return VersionVector.parseJSON(merged);
424
+ type CounterRange = { peer: PeerID; from: number; to: number };
425
+
426
+ // Per author, the sorted, disjoint half-open counter ranges a server holds.
427
+ //
428
+ // A version vector cannot describe this: it says an author's ops are held up
429
+ // to a counter, and a server's op log can hold an author's later ops while
430
+ // missing an earlier one. Each tail blob therefore contributes its own range
431
+ // rather than raising a per-author maximum, which would read the hole as held.
432
+ type HeldRanges = Map<PeerID, [number, number][]>;
433
+
434
+ function holdRange(held: HeldRanges, peer: PeerID, from: number, to: number): void {
435
+ if (to <= from) return;
436
+ const merged: [number, number][] = [];
437
+ let next: [number, number] = [from, to];
438
+ for (const range of held.get(peer) ?? []) {
439
+ if (range[1] < next[0] || range[0] > next[1]) {
440
+ merged.push(range);
441
+ } else {
442
+ next = [Math.min(range[0], next[0]), Math.max(range[1], next[1])];
443
+ }
444
+ }
445
+ merged.push(next);
446
+ merged.sort((a, b) => a[0] - b[0]);
447
+ held.set(peer, merged);
448
+ }
449
+
450
+ // What the durable state holds: the snapshot, which is causally complete so
451
+ // every author's ops from zero, plus each tail blob's own per-author range.
452
+ // Throws on a malformed vector or blob.
453
+ function durableHoldings(
454
+ persistedSnapshotVersionVector: Uint8Array,
455
+ durableTailBlobs: readonly Uint8Array[],
456
+ ): HeldRanges {
457
+ const held: HeldRanges = new Map();
458
+ for (const [peer, to] of VersionVector.decode(persistedSnapshotVersionVector).toJSON()) {
459
+ holdRange(held, peer, 0, to);
460
+ }
461
+ for (const blob of durableTailBlobs) {
462
+ const meta = decodeImportBlobMeta(blob, false);
463
+ const start = meta.partialStartVersionVector.toJSON();
464
+ for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
465
+ holdRange(held, peer, start.get(peer) ?? 0, to);
466
+ }
467
+ }
468
+ return held;
469
+ }
470
+
471
+ // The ranges of `local` the durable state does not hold, in counter order
472
+ // per author.
473
+ function rangesMissingFrom(local: VersionVector, held: HeldRanges): CounterRange[] {
474
+ const missing: CounterRange[] = [];
475
+ for (const [peer, to] of local.toJSON()) {
476
+ let from = 0;
477
+ for (const [heldFrom, heldTo] of held.get(peer) ?? []) {
478
+ if (heldFrom >= to) break;
479
+ if (heldFrom > from) missing.push({ peer, from, to: heldFrom });
480
+ from = Math.max(from, heldTo);
481
+ }
482
+ if (from < to) missing.push({ peer, from, to });
483
+ }
484
+ return missing;
485
+ }
486
+
487
+ // Group ranges so no group carries two ranges of one author. A blob's header
488
+ // names one start and one end per author, so a blob spanning two separate
489
+ // ranges of an author would read, to whoever folds it into a frontier, as
490
+ // holding the ops between them as well.
491
+ function oneRangePerAuthor(ranges: readonly CounterRange[]): CounterRange[][] {
492
+ const groups: CounterRange[][] = [];
493
+ const seen = new Map<PeerID, number>();
494
+ for (const range of ranges) {
495
+ const index = seen.get(range.peer) ?? 0;
496
+ seen.set(range.peer, index + 1);
497
+ (groups[index] ??= []).push(range);
498
+ }
499
+ return groups;
340
500
  }
341
501
 
342
- type CounterRange = { peer: PeerID; from: number; to: number };
343
502
 
344
503
  function rangeOpCount(ranges: readonly CounterRange[]): number {
345
504
  return ranges.reduce((n, r) => n + (r.to - r.from), 0);
@@ -504,8 +663,29 @@ function pageRoot(
504
663
  // Why a live Document refuses local writes. Each names a refusal the server
505
664
  // would make, so `mutate` can say which one applies rather than answering
506
665
  // every refusal with one message.
507
- /** @internal */
508
- export type ReadOnlyReason =
666
+ /**
667
+ * Why a reader may not write, where the refusal comes from somewhere OUTSIDE
668
+ * this document: the server, the link they arrived through, or the caller's
669
+ * own ask. `_markReadOnly` takes one of these and nothing else, because the
670
+ * reasons below it are states the document computes for itself and could not
671
+ * be handed one of.
672
+ * @internal
673
+ */
674
+ /** The three ways a document is open: editable, read-only and following
675
+ * edits, or frozen at what it loaded.
676
+ * @internal */
677
+ export type ReopenMode = 'edit' | 'read' | 'frozen';
678
+
679
+ interface PendingReopen {
680
+ mode: ReopenMode;
681
+ // What the reopen produced, whether or not a later reopen superseded it.
682
+ result: Promise<OpenedDocument>;
683
+ superseded: boolean;
684
+ // What the call that started it receives.
685
+ promise: Promise<OpenedDocument>;
686
+ }
687
+
688
+ export type ForcedReadOnlyReason =
509
689
  // Opened through a view-only share link.
510
690
  | 'link'
511
691
  // The reader's grant on this document does not carry write access.
@@ -523,7 +703,55 @@ export type ReadOnlyReason =
523
703
  | 'capacity'
524
704
  // The document opened with no live connection to write through, so a
525
705
  // change would stay on this device.
526
- | 'no_connection';
706
+ | 'no_connection'
707
+ // The caller asked for a snapshot of the document rather than a session on
708
+ // it. Unlike every reason above, this one is the caller's own choice rather
709
+ // than a limit on them, so a reader seeing it may well hold write access and
710
+ // reach it by opening the document again without asking for a snapshot.
711
+ | 'snapshot'
712
+ // The document is too large for the platform to make editable, so it opens
713
+ // as a copy to read and no reload changes that.
714
+ | 'too_large'
715
+ // The document still holds only the file it was uploaded as, and no
716
+ // importer takes that file's type, so the file is its content.
717
+ | 'not_imported';
718
+
719
+ /**
720
+ * Why a reader may not write, in the vocabulary `mutate` refuses in.
721
+ *
722
+ * The four beyond the forced ones are states this document computes for
723
+ * itself: it is showing a historical version, its reader has the version bar
724
+ * open, its content has not arrived, or the work that would bring it ended
725
+ * with nothing. They are derived rather than set, which is why one function
726
+ * (`_readOnlyBecause`) answers both `readOnly` and the refusal message. Two
727
+ * ladders over the same conditions is what they replace, and the pair could
728
+ * disagree about whether a document was writable and about why.
729
+ * @internal
730
+ */
731
+ export type ReadOnlyReason =
732
+ | ForcedReadOnlyReason
733
+ // A historical version is showing (a checkout).
734
+ | 'version'
735
+ // The reader has the version bar open. Browsing locks the document at the
736
+ // live head as well as at an earlier point: stepping moves between the
737
+ // points history KEEPS, and an edit at the head makes no such point, so an
738
+ // edit made under the open bar puts the reader's own newest work somewhere
739
+ // the bar cannot travel to. Reading history and writing it are separate
740
+ // modes, and the bar carries the Close that leaves this one.
741
+ | 'browsing'
742
+ // Content-replacing work is still coming. Editing would write ops onto a
743
+ // document the incoming bytes are about to replace, and it is also what
744
+ // stops an app's "seed an empty doc" path from clobbering a document that
745
+ // only looks empty because it has not loaded yet.
746
+ | 'loading'
747
+ // That work ended with no content. A document nothing could prepare is
748
+ // empty, and an edit there writes ops into one the app is about to be told
749
+ // could not be opened.
750
+ | 'load_failed'
751
+ // `reopen` is replacing this copy. The copy closes once the new one has
752
+ // joined, and closing it drops whatever it has not sent, so an edit made
753
+ // here in the meantime would be lost.
754
+ | 'reopening';
527
755
 
528
756
  const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
529
757
  link: 'Document is read-only: it was opened via a view-only share link.',
@@ -538,12 +766,80 @@ const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
538
766
  no_connection:
539
767
  'Document is read-only: it opened with no live connection, so a change ' +
540
768
  'would stay on this device.',
769
+ snapshot:
770
+ 'Document is read-only: it was opened as a snapshot. Open it without ' +
771
+ 'asking for one to edit.',
772
+ too_large:
773
+ 'Document is read-only: it is too large for Brass to make editable ' +
774
+ '(see `doc.status`).',
775
+ not_imported:
776
+ 'Document is read-only: it holds an uploaded file that has not been ' +
777
+ 'imported, and no importer takes its type.',
778
+ version:
779
+ 'Document is read-only: it is showing a historical version. ' +
780
+ 'Return to the latest version to edit.',
781
+ browsing:
782
+ 'Document is read-only: the reader is browsing its versions. ' +
783
+ 'Close the version bar to edit.',
784
+ loading:
785
+ 'Document is still loading its content; wait for the load to finish ' +
786
+ 'before editing (see `doc.onStatus`).',
787
+ reopening:
788
+ 'Document is read-only while it reopens; edit the copy `reopen` resolves with.',
789
+ load_failed:
790
+ 'Document is read-only: its content could not be prepared ' +
791
+ '(see `doc.status`). Reopen it to try again.',
541
792
  };
542
793
 
794
+ // What `status` reads while this client's edits wait on a connection that has
795
+ // never opened. Pending, because Brass keeps trying and the edits save the
796
+ // moment it connects; not blocking, because editing stays open.
797
+ const NOT_SAVED_NOTICE: DocumentWorkStatus = {
798
+ message:
799
+ 'Your changes are not saved yet. Brass cannot connect to save them and ' +
800
+ 'keeps trying. Keep this page open until they are saved.',
801
+ pending: true,
802
+ blocking: false,
803
+ };
804
+
805
+ // What `status` reads while one of this client's edits goes unanswered by a
806
+ // connection that answers its others. Pending and not blocking for the same
807
+ // reasons as `NOT_SAVED_NOTICE`.
808
+ const UNANSWERED_NOTICE: DocumentWorkStatus = {
809
+ message:
810
+ 'Some of your changes are not saved yet. Brass keeps trying to save them. ' +
811
+ 'Keep this page open until they are saved.',
812
+ pending: true,
813
+ blocking: false,
814
+ };
815
+
816
+ const SAVE_NOTICES = { never_opened: NOT_SAVED_NOTICE, unanswered: UNANSWERED_NOTICE };
817
+
543
818
  export interface DocumentOptions {
544
819
  // Root key the Document wraps. Defaults to `"entities"` to match the
545
820
  // wire protocol's top-level shape.
546
821
  rootKey?: string;
822
+ /** The document's content in its frozen form, for a stream the platform
823
+ * holds that way. Its roots answer every read, and no CRDT engine
824
+ * materialises them.
825
+ * @internal */
826
+ frozenContent?: SealedContent;
827
+ /** The seq `frozenContent` was sealed at, as the join named it.
828
+ * @internal */
829
+ frozenSeal?: number;
830
+ /** Author every commit as this peer rather than a random one.
831
+ *
832
+ * What it buys is a DETERMINISTIC document: the same records written under
833
+ * the same peer produce the same ops, so two sides that build a document
834
+ * from one record set independently build the same one, and their later
835
+ * edits merge instead of doubling every record. That is what lets a stored
836
+ * record set become a CRDT wherever it is first needed, with nothing
837
+ * coordinating which side does it.
838
+ *
839
+ * Only a conversion sets this. A live editor keeps its own peer, so the
840
+ * ops it writes after a conversion are its own.
841
+ * @internal */
842
+ peerId?: `${number}`;
547
843
  // The server-minted `doc_id`. Set by the SDK's open / create paths so
548
844
  // callers read `doc.id` to track the active doc. `null` on
549
845
  // direct-construction (`Document.create()` / `openFromSnapshot`),
@@ -555,11 +851,12 @@ export interface DocumentOptions {
555
851
  branchId?: string;
556
852
  // True when opened at a historical version. A read-only Document
557
853
  // throws from `mutate` and carries no live WS transport.
854
+ /** @deprecated Pass `readOnly: true` to `session.open` / `Brass.openDocumentById`, which types the result `ReadOnlyDocument`. */
558
855
  readOnly?: boolean;
559
856
  // Why `readOnly` was set, which picks the message `mutate` throws.
560
857
  // Defaults to the view-only link.
561
858
  /** @internal */
562
- readOnlyReason?: ReadOnlyReason;
859
+ readOnlyReason?: ForcedReadOnlyReason;
563
860
  // The historical version materialized at, when opened via `{ version
564
861
  // }`; `null` for a live (head) open. The server's *effective* version,
565
862
  // which may round down when compaction has TTL'd the exact op.
@@ -588,9 +885,242 @@ interface RootTextHandle extends TextHandle {
588
885
  _teardown(): void;
589
886
  }
590
887
 
888
+ // The roots a document's referenced inputs live in are written only by the
889
+ // SDK's own input writers, so a frozen root carrying one of those names
890
+ // converts to nothing.
891
+ const INPUTS_ROOTS: ReadonlySet<string> = new Set([INPUTS_REFS_ROOT, INPUTS_READ_ROOT]);
892
+
893
+ /**
894
+ * The CRDT a frozen stream becomes: its content written through the same
895
+ * handles an app writes through, so what a conversion produces is what an app
896
+ * writing the same content would have produced, paging and all.
897
+ *
898
+ * Every side converts by calling this. It is authored by the pinned
899
+ * conversion peer (`CONVERSION_PEER_ID`) over the content in stored order, so
900
+ * one frozen stream always converts to the same document; it is NOT a licence
901
+ * for two builds to convert separately and expect to agree, because the rest
902
+ * of the output moves with the paging constants and the batching budget, which
903
+ * is why the platform converts once and stores the result.
904
+ *
905
+ * A list that is a map's key walk is that map's order, so the two are written
906
+ * as one ordered map.
907
+ *
908
+ * @internal
909
+ */
910
+ export function snapshotFromSealedContent(content: SealedContent): Uint8Array {
911
+ const names = content.rootNames().filter((n) => !INPUTS_ROOTS.has(n));
912
+ const walked = new Map<string, string>();
913
+ for (const name of names) {
914
+ const map = content.keyWalkOf(name);
915
+ if (map !== null && !walked.has(map)) walked.set(map, name);
916
+ }
917
+ // A `Document` registers its root container on construction, so the root
918
+ // key is one the content writes anyway rather than an empty `entities` map
919
+ // in every snapshot.
920
+ const rootKey =
921
+ [...walked.keys()][0] ??
922
+ names.find((n) => content.kindOf(n) === 'map') ??
923
+ 'entities';
924
+ const doc = Document.create({ rootKey, peerId: CONVERSION_PEER_ID });
925
+ try {
926
+ for (const name of names) {
927
+ // A reserved root is written only through `dependsOn` / `markRead`.
928
+ if (isReservedRootName(name)) continue;
929
+ const kind = content.kindOf(name);
930
+ if (kind === 'map') {
931
+ const order = walked.get(name);
932
+ const write = (draft: Record<string, unknown>): void => {
933
+ content.forEachMapEntry(name, (key, value) => {
934
+ draft[key] = value;
935
+ });
936
+ };
937
+ if (order !== undefined) doc.orderedMap<unknown>(name, order).mutate(write);
938
+ else doc.map<unknown>(name).mutate(write);
939
+ } else if (kind === 'list') {
940
+ const map = content.keyWalkOf(name);
941
+ if (map !== null && walked.get(map) === name) continue;
942
+ const items = content.list(name) ?? [];
943
+ if (items.length === 0) continue;
944
+ doc.list<unknown>(name).mutate((list) => {
945
+ for (const item of items) list.push(item);
946
+ });
947
+ } else if (kind === 'text') {
948
+ const text = content.text(name) ?? '';
949
+ if (text !== '') doc.text(name).update(text);
950
+ }
951
+ }
952
+ return doc._exportSnapshot();
953
+ } finally {
954
+ doc.close();
955
+ }
956
+ }
957
+
958
+ /**
959
+ * A CRDT snapshot's content as JSON, read the way an app reads it (a paged
960
+ * root flattened to its records), holding exactly the roots the snapshot
961
+ * holds. A Document registers its root map on construction, so it is opened
962
+ * on a root the snapshot already has rather than on the default.
963
+ * @internal
964
+ */
965
+ export function snapshotContentJSON(snapshot: Uint8Array): Record<string, unknown> {
966
+ // One engine, and the roots named from its shallow value: a whole-engine
967
+ // `toJSON` here would hold a second copy of the content beside the one the
968
+ // read below builds.
969
+ const engine = new LoroDoc();
970
+ engine.import(snapshot);
971
+ const shallow = engine.getShallowValue() as Record<string, unknown>;
972
+ const held = Object.keys(shallow);
973
+ const rootKey = held.find((k) => {
974
+ if (isReservedRootName(k)) return false;
975
+ const id = shallow[k];
976
+ return typeof id === 'string' && id.endsWith(':Map');
977
+ });
978
+ const doc = Document._fromEngine(engine, rootKey !== undefined ? { rootKey } : undefined);
979
+ try {
980
+ const json = doc.toJSON();
981
+ for (const key of Object.keys(json)) if (!held.includes(key)) delete json[key];
982
+ return json;
983
+ } finally {
984
+ doc.close();
985
+ }
986
+ }
987
+
988
+ /** The CRDT a stored record store becomes. The same conversion as
989
+ * `snapshotFromSealedContent`, reached from the record-store view.
990
+ * @internal */
991
+ /** A whole CRDT document as plain JSON, its paged roots as records. */
992
+ function engineJSON(source: LoroDoc): Record<string, unknown> {
993
+ const json = source.toJSON() as Record<string, unknown>;
994
+ // The document's references and read marks are the platform's, read
995
+ // through `dependsOn` and the input status rather than as content.
996
+ for (const key of Object.keys(json)) if (isReservedRootName(key)) delete json[key];
997
+ const meta = json[META_KEY];
998
+ if (isRootLookup(meta)) {
999
+ for (const key of Object.keys(meta)) {
1000
+ if (isPagedMarkerEntry(META_KEY, key)) delete meta[key];
1001
+ }
1002
+ }
1003
+ // A paged root holds its records inside page entries beside the store's
1004
+ // own edit bookkeeping, so flatten it to the same record lookup
1005
+ // `doc.map(root).read()` hands back: a serializer and a whole-document
1006
+ // renderer read this surface, and a store that crossed the paging
1007
+ // threshold has to reach them as records. Every root is asked, because a
1008
+ // root no handle was ever taken on is paged by the same rule as one that
1009
+ // was; a root the marker does not name comes back as loro produced it.
1010
+ for (const [rootKey, value] of Object.entries(json)) {
1011
+ if (!isRootLookup(value)) continue;
1012
+ if (!readRootPaged(source, rootKey)) continue;
1013
+ json[rootKey] = flattenPagedLookup(value, true);
1014
+ }
1015
+ return json;
1016
+ }
1017
+
1018
+ /** A CRDT engine holding decoded join content. */
1019
+ function engineFrom(content: JoinContent): LoroDoc {
1020
+ const doc = new LoroDoc();
1021
+ if (content.kind === 'crdt') doc.import(content.bytes);
1022
+ else if (content.kind === 'frozen') doc.import(snapshotFromSealedContent(content.content));
1023
+ return doc;
1024
+ }
1025
+
1026
+ /**
1027
+ * A version of a Document over frozen content, as the content its handles read.
1028
+ *
1029
+ * Frozen content with nothing above it is the version itself. Anything else
1030
+ * (a version stored as a CRDT, or a tail over the seal) is materialised and
1031
+ * read back as content: a preview takes no write, so the engine it needs is
1032
+ * built for the read and freed after it.
1033
+ */
1034
+ function frozenPreview(content: JoinContent, tailOpBlobs: Uint8Array[]): SealedContent {
1035
+ if (content.kind === 'frozen' && tailOpBlobs.length === 0) return content.content;
1036
+ const engine = engineFrom(content);
1037
+ try {
1038
+ for (const blob of tailOpBlobs) importTailOp(engine, blob);
1039
+ return SealedContent.fromJSON(engineJSON(engine) as SealedContentJson);
1040
+ } finally {
1041
+ engine.free();
1042
+ }
1043
+ }
1044
+
1045
+ export function snapshotFromSealedStore(store: SealedStore): Uint8Array {
1046
+ return snapshotFromSealedContent(store.content);
1047
+ }
1048
+
1049
+ /** What a record-store writer is handed. */
1050
+ export interface RecordStoreSource {
1051
+ schemaVersion: string;
1052
+ header: readonly { tag: string; body: unknown }[];
1053
+ /** Walks every record, in order. */
1054
+ forEachRecord(
1055
+ visit: (key: string, tag: string | null, body: unknown) => void,
1056
+ ): void;
1057
+ }
1058
+
1059
+ /**
1060
+ * Write a record store as a CRDT snapshot through the one conversion every
1061
+ * frozen stream takes.
1062
+ */
1063
+ export function recordStoreSnapshot(source: RecordStoreSource): Uint8Array {
1064
+ const records: [string, string | null, unknown][] = [];
1065
+ source.forEachRecord((key, tag, body) => {
1066
+ records.push([key, tag, body]);
1067
+ });
1068
+ return snapshotFromSealedContent(
1069
+ SealedContent.fromRecords({
1070
+ schemaVersion: source.schemaVersion,
1071
+ header: source.header,
1072
+ records,
1073
+ }),
1074
+ );
1075
+ }
1076
+
1077
+ /**
1078
+ * Materialises a line at a version: the content the join names, the tail
1079
+ * above it, and the version the server resolved. `streamSeqs` is the per-stream
1080
+ * seq vector at that version (from the newest checkpoint at or below it),
1081
+ * `{ line_id: seq }`, which lets a preview bring every opened stream to the
1082
+ * same point; absent for a stream line's own timeline and before any
1083
+ * checkpoint recorded one.
1084
+ * @internal
1085
+ */
1086
+ export type CheckoutFetcher = (
1087
+ docId: string,
1088
+ version: number,
1089
+ branchId: string,
1090
+ ) => Promise<{
1091
+ content: JoinContent;
1092
+ tailOpBlobs: Uint8Array[];
1093
+ effectiveVersion: number;
1094
+ streamSeqs?: Record<string, number>;
1095
+ }>;
1096
+
591
1097
  export class Document {
592
- private readonly _doc: LoroDoc;
593
- private readonly _rootKey: string;
1098
+ private _importReport: ImportReport | null = null;
1099
+ private _changed = false;
1100
+ private _changeNotice: ((fire: () => void) => () => void) | null = null;
1101
+ private _stopChangeNotice: (() => void) | null = null;
1102
+ private readonly _changedListeners = new Set<() => void>();
1103
+ // A frozen copy's reload, wired by the transport: re-joins and applies the
1104
+ // result in place. `null` on every other open.
1105
+ private _frozenReloader: (() => Promise<void>) | null = null;
1106
+ // Opens this document again in another mode. Installed by whichever layer
1107
+ // opened it: the session for the document it holds, the client otherwise.
1108
+ private _reopener: ((mode: ReopenMode) => Promise<OpenedDocument>) | null = null;
1109
+ private _reloading: Promise<void> | null = null;
1110
+ // `onStatus` listeners on a frozen copy, which hears status on the same
1111
+ // notice the change report rides.
1112
+ private _statusWatches = 0;
1113
+ private _inputsRuntime: InputsStatusRuntime | null = null;
1114
+ // Built on the first status subscription.
1115
+ private _inputStatusHub: InputStatusHub | null = null;
1116
+ // The `subscribeInputDocuments` followers, which close and park stop so no
1117
+ // retry, handler or kept copy outlives the document.
1118
+ private readonly _inputFollowers = new InputFollowers();
1119
+
1120
+ // The CRDT engine. `null` until something reads it: a Document over frozen
1121
+ // content never does, so it holds no engine at all.
1122
+ private _engine: LoroDoc | null;
1123
+ private _rootKey: string;
594
1124
  // Server-minted `doc_id`, or `null` when constructed without one
595
1125
  // (test / offline path).
596
1126
  private readonly _docId: string | null;
@@ -622,6 +1152,32 @@ export class Document {
622
1152
  // and `returnToHead` reject. The live + checkout-on-demand path
623
1153
  // leaves this `false`.
624
1154
  private readonly _isFrozen: boolean;
1155
+ // The frozen content this Document reads, when the platform holds its
1156
+ // content in that encoding. Its roots serve `map` / `list` / `orderedMap`
1157
+ // and `toJSON` in place of the CRDT's, so an app reads one document
1158
+ // surface whichever way the content is stored. `null` for a CRDT document.
1159
+ private _sealedRoots: SealedRoots | null;
1160
+ // The frozen content at the head, and the content the roots read now: the
1161
+ // head, or a version a checkout swapped in. `null` with `_sealedRoots`.
1162
+ private _frozenHead: SealedContent | null;
1163
+ private _frozenShown: SealedContent | null;
1164
+ // The version a Document over frozen content is showing, or `null` for its
1165
+ // head.
1166
+ private _frozenVersion: number | null = null;
1167
+ // The seq the frozen content was sealed at, which is what says whether a
1168
+ // later join's frozen content is the same content.
1169
+ private _frozenSeal: number | null;
1170
+ // What `map` / `list` / `text` / `orderedMap` hand out: one handle per
1171
+ // root, over whichever storage this Document holds now.
1172
+ private readonly _handles = new StableHandles((err) => this._handleOwner.reportSubscriberThrow(err));
1173
+ // What an engine built for this Document after construction is built with:
1174
+ // the root a CRDT open would root `mutate` at, and the caller's engine
1175
+ // options.
1176
+ private _engineRootKey: string;
1177
+ private readonly _engineOptions: Pick<DocumentOptions, 'peerId' | 'undo'>;
1178
+ // Swaps this Document's frozen content for the CRDT the server now holds,
1179
+ // wired by the transport. Absent, a remote op over frozen content reloads.
1180
+ private _storageUpgrader: (() => void) | null = null;
625
1181
  // Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
626
1182
  // is a version-pinned open with no live channel): the Document keeps a
627
1183
  // live WS subscription and applies incoming remote ops, but local
@@ -630,8 +1186,11 @@ export class Document {
630
1186
  // grant, a view-only link), and latched on afterwards by
631
1187
  // `_markReadOnly` when the server refuses a write on the same grounds.
632
1188
  private _forcedReadOnly: boolean;
1189
+ // Set while `reopen` replaces this copy (see the `reopening` reason).
1190
+ private _reopening = false;
1191
+ private _pendingReopen: PendingReopen | null = null;
633
1192
  // Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
634
- private _readOnlyReason: ReadOnlyReason;
1193
+ private _readOnlyReason: ForcedReadOnlyReason;
635
1194
  // The UndoManager is constructed eagerly so the first call to
636
1195
  // `doc.mutate(...)` lands in the undo stack. Lazy construction
637
1196
  // (deferred to first `doc.undo()`) would miss every commit made
@@ -640,6 +1199,10 @@ export class Document {
640
1199
  // only; it does NOT fire on snapshot import, so the
641
1200
  // cold-load contract is unaffected (imports are not local commits).
642
1201
  private _undo: UndoController | null;
1202
+ // The document's one undo stack across its streams. A Document owns the
1203
+ // group it was created with; a stream's own Document joins its document's
1204
+ // group when the stream opens (`_joinUndoGroup`).
1205
+ private _undoGroup: UndoGroup = new UndoGroup();
643
1206
  // One subscriber channel. Every event reaches every subscriber, whatever
644
1207
  // produced it (local mutate, remote import, undo, redo, checkout); the
645
1208
  // app reconciles by value and reads `origin.source` only when it has a
@@ -657,6 +1220,11 @@ export class Document {
657
1220
  private readonly _undoStateSubs: Set<UndoStateSubscriber> = new Set();
658
1221
  private _lastCanUndo = false;
659
1222
  private _lastCanRedo = false;
1223
+ private _replayingUndo = false;
1224
+ // The change a replay produced, held until the replay ends so `subscribe`
1225
+ // fires once per `undo()` / `redo()`. A listener that writes in reaction
1226
+ // would otherwise commit mid-replay, joining the step being replayed.
1227
+ private _replayChange: ChangeSource | null = null;
660
1228
  private _unsubLoro: (() => void) | null = null;
661
1229
  private _unsubLocal: (() => void) | null = null;
662
1230
  // Local commits drive this hook with the resulting op blob; the hook
@@ -667,23 +1235,7 @@ export class Document {
667
1235
  private _localOpHook: LocalOpSendHook | null = null;
668
1236
  // Materialises a historical version against the server; returns the
669
1237
  // snapshot bytes (or null) + tail op blobs + effective version.
670
- private _checkoutFetcher:
671
- | ((
672
- docId: string,
673
- version: number,
674
- branchId: string,
675
- ) => Promise<{
676
- snapshotBytes: Uint8Array | null;
677
- tailOpBlobs: Uint8Array[];
678
- effectiveVersion: number;
679
- // The per-stream seq vector at this version (from the newest
680
- // checkpoint at or below it): `{ line_id: seq }`. Lets a preview
681
- // re-materialize every opened stream at the same point in time.
682
- // Absent for a stream line's own timeline (no checkpoints) and
683
- // before any checkpoint recorded one.
684
- streamSeqs?: Record<string, number>;
685
- }>)
686
- | null = null;
1238
+ private _checkoutFetcher: CheckoutFetcher | null = null;
687
1239
  // The stream seq vector from the active preview, so a stream opened WHILE
688
1240
  // a preview is active can open at its historical seq too. Cleared on
689
1241
  // `returnToHead`.
@@ -715,22 +1267,30 @@ export class Document {
715
1267
  // still hydrating is exactly the one the seed watch is armed for, and
716
1268
  // the arm happens while that lock is still on.
717
1269
  isCaptureAllowed: (): boolean =>
718
- this._isLive && this._checkout === null && !this._forcedReadOnly,
1270
+ this._isLive && this._previewVersion === null && !this._forcedReadOnly,
719
1271
  isAttached: (): boolean => this._isLive,
720
- onCaptured: (): void => {
721
- this._thumbnailVersion = this.currentSeq ?? 0;
1272
+ currentVersion: (): number | null => this.currentSeq,
1273
+ onCaptured: (version?: number): void => {
1274
+ this._thumbnailVersion = version ?? this.currentSeq ?? 0;
722
1275
  },
723
1276
  });
724
1277
  private readonly _checkpoints = new CheckpointScheduler({
725
1278
  docId: (): string | null => this._docId,
726
1279
  isAttached: (): boolean => this._isLive,
727
1280
  isAutoAllowed: (): boolean =>
728
- this._isLive && this._checkout === null,
1281
+ this._isLive && this._previewVersion === null,
729
1282
  currentSeq: (): number | null => this.currentSeq,
730
1283
  sealUndoStep: (): void => {
731
- if (this._undo) this._undo.sealStep();
1284
+ this._undoGroup.seal();
732
1285
  },
1286
+ hasUnsavedOps: (): boolean => this.savingCount > 0,
733
1287
  whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
1288
+ captureFrame: () => this._thumbnails.captureFrame(),
1289
+ uploadFrame: (frame, version): void => {
1290
+ // The debounced capture usually lands first, at the same version.
1291
+ if (this._thumbnailVersion === version) return;
1292
+ void this._thumbnails.uploadFrame(frame, version);
1293
+ },
734
1294
  });
735
1295
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
736
1296
  // then throws with a clear message rather than silently dropping.
@@ -751,6 +1311,9 @@ export class Document {
751
1311
  private _sourceBlobFetcher: (() => Promise<DocumentSourceBlob>) | null = null;
752
1312
  // Cached doc name. `null` on offline / test docs.
753
1313
  private _name: string | null = null;
1314
+ // When the platform will remove this document, for one created without an
1315
+ // account; `null` for every other document.
1316
+ private _expiresAt: Date | null = null;
754
1317
  // True once the reader has asked for the version-browsing surface without
755
1318
  // checking anything out (the hosted history dialog's browse-in-the-app
756
1319
  // outcome). The host's version bar latches on this as well as on a live
@@ -868,9 +1431,21 @@ export class Document {
868
1431
  // below (or `documentFromEngine`, for in-repo tooling that brings its own).
869
1432
  // Owning construction is what lets this class declare an engine-neutral
870
1433
  // surface: app code holds a Document, never the doc underneath it.
871
- private constructor(loroDoc: LoroDoc, options?: DocumentOptions) {
872
- this._doc = loroDoc;
1434
+ private constructor(loroDoc: LoroDoc | null, options?: DocumentOptions) {
1435
+ this._undoGroup.setOwner(this, {
1436
+ blocked: (): boolean => this._undoGroupBlocked(),
1437
+ notify: (): void => this._maybeFireUndoState(),
1438
+ // A write to another stream is an edit like one here, so it arms the
1439
+ // same idle checkpoint, which is what seals the step it opened.
1440
+ memberCommitted: (): void => this._checkpoints.scheduleAuto(),
1441
+ });
1442
+ this._engine = loroDoc;
873
1443
  this._rootKey = options?.rootKey ?? 'entities';
1444
+ this._engineRootKey = this._rootKey;
1445
+ this._engineOptions = {
1446
+ ...(options?.peerId !== undefined ? { peerId: options.peerId } : {}),
1447
+ ...(options?.undo !== undefined ? { undo: options.undo } : {}),
1448
+ };
874
1449
  this._docId = options?.docId ?? null;
875
1450
  this._branchId = options?.branchId ?? null;
876
1451
  // A Document constructed with a `version` is a "frozen" cold-link
@@ -878,14 +1453,20 @@ export class Document {
878
1453
  // `loroDoc` and there is no live WS channel. The Document is
879
1454
  // permanently in checkout against itself; `returnToHead` /
880
1455
  // `_checkoutVersion` are not meaningful on this shape.
1456
+ const frozen = options?.frozenContent;
881
1457
  const initialVersion = options?.version ?? null;
882
1458
  if (initialVersion !== null) {
883
1459
  this._isFrozen = true;
884
- this._checkout = {
885
- previewDoc: loroDoc,
886
- previewRoot: loroDoc.getMap(this._rootKey),
887
- version: initialVersion,
888
- };
1460
+ if (loroDoc !== null) {
1461
+ this._checkout = {
1462
+ previewDoc: loroDoc,
1463
+ previewRoot: loroDoc.getMap(this._rootKey),
1464
+ version: initialVersion,
1465
+ };
1466
+ } else {
1467
+ this._checkout = null;
1468
+ this._frozenVersion = initialVersion;
1469
+ }
889
1470
  } else {
890
1471
  this._isFrozen = false;
891
1472
  this._checkout = null;
@@ -894,12 +1475,63 @@ export class Document {
894
1475
  // reader the server will not accept writes from, whether because their
895
1476
  // grant is view-only or because the link they came through is. A
896
1477
  // version open is already frozen, so it does not also need the flag.
1478
+ // A stored record set answers every read directly, and takes no write: a
1479
+ // record set holds no op log for an edit to become, and making one is the
1480
+ // platform's (see `open-document.ts`, where the join serves a record set
1481
+ // only to a reader). So an app reads one document surface and never
1482
+ // learns which encoding it was handed, and the write it cannot make is
1483
+ // refused by the same read-only gate every other unwritable open uses.
1484
+ this._frozenHead = frozen ?? null;
1485
+ this._frozenShown = frozen ?? null;
1486
+ this._frozenSeal = options?.frozenSeal ?? null;
1487
+ this._sealedRoots =
1488
+ frozen === undefined
1489
+ ? null
1490
+ : sealedRoots(
1491
+ () => this._frozenShown!,
1492
+ () => this._handleOwner.assertWritable(),
1493
+ );
897
1494
  this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
898
1495
  this._readOnlyReason = options?.readOnlyReason ?? 'link';
899
- // Materialize the root container, so a document nothing has written to
900
- // still carries its root in the shape a reader opens it with.
901
- loroDoc.getMap(this._rootKey);
902
- this._undo = new UndoController(this._doc, options?.undo);
1496
+ if (loroDoc !== null) {
1497
+ if (options?.peerId !== undefined) loroDoc.setPeerId(options.peerId);
1498
+ // Materialize the root container, so a document nothing has written to
1499
+ // still carries its root in the shape a reader opens it with.
1500
+ loroDoc.getMap(this._rootKey);
1501
+ this._undo = this._newUndoController(loroDoc, options?.undo);
1502
+ } else {
1503
+ this._undo = null;
1504
+ }
1505
+ }
1506
+
1507
+ // The engine, built on first use for a Document constructed without one.
1508
+ private get _doc(): LoroDoc {
1509
+ if (this._engine === null) {
1510
+ this._engine = new LoroDoc();
1511
+ this._engine.getMap(this._rootKey);
1512
+ }
1513
+ return this._engine;
1514
+ }
1515
+
1516
+ /** Whether this Document has been closed. @internal */
1517
+ get _isClosed(): boolean {
1518
+ return this._closed;
1519
+ }
1520
+
1521
+ /** Whether this Document has built a CRDT engine. @internal */
1522
+ _holdsEngine(): boolean {
1523
+ return this._engine !== null;
1524
+ }
1525
+
1526
+ /** The seq this Document's frozen content was sealed at, or `null` for a
1527
+ * CRDT document or a seal the join did not name. @internal */
1528
+ get _frozenSealSeq(): number | null {
1529
+ return this._frozenSeal;
1530
+ }
1531
+
1532
+ // The version this Document is showing instead of its head, or `null`.
1533
+ private get _previewVersion(): number | null {
1534
+ return this._checkout?.version ?? this._frozenVersion;
903
1535
  }
904
1536
 
905
1537
  // ---- Typed container handles (the primary read/write/subscribe surface) --
@@ -933,15 +1565,31 @@ export class Document {
933
1565
  return this._doc.peerIdStr;
934
1566
  }
935
1567
 
1568
+ /** Whether this document's reads are answered by a stored record set.
1569
+ * @internal */
1570
+ _holdsRecordSet(): boolean {
1571
+ return this._sealedRoots !== null;
1572
+ }
1573
+
936
1574
  // Get a handle for a top-level LoroMap at `key`. The container is
937
1575
  // materialized in the underlying LoroDoc immediately (Loro's
938
1576
  // `getMap` is "get or create"); mutations through the handle do
939
1577
  // not need to create the container themselves.
940
1578
  map<T = unknown>(key: string): MapHandle<T> {
941
1579
  this._assertOpen();
1580
+ assertAppRootName(key);
1581
+ return this._handles.root(`map:${key}`, 'map', () => this._rootMap<T>(key));
1582
+ }
1583
+
1584
+ private _rootMap<T>(key: string): MapHandle<T> {
942
1585
  const cacheKey = `map:${key}`;
943
1586
  const existing = this._rootHandles.get(cacheKey);
944
1587
  if (existing) return existing.handle as unknown as MapHandle<T>;
1588
+ if (this._sealedRoots !== null) {
1589
+ const handle = this._sealedRoots.map<unknown>(key);
1590
+ this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
1591
+ return handle as unknown as MapHandle<T>;
1592
+ }
945
1593
  // Source the LoroMap from the active source (live or checkout
946
1594
  // preview), same as `toJSON` does. Handles bound to a Document
947
1595
  // in checkout see the historical state via the preview doc.
@@ -972,7 +1620,7 @@ export class Document {
972
1620
  },
973
1621
  sessionToken: (): string => this._pagingSessionToken,
974
1622
  stateVersion: (): StateVersion => activeSource().frontiers(),
975
- });
1623
+ }, key);
976
1624
  this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
977
1625
  return handle as unknown as MapHandle<T>;
978
1626
  }
@@ -980,9 +1628,19 @@ export class Document {
980
1628
  // Get a handle for a top-level LoroMovableList at `key`.
981
1629
  list<T = unknown>(key: string): ListHandle<T> {
982
1630
  this._assertOpen();
1631
+ assertAppRootName(key);
1632
+ return this._handles.root(`list:${key}`, 'list', () => this._rootList<T>(key));
1633
+ }
1634
+
1635
+ private _rootList<T>(key: string): ListHandle<T> {
983
1636
  const cacheKey = `list:${key}`;
984
1637
  const existing = this._rootHandles.get(cacheKey);
985
1638
  if (existing) return existing.handle as unknown as ListHandle<T>;
1639
+ if (this._sealedRoots !== null) {
1640
+ const handle = this._sealedRoots.list<unknown>(key);
1641
+ this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
1642
+ return handle as unknown as ListHandle<T>;
1643
+ }
986
1644
  const source =
987
1645
  this._checkout !== null ? this._checkout.previewDoc : this._doc;
988
1646
  const loroList = source.getMovableList(key);
@@ -1013,9 +1671,15 @@ export class Document {
1013
1671
  */
1014
1672
  text(key: string): TextHandle {
1015
1673
  this._assertOpen();
1674
+ assertAppRootName(key);
1675
+ return this._handles.root(`text:${key}`, 'text', () => this._rootText(key));
1676
+ }
1677
+
1678
+ private _rootText(key: string): TextHandle {
1016
1679
  const cacheKey = `text:${key}`;
1017
1680
  const existing = this._rootHandles.get(cacheKey);
1018
1681
  if (existing) return existing.handle as TextHandle;
1682
+ if (this._sealedRoots !== null) return this._sealedRoots.text(key);
1019
1683
  const owner = this._handleOwner;
1020
1684
  const activeSource = (): LoroDoc =>
1021
1685
  this._checkout !== null ? this._checkout.previewDoc : this._doc;
@@ -1127,12 +1791,25 @@ export class Document {
1127
1791
  orderKey: string,
1128
1792
  ): OrderedMapHandle<T> {
1129
1793
  this._assertOpen();
1794
+ return this._handles.root(`ordered:${mapKey}\u0000${orderKey}`, 'map', () =>
1795
+ this._rootOrderedMap<T>(mapKey, orderKey),
1796
+ );
1797
+ }
1798
+
1799
+ private _rootOrderedMap<T>(mapKey: string, orderKey: string): OrderedMapHandle<T> {
1130
1800
  const cacheKey = `${mapKey}\u0000${orderKey}`;
1131
1801
  let h = this._orderedMapHandles.get(cacheKey);
1802
+ if (!h && this._sealedRoots !== null) {
1803
+ // Taken from the store rather than composed from this document's own
1804
+ // map + list handles: the composition exists to keep a map and its
1805
+ // order list in step through writes, and a stored record set has none.
1806
+ h = this._sealedRoots.orderedMap<unknown>(mapKey, orderKey);
1807
+ this._orderedMapHandles.set(cacheKey, h);
1808
+ }
1132
1809
  if (!h) {
1133
1810
  h = createOrderedMapHandle<unknown>(
1134
- this.map(mapKey),
1135
- this.list<string>(orderKey),
1811
+ this._rootMap(mapKey),
1812
+ this._rootList<string>(orderKey),
1136
1813
  this._handleOwner,
1137
1814
  );
1138
1815
  this._orderedMapHandles.set(cacheKey, h);
@@ -1154,42 +1831,23 @@ export class Document {
1154
1831
  },
1155
1832
  assertWritable: (): void => {
1156
1833
  this._assertOpen();
1157
- if (this._checkout !== null) {
1158
- throw new Error(
1159
- 'Document is read-only: it is showing a historical version. ' +
1160
- 'Call `doc.returnToHead()` to edit.',
1161
- );
1162
- }
1163
- if (this._versionBrowsing) {
1164
- throw new Error(
1165
- 'Document is read-only: the reader is browsing its versions. ' +
1166
- 'Close the version bar to edit.',
1167
- );
1168
- }
1169
- if (this._forcedReadOnly) {
1170
- throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
1171
- }
1172
- if (this._loadFailed) {
1173
- throw new Error(
1174
- 'Document is read-only: its content could not be prepared ' +
1175
- '(see `doc.status`). Reopen it to try again.',
1176
- );
1177
- }
1178
- if (this._isLoadLocked()) {
1179
- throw new Error(
1180
- 'Document is still loading its content; wait for the load to ' +
1181
- 'finish before editing (see `doc.onStatus`).',
1182
- );
1183
- }
1834
+ const reason = this._readOnlyBecause;
1835
+ if (reason !== null) throw new Error(READ_ONLY_MESSAGES[reason]);
1184
1836
  },
1185
1837
  ensureLocalUpdatesSubscribed: (): void => {
1186
1838
  this._ensureLocalUpdatesSubscribed();
1187
1839
  },
1188
1840
  commitMutate: (): void => {
1189
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
1841
+ this._doc.setNextCommitOrigin(this._mutateCommitOrigin);
1190
1842
  this._doc.commit();
1191
1843
  },
1844
+ runMutate: (options: MutateOptions | undefined, fn: () => void): void => {
1845
+ this._runInMutate(options?.undoable !== false, fn);
1846
+ },
1192
1847
  originOf: (event: unknown): ChangeSource => changeSourceOf(event),
1848
+ reportSubscriberThrow: (err: unknown): void => {
1849
+ this._reportSubscriberThrow(err);
1850
+ },
1193
1851
  };
1194
1852
  return this._handleOwnerCache;
1195
1853
  }
@@ -1221,28 +1879,45 @@ export class Document {
1221
1879
  // and follows a grant the server withdraws mid-session as well as a
1222
1880
  // checkout the reader entered.
1223
1881
  get readOnly(): boolean {
1224
- // A doc that is still loading its first content (a drive-linked
1225
- // stub hydrating on first open) is read-only until the bytes
1226
- // arrive: editing it would write ops onto an empty doc that the
1227
- // incoming snapshot is about to replace. The SDK reloads the
1228
- // content automatically when the work finishes (see open-document's
1229
- // `reloadAfterHydration`); until then mutation is rejected. This is
1230
- // also what stops an app's "seed an empty doc" path from clobbering
1231
- // a doc that only looks empty because it has not loaded yet.
1232
- // Browsing versions locks the document at the live head as well as at an
1233
- // earlier one. Stepping moves between the points history KEEPS, and an
1234
- // edit at the head makes no such point, so an edit made under the open
1235
- // bar puts the reader's own newest work somewhere the bar cannot travel
1236
- // to: Back then steps past it to the newest marker, and the way forward
1237
- // is a different number of presses from the way back. Reading history
1238
- // and writing it are separate modes, and the bar carries the Close that
1239
- // leaves this one.
1240
- return (
1241
- this._forcedReadOnly ||
1242
- this._checkout !== null ||
1243
- this._versionBrowsing ||
1244
- this._isLoadLocked()
1245
- );
1882
+ return this._readOnlyBecause !== null;
1883
+ }
1884
+
1885
+ /**
1886
+ * The refusal message when this reader's access, a view-only link, or this
1887
+ * app's standing keeps them from writing however the document is opened,
1888
+ * else null. `session.edit()` rejects with it rather than reopening.
1889
+ * @internal
1890
+ */
1891
+ get _editRefusal(): string | null {
1892
+ const reason = this._forcedReadOnly ? this._readOnlyReason : null;
1893
+ if (reason === 'grant' || reason === 'link' || reason === 'app') {
1894
+ return READ_ONLY_MESSAGES[reason];
1895
+ }
1896
+ return null;
1897
+ }
1898
+
1899
+ /**
1900
+ * Why this reader cannot write, or null when they can.
1901
+ *
1902
+ * The one place the conditions are listed. `readOnly` asks whether there is
1903
+ * a reason and `mutate` refuses with the reason's own message, so the two
1904
+ * cannot answer differently about the same document, and a new reason
1905
+ * reaches both by being added here.
1906
+ *
1907
+ * Order decides which message a reader sees when several hold at once, and
1908
+ * it runs from the state they are in to the state they were put in: a
1909
+ * checkout and an open version bar are things this reader did and can
1910
+ * undo, so naming those first tells them what to do. A load that has not
1911
+ * finished comes last because it clears on its own.
1912
+ */
1913
+ private get _readOnlyBecause(): ReadOnlyReason | null {
1914
+ if (this._previewVersion !== null) return 'version';
1915
+ if (this._versionBrowsing) return 'browsing';
1916
+ if (this._forcedReadOnly) return this._readOnlyReason;
1917
+ if (this._reopening) return 'reopening';
1918
+ if (this._loadFailed) return 'load_failed';
1919
+ if (this._isLoadLocked()) return 'loading';
1920
+ return null;
1246
1921
  }
1247
1922
 
1248
1923
  // True when content-replacing work (hydration / extraction) is pending
@@ -1257,7 +1932,7 @@ export class Document {
1257
1932
  // blocking so hydration stays locked.
1258
1933
  private _isLoadLocked(): boolean {
1259
1934
  if (this._loadFailed) return true;
1260
- const s = this._workStatus.status;
1935
+ const s = this._workStatus.work;
1261
1936
  return s?.pending === true && s.blocking !== false;
1262
1937
  }
1263
1938
 
@@ -1286,8 +1961,8 @@ export class Document {
1286
1961
  * `doc.readOnly` (true for a preview, a view-only link, or a load lock).
1287
1962
  * @internal
1288
1963
  */
1289
- get version(): number | null {
1290
- return this._checkout?.version ?? null;
1964
+ get previewVersion(): number | null {
1965
+ return this._previewVersion;
1291
1966
  }
1292
1967
 
1293
1968
  /** @internal Whether the host should be showing its version-browsing
@@ -1314,7 +1989,8 @@ export class Document {
1314
1989
  * upload pins to it); no app-facing operation takes a seq, since version
1315
1990
  * history is hosted chrome the app does not drive with numbers. */
1316
1991
  get currentSeq(): number | null {
1317
- if (this._checkout !== null) return this._checkout.version;
1992
+ const previewing = this._previewVersion;
1993
+ if (previewing !== null) return previewing;
1318
1994
  if (this._currentSeqProvider) return this._currentSeqProvider();
1319
1995
  return null;
1320
1996
  }
@@ -1334,6 +2010,38 @@ export class Document {
1334
2010
  return new Document(loroDoc, options);
1335
2011
  }
1336
2012
 
2013
+ /**
2014
+ * Open a Document over frozen content: the document's content as it was
2015
+ * sealed, read with no CRDT engine.
2016
+ *
2017
+ * No CRDT engine is built: every root answers from the content, and the
2018
+ * machinery a Document carries regardless (its id, its subscriber channel,
2019
+ * its close path) runs without one.
2020
+ * @internal
2021
+ */
2022
+ static openFromSealedContent(content: SealedContent, options?: DocumentOptions): Document {
2023
+ // Rooted at a map the content holds, so a document reading frozen content
2024
+ // registers no empty root of its own.
2025
+ //
2026
+ // Read-only is stated here rather than left to the caller, because it is
2027
+ // a property of the bytes: frozen content holds no op log, so there is
2028
+ // nothing an edit could be written into, and making one is the platform's
2029
+ // (`runRecordSetConversion`). Stating it at the factory is what lets the
2030
+ // refusal come from the document's own gate, which names the reader's
2031
+ // actual limit, instead of from each handle describing the encoding.
2032
+ const rootKey =
2033
+ content.rootNames().find((n) => content.kindOf(n) === 'map') ?? RECORDS_KEY;
2034
+ const doc = new Document(null, {
2035
+ ...options,
2036
+ readOnly: true,
2037
+ readOnlyReason: options?.readOnlyReason ?? 'snapshot',
2038
+ rootKey,
2039
+ frozenContent: content,
2040
+ });
2041
+ doc._engineRootKey = options?.rootKey ?? 'entities';
2042
+ return doc;
2043
+ }
2044
+
1337
2045
  /**
1338
2046
  * Construct an empty Document (empty-snapshot join case, and tests).
1339
2047
  * @internal
@@ -1373,7 +2081,17 @@ export class Document {
1373
2081
  // subscribers.
1374
2082
  return;
1375
2083
  }
1376
- this._doc.import(opBlob);
2084
+ // An op exists, so the document is now a CRDT and somebody has edited it.
2085
+ // The op is authored against that CRDT, so imported over frozen content it
2086
+ // would land on an empty LoroDoc and sit causally incomplete. The storage
2087
+ // is upgraded instead: the transport re-joins, and the join's CRDT
2088
+ // replaces the frozen content under the same handles (`_upgradeStorage`).
2089
+ // The op is in what that join serves.
2090
+ if (this._sealedRoots !== null) {
2091
+ this._requestStorageUpgrade();
2092
+ return;
2093
+ }
2094
+ importTailOp(this._doc, opBlob);
1377
2095
  }
1378
2096
 
1379
2097
  // Apply a snapshot blob over the top of an existing Document.
@@ -1390,6 +2108,12 @@ export class Document {
1390
2108
  // arrives and the Loro subscriber cannot be relied on to report it: it is
1391
2109
  // attached lazily, so a document whose content lands before the session has
1392
2110
  // bound its canvas has nobody subscribed and the import fires nothing.
2111
+ // CRDT bytes over frozen content are a storage upgrade, which keeps the
2112
+ // app's handles (see `_upgradeStorage`).
2113
+ if (this._sealedRoots !== null) {
2114
+ if (!this._upgradeStorage(snapshot, [])) this._reopen();
2115
+ return;
2116
+ }
1393
2117
  this._armSeedIfPictureIsStale();
1394
2118
  this._doc.import(snapshot);
1395
2119
  // The undo stack pre-snapshot referenced ops the new snapshot
@@ -1400,7 +2124,112 @@ export class Document {
1400
2124
  if (this._undo) this._undo.clear();
1401
2125
  }
1402
2126
 
1403
- // Revert the most recent local commit. Returns `true` if an undo
2127
+ /** @internal Wire the re-join that upgrades this Document's frozen content
2128
+ * to the CRDT the server now holds. */
2129
+ _setStorageUpgrader(upgrade: () => void): void {
2130
+ this._storageUpgrader = upgrade;
2131
+ }
2132
+
2133
+ private _requestStorageUpgrade(): void {
2134
+ const upgrade = this._storageUpgrader;
2135
+ if (upgrade === null) {
2136
+ this._reopen();
2137
+ return;
2138
+ }
2139
+ upgrade();
2140
+ }
2141
+
2142
+ /**
2143
+ * Swap this Document's storage from frozen content to a CRDT, in place.
2144
+ * @internal
2145
+ *
2146
+ * Every handle an app took over the frozen content keeps working, now over
2147
+ * the CRDT, and its subscribers move there as a whole-container resync (keys
2148
+ * the CRDT does not hold arrive as `deleted`, and `onSettled` fires again).
2149
+ * Read-only state is unchanged, because the reader's intent is.
2150
+ *
2151
+ * Returns false, leaving the frozen content in place, when the Document is
2152
+ * showing a version of the frozen content: the preview has no CRDT
2153
+ * counterpart to rebind to, so the caller reloads instead.
2154
+ */
2155
+ _upgradeStorage(snapshot: Uint8Array | null, tailOps: readonly Uint8Array[]): boolean {
2156
+ if (this._closed) return false;
2157
+ if (this._sealedRoots === null) return true;
2158
+ if (this._frozenVersion !== null) return false;
2159
+ const engine = new LoroDoc();
2160
+ if (snapshot !== null) engine.import(snapshot);
2161
+ for (const op of tailOps) importTailOp(engine, op);
2162
+ if (this._engineOptions.peerId !== undefined) engine.setPeerId(this._engineOptions.peerId);
2163
+ this._handles.swap(() => {
2164
+ this._rootKey = this._engineRootKey;
2165
+ engine.getMap(this._rootKey);
2166
+ this._engine = engine;
2167
+ this._undo = this._newUndoController(engine, this._engineOptions.undo);
2168
+ this._sealedRoots = null;
2169
+ this._frozenHead = null;
2170
+ this._frozenShown = null;
2171
+ this._frozenSeal = null;
2172
+ this._rootHandles.clear();
2173
+ this._orderedMapHandles.clear();
2174
+ });
2175
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
2176
+ return true;
2177
+ }
2178
+
2179
+ /**
2180
+ * Swap this Document's storage from a CRDT to frozen content, in place: the
2181
+ * counterpart of `_upgradeStorage`, for a frozen copy whose reload is served
2182
+ * the line as stored after the copy was opened on a CRDT.
2183
+ * @internal
2184
+ *
2185
+ * Returns false, leaving the CRDT in place, when the Document is showing a
2186
+ * version: the preview has no frozen counterpart, so the caller reloads.
2187
+ */
2188
+ _downgradeStorage(content: SealedContent, seal: number | null): boolean {
2189
+ if (this._closed || this._sealedRoots !== null || this._checkout !== null) return false;
2190
+ this._handles.swap(() => {
2191
+ this._frozenHead = content;
2192
+ this._frozenShown = content;
2193
+ this._frozenSeal = seal;
2194
+ this._sealedRoots = sealedRoots(
2195
+ () => this._frozenShown!,
2196
+ () => this._handleOwner.assertWritable(),
2197
+ );
2198
+ this._rootHandles.clear();
2199
+ this._orderedMapHandles.clear();
2200
+ // Nothing reads the engine from here on. It is dropped rather than
2201
+ // freed, because a watcher an input follower attached to it may still
2202
+ // detach.
2203
+ this._unsubLoro?.();
2204
+ this._unsubLoro = null;
2205
+ this._unsubLocal?.();
2206
+ this._unsubLocal = null;
2207
+ this._undo?.free();
2208
+ this._undo = null;
2209
+ this._engine = null;
2210
+ });
2211
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
2212
+ return true;
2213
+ }
2214
+
2215
+ /**
2216
+ * Replace this Document's frozen content with other frozen content, in
2217
+ * place, re-delivering every handle's subscribers. A join serves a newer
2218
+ * seal when the document was edited and frozen again in between.
2219
+ * @internal
2220
+ */
2221
+ _replaceSealedContent(content: SealedContent, seal: number | null): boolean {
2222
+ if (this._closed || this._sealedRoots === null) return false;
2223
+ if (this._frozenVersion !== null) return false;
2224
+ this._frozenHead = content;
2225
+ this._frozenShown = content;
2226
+ this._frozenSeal = seal;
2227
+ this._sealedRoots.resync();
2228
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
2229
+ return true;
2230
+ }
2231
+
2232
+ // Revert the most recent local step. Returns `true` if an undo
1404
2233
  // step was applied, `false` if the stack is empty. Remote peers'
1405
2234
  // edits are not in this stack: Loro's UndoManager is local-peer-
1406
2235
  // scoped, which is the user-visible behaviour every collaborative
@@ -1409,23 +2238,17 @@ export class Document {
1409
2238
  // The reverted change is itself a local commit, so it ships over
1410
2239
  // the WS transport like any other mutation and other peers see the
1411
2240
  // state revert.
2241
+ //
2242
+ // The stack is the document's, across its streams: a step is every local
2243
+ // commit between two seals in whichever streams took one, and one `undo()`
2244
+ // reverts it in each of them. A stream that refuses writes takes no part.
1412
2245
  undo(): boolean {
1413
2246
  this._assertOpen();
1414
- // During a checkout the UndoManager still tracks the live head, but reads
1415
- // go through the preview. Undoing would commit against the live `_doc` and
1416
- // ship over the WS transport while the previewing user sees nothing, a
1417
- // silent live edit. Match `canUndo()` (false during a checkout) and no-op.
1418
- if (this._checkout !== null) return false;
1419
- // An undo produces a local commit exactly as `mutate` does, so it obeys
1420
- // the same read-only gates: a view-only guest link (`_forcedReadOnly`)
1421
- // and a pending content-replacing work-status (`_isLoadLocked()`), whose
1422
- // whole purpose is to keep local ops from racing the incoming bytes.
1423
- if (this._forcedReadOnly || this._isLoadLocked()) return false;
1424
- this._ensureLocalUpdatesSubscribed();
1425
- // Tag the commit the UndoManager is about to make so the resulting
1426
- // change reads back as `origin.source === 'undo'`, not `'local'`.
1427
- this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
1428
- return this._undo!.undo();
2247
+ try {
2248
+ return this._undoGroup.replay('undo');
2249
+ } finally {
2250
+ this._maybeFireUndoState();
2251
+ }
1429
2252
  }
1430
2253
 
1431
2254
  // Re-apply the most recently undone local commit. Returns `true`
@@ -1437,13 +2260,69 @@ export class Document {
1437
2260
  // composes correctly.
1438
2261
  redo(): boolean {
1439
2262
  this._assertOpen();
1440
- // No-op during a checkout, same reasoning as `undo()`.
1441
- if (this._checkout !== null) return false;
1442
- // Same read-only gates as `undo()` / `mutate()`.
1443
- if (this._forcedReadOnly || this._isLoadLocked()) return false;
2263
+ try {
2264
+ return this._undoGroup.replay('redo');
2265
+ } finally {
2266
+ this._maybeFireUndoState();
2267
+ }
2268
+ }
2269
+
2270
+ // Whether `undo()` / `redo()` must no-op. A replay produces local commits
2271
+ // exactly as `mutate` does, so it obeys every gate `mutate` does. During a
2272
+ // checkout the UndoManager still tracks the live head while reads go
2273
+ // through the preview, so a replay would be a silent live edit. A copy with
2274
+ // no CRDT (frozen content) has no undo stack at all. `undo()` / `redo()`
2275
+ // also no-op inside a `mutate` callback, whose open transaction holds
2276
+ // writes the replay would commit outside the op it ships.
2277
+ private _undoBlocked(): boolean {
2278
+ return this._checkout !== null || this._undo === null || this._readOnlyBecause !== null;
2279
+ }
2280
+
2281
+ // Tags each commit the step's replay makes, so the resulting change reads
2282
+ // back as `origin.source === 'undo'` / `'redo'`, not `'local'`.
2283
+ //
2284
+ // A step replays as one commit per commit it took, and those ship as ONE op
2285
+ // exported from the version before the replay, so an Undo of a long step is
2286
+ // one write for the server and one import for each peer.
2287
+ /** @internal Replay this stream's own part of the newest step that touched
2288
+ * it (undo) or of the step it most recently undid (redo), leaving the
2289
+ * document's other streams alone. `DocumentStream.undo` / `redo`. */
2290
+ _replayOwnUndoStep(dir: 'undo' | 'redo'): boolean {
2291
+ this._assertOpen();
2292
+ try {
2293
+ if (this._mutateDepth > 0 || this._undoBlocked()) return false;
2294
+ const tag = this._undo!.peek(dir);
2295
+ return tag !== undefined && this._replayUndoStep(dir, tag);
2296
+ } finally {
2297
+ this._maybeFireUndoState();
2298
+ }
2299
+ }
2300
+
2301
+ /** @internal */
2302
+ _replayUndoStep(dir: 'undo' | 'redo', tag: number): boolean {
1444
2303
  this._ensureLocalUpdatesSubscribed();
1445
- this._doc.setNextCommitOrigin(REDO_COMMIT_ORIGIN);
1446
- return this._undo!.redo();
2304
+ const origin = dir === 'undo' ? UNDO_COMMIT_ORIGIN : REDO_COMMIT_ORIGIN;
2305
+ const tagCommit = (): void => this._doc.setNextCommitOrigin(origin);
2306
+ const before = this._doc.oplogVersion();
2307
+ const beforeHead = JSON.stringify(this._doc.oplogFrontiers());
2308
+ this._replayingUndo = true;
2309
+ this._replayChange = null;
2310
+ try {
2311
+ return this._undo!.replayStep(dir, tag, tagCommit);
2312
+ } finally {
2313
+ this._replayingUndo = false;
2314
+ // Whatever the replay committed ships, a replay that threw part-way included.
2315
+ if (this._unsubLocal && JSON.stringify(this._doc.oplogFrontiers()) !== beforeHead) {
2316
+ this._shipLocalUpdate(this._doc.export({ mode: 'update', from: before }));
2317
+ }
2318
+ before.free();
2319
+ const source = this._replayChange;
2320
+ this._replayChange = null;
2321
+ if (source !== null) {
2322
+ this._subs.emit({ kind: 'change', source } satisfies SyntheticSubscribeEvent);
2323
+ }
2324
+ this._maybeFireUndoState();
2325
+ }
1447
2326
  }
1448
2327
 
1449
2328
  /**
@@ -1455,33 +2334,74 @@ export class Document {
1455
2334
  * @internal
1456
2335
  */
1457
2336
  _sealUndoStep(): void {
1458
- if (this._undo) this._undo.sealStep();
2337
+ this._undoGroup.seal();
2338
+ }
2339
+
2340
+ /** @internal */
2341
+ _undoController(): UndoController | null {
2342
+ return this._undo;
2343
+ }
2344
+
2345
+ /** @internal */
2346
+ _undoEligible(): boolean {
2347
+ return !this._closed && !this._undoBlocked();
2348
+ }
2349
+
2350
+ /** @internal */
2351
+ _undoMutating(): boolean {
2352
+ return this._mutateDepth > 0;
2353
+ }
2354
+
2355
+ // A version on screen holds every stream of the document at that moment,
2356
+ // so no stream replays a step while the document shows one, even a stream
2357
+ // whose own preview has not landed yet.
2358
+ private _undoGroupBlocked(): boolean {
2359
+ return (
2360
+ this._closed ||
2361
+ this._checkout !== null ||
2362
+ this._previewVersion !== null ||
2363
+ this._versionBrowsing
2364
+ );
2365
+ }
2366
+
2367
+ private _newUndoController(
2368
+ loroDoc: LoroDoc,
2369
+ options: UndoControllerOptions | undefined,
2370
+ ): UndoController {
2371
+ return new UndoController(loroDoc, options, this._undoGroup, {
2372
+ onFreshCommit: (): void => this._undoGroup.freshCommit(this),
2373
+ onStackChange: (): void => {
2374
+ if (!this._undoGroup.isOwner(this)) this._undoGroup.changed();
2375
+ },
2376
+ });
2377
+ }
2378
+
2379
+ /** @internal Make this stream's undo part of its document's stack. Called
2380
+ * when the stream opens, before it has taken a local commit. */
2381
+ _joinUndoGroup(group: UndoGroup): void {
2382
+ this._undoGroup.delete(this);
2383
+ this._undoGroup = group;
2384
+ group.add(this);
2385
+ this._undo?.useClock(group);
1459
2386
  }
1460
2387
 
1461
2388
  // Whether `undo()` would have an effect right now. Feeds the
1462
2389
  // commands machinery and the undo-state notifier; apps consume it
1463
2390
  // through `subscribeUndoState` snapshots.
2391
+ //
2392
+ // The gates `undo()` enforces, so a subscribeUndoState-bound button
2393
+ // disables rather than inviting an undo the call would refuse: during a
2394
+ // checkout the stacks still track the live head while the UI shows the
2395
+ // preview, and a stream that refuses writes has nothing it may replay.
1464
2396
  private canUndo(): boolean {
1465
2397
  if (this._closed) return false;
1466
- // During a checkout, the undo stack still tracks the live doc's
1467
- // history, but the UI is showing the historical preview and
1468
- // mutate is rejected. Surface that as "can't undo right now" so
1469
- // subscribeUndoState-bound buttons disable correctly.
1470
- if (this._checkout !== null) return false;
1471
- // Fold the same read-only gates `undo()` enforces so a
1472
- // subscribeUndoState-bound button disables while the doc is a view-only
1473
- // guest link or load-locked, rather than inviting an undo the call
1474
- // would refuse.
1475
- if (this._forcedReadOnly || this._isLoadLocked()) return false;
1476
- return this._undo!.canUndo();
2398
+ return this._undoGroup.canUndo();
1477
2399
  }
1478
2400
 
1479
2401
  // Whether `redo()` would have an effect right now. As above.
1480
2402
  private canRedo(): boolean {
1481
2403
  if (this._closed) return false;
1482
- if (this._checkout !== null) return false;
1483
- if (this._forcedReadOnly || this._isLoadLocked()) return false;
1484
- return this._undo!.canRedo();
2404
+ return this._undoGroup.canRedo();
1485
2405
  }
1486
2406
 
1487
2407
  // The plain-object proxy callers mutate. The mutation runs against
@@ -1514,10 +2434,11 @@ export class Document {
1514
2434
  // either.
1515
2435
  mutate<E extends object = EntityProxy>(
1516
2436
  fn: (d: DocumentMutator<E>) => void,
2437
+ options?: MutateOptions,
1517
2438
  ): void {
1518
2439
  this.map<E>(this._rootKey).mutate((entities) => {
1519
2440
  fn({ entities });
1520
- });
2441
+ }, options);
1521
2442
  }
1522
2443
 
1523
2444
  // Install / replace the local-op send hook; `null` detaches it.
@@ -1551,20 +2472,7 @@ export class Document {
1551
2472
  // `_checkoutVersion`. A Document without one rejects the checkout
1552
2473
  // with a clear error.
1553
2474
  /** @internal */
1554
- setCheckoutFetcher(
1555
- fetcher:
1556
- | ((
1557
- docId: string,
1558
- version: number,
1559
- branchId: string,
1560
- ) => Promise<{
1561
- snapshotBytes: Uint8Array | null;
1562
- tailOpBlobs: Uint8Array[];
1563
- effectiveVersion: number;
1564
- streamSeqs?: Record<string, number>;
1565
- }>)
1566
- | null,
1567
- ): void {
2475
+ setCheckoutFetcher(fetcher: CheckoutFetcher | null): void {
1568
2476
  this._checkoutFetcher = fetcher;
1569
2477
  }
1570
2478
 
@@ -1577,7 +2485,7 @@ export class Document {
1577
2485
  /** @internal Whether this Document is currently showing a historical
1578
2486
  * preview rather than its live head. */
1579
2487
  _isCheckedOut(): boolean {
1580
- return this._checkout !== null;
2488
+ return this._previewVersion !== null;
1581
2489
  }
1582
2490
 
1583
2491
  /** @internal Whether this Document is pinned to the version it was opened
@@ -1590,7 +2498,7 @@ export class Document {
1590
2498
  // Switch the Document to a historical version, in place. @internal
1591
2499
  // (the hosted `history` command drives this). After the promise
1592
2500
  // resolves, `doc.readOnly` is `true`,
1593
- // `doc.version` is the server's effective version (which may round
2501
+ // `doc.previewVersion` is the server's effective version (which may round
1594
2502
  // down from the requested one when compaction has TTL'd the exact
1595
2503
  // op), and `mutate` throws. `subscribe` fires once after the swap so
1596
2504
  // the app's render loop redraws, and the app returns to live through
@@ -1622,7 +2530,7 @@ export class Document {
1622
2530
  );
1623
2531
  }
1624
2532
  const generation = ++this._checkoutGeneration;
1625
- const { snapshotBytes, tailOpBlobs, effectiveVersion, streamSeqs } =
2533
+ const { content, tailOpBlobs, effectiveVersion, streamSeqs } =
1626
2534
  await this._checkoutFetcher(this._docId, version, this._branchId);
1627
2535
  if (this._closed || this._suspended) return;
1628
2536
  // A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
@@ -1630,11 +2538,20 @@ export class Document {
1630
2538
  // than clobbering the live head (or a newer checkout) the user asked for.
1631
2539
  if (generation !== this._checkoutGeneration) return;
1632
2540
  this._checkoutStreamSeqs = streamSeqs ?? {};
2541
+ // Handles over frozen content read frozen content, so the version is
2542
+ // swapped in as frozen content whatever encoding the server stores it in.
2543
+ if (this._sealedRoots !== null) {
2544
+ this._frozenShown = frozenPreview(content, tailOpBlobs);
2545
+ this._frozenVersion = effectiveVersion;
2546
+ this._sealedRoots.resync();
2547
+ this._fireSubscribers({ kind: 'history', version: effectiveVersion });
2548
+ await this._checkoutOpenStreams(generation);
2549
+ return;
2550
+ }
1633
2551
  // Materialise the preview into a fresh LoroDoc. The previous
1634
2552
  // checkout's preview LoroDoc, if any, is freed.
1635
- const previewDoc = new LoroDoc();
1636
- if (snapshotBytes !== null) previewDoc.import(snapshotBytes);
1637
- for (const blob of tailOpBlobs) previewDoc.import(blob);
2553
+ const previewDoc = engineFrom(content);
2554
+ for (const blob of tailOpBlobs) importTailOp(previewDoc, blob);
1638
2555
  const previewRoot = previewDoc.getMap(this._rootKey);
1639
2556
  const old = this._checkout;
1640
2557
  // Snapshot the handles' keys before the active-source pointer moves (see
@@ -1660,7 +2577,7 @@ export class Document {
1660
2577
  // container captured at attach time (the OLD preview's); freeing first
1661
2578
  // would run that detach against a freed LoroDoc. This is the same
1662
2579
  // teardown-before-free order `close()` documents.
1663
- if (old && old.previewDoc !== this._doc) {
2580
+ if (old && old.previewDoc !== this._engine) {
1664
2581
  try {
1665
2582
  old.previewDoc.free();
1666
2583
  } catch {
@@ -1729,6 +2646,14 @@ export class Document {
1729
2646
  // `_checkout` still null, and this is the signal that supersedes it when
1730
2647
  // it resolves.
1731
2648
  const generation = (this._checkoutGeneration += 1);
2649
+ if (this._sealedRoots !== null && this._frozenVersion !== null) {
2650
+ this._frozenShown = this._frozenHead;
2651
+ this._frozenVersion = null;
2652
+ this._sealedRoots.resync();
2653
+ this._fireSubscribers({ kind: 'return-to-head' });
2654
+ this._returnOpenStreams(generation);
2655
+ return;
2656
+ }
1732
2657
  if (this._checkout === null) return;
1733
2658
  const old = this._checkout;
1734
2659
  // Symmetric with `_checkoutVersion`: snapshot before the active-source
@@ -1742,7 +2667,7 @@ export class Document {
1742
2667
  // Free the preview only after the rebind: `_rebindRootHandles` detaches
1743
2668
  // each subscriber against the preview container it was attached to, so
1744
2669
  // freeing first would be a use-after-free (see `_checkoutVersion`).
1745
- if (old.previewDoc !== this._doc) {
2670
+ if (old.previewDoc !== this._engine) {
1746
2671
  try {
1747
2672
  old.previewDoc.free();
1748
2673
  } catch {
@@ -1751,9 +2676,13 @@ export class Document {
1751
2676
  }
1752
2677
  this._fireSubscribers({ kind: 'return-to-head' });
1753
2678
  this._maybeFireUndoState();
1754
- // Return every opened stream to its live head too, symmetric with the
1755
- // checkout path. Fire-and-forget (returnToHead is synchronous and has no
1756
- // fetch): a stream not previewing is a no-op.
2679
+ this._returnOpenStreams(generation);
2680
+ }
2681
+
2682
+ // Return every opened stream to its live head too, symmetric with the
2683
+ // checkout path. Fire-and-forget (returnToHead is synchronous and has no
2684
+ // fetch): a stream not previewing is a no-op.
2685
+ private _returnOpenStreams(generation: number): void {
1757
2686
  this._checkoutStreamSeqs = null;
1758
2687
  for (const openPromise of this._streamOpens.values()) {
1759
2688
  void openPromise
@@ -1778,7 +2707,20 @@ export class Document {
1778
2707
  // and the caller moves the active-source pointer between the two calls.
1779
2708
  // Callers must not return early in that window; a snapshot left unconsumed
1780
2709
  // would be diffed against a later, unrelated swap.
2710
+ // Detach every cached root handle's live subscriptions. Reached only on a
2711
+ // CRDT document: the teardown, the key snapshot and the rebind below are
2712
+ // `handles.ts`'s own internal protocol, spoken by the handles it produces,
2713
+ // and a stored record set's roots hold no container subscription to detach,
2714
+ // no anchor to re-point and no prior keys to diff against a swap.
2715
+ private _teardownRootHandles(): void {
2716
+ for (const entry of this._rootHandles.values()) {
2717
+ if (entry.kind === 'text') entry.handle._teardown();
2718
+ else teardownRootHandle(entry.handle);
2719
+ }
2720
+ }
2721
+
1781
2722
  private _snapshotRootHandleKeys(): void {
2723
+ if (this._sealedRoots !== null) return;
1782
2724
  for (const entry of this._rootHandles.values()) {
1783
2725
  if (entry.kind === 'map') snapshotRootMapKeys(entry.handle);
1784
2726
  }
@@ -1791,6 +2733,7 @@ export class Document {
1791
2733
  // `getMap` / `getList` are get-or-create, so a container absent in the
1792
2734
  // preview resolves to an empty one and its subscribers redraw to empty.
1793
2735
  private _rebindRootHandles(source: LoroDoc): void {
2736
+ if (this._sealedRoots !== null) return;
1794
2737
  // Two passes, not one per handle: rebind every anchor to the new source
1795
2738
  // first, then resync. A composed handle can read a sibling root while
1796
2739
  // firing (the ordered map reads its order list during its map redraw), so
@@ -1980,13 +2923,11 @@ export class Document {
1980
2923
  // both alongside the publish-over that `pushToDrive` does. Driven by the
1981
2924
  // hosted toolbar.
1982
2925
  //
1983
- // Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
1984
- // takes over the drive link (take-source trashes this doc, keep-both
1985
- // detaches it into a standalone fork). For a session-backed app the
1986
- // SDK re-opens onto that new doc automatically (the resolver routes it
1987
- // through the same `openSiblingDoc` path the history fork uses); the
1988
- // returned `newDocId` lets a multi-doc app do its own switch. Resolves
1989
- // with the outcome, or `null` when the user dismissed the dialog.
2926
+ // Taking the drive's version keeps this document's id: the open copy moves
2927
+ // onto that version when it lands (`onGone('moved')`, which a session
2928
+ // re-opens). A `newDocId` in the result is opened through the same
2929
+ // `openSiblingDoc` path the history fork uses. Resolves with the outcome,
2930
+ // or `null` when the user dismissed the dialog.
1990
2931
  // Throws when there is no doc identity or no resolver is attached.
1991
2932
  /** @internal */
1992
2933
  async resolveDrive(): Promise<DriveResolveResult | null> {
@@ -2003,20 +2944,8 @@ export class Document {
2003
2944
  }
2004
2945
 
2005
2946
  // Fetch the file a document was uploaded as: a short-lived presigned GET
2006
- // plus the file's name / content type / size. An app that knows the format
2007
- // fetches the bytes from `url`, parses them, and writes the structured
2008
- // content into this document through the normal handle surface
2009
- // (`doc.map('instances').mutate(...)`):
2010
- //
2011
- // const src = await doc.sourceBlob();
2012
- // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
2013
- // const records = parseMyFormat(bytes); // app's own parser
2014
- // doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
2015
- //
2016
- // A source file is a set of one or more streams: `src.streams` lists
2017
- // every stream (primary first, so a single-stream file has one entry),
2018
- // each with its own URL, so a multi-stream format (a glTF with its
2019
- // `.bin` buffer) fetches only the streams it needs.
2947
+ // plus the file's name / content type / size. The document's content is
2948
+ // what its importer returned for this file, sealed by the platform.
2020
2949
  //
2021
2950
  // Rejects with a `BrassError` when the document holds no such file, or
2022
2951
  // the caller cannot reach it (both 404).
@@ -2117,23 +3046,24 @@ export class Document {
2117
3046
  `doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`,
2118
3047
  );
2119
3048
  }
2120
- // Only a CRDT stream opens as live collaborative state. Any other
2121
- // value fails loudly here rather than joining a line that is not
2122
- // one, so a format added after this SDK shipped is refused by the
2123
- // version that cannot read it.
3049
+ // Every stream the platform writes is a document line and reports
3050
+ // `'crdt'`, whether it is stored as a CRDT or frozen. Any other value
3051
+ // names a kind this version does not know, so it is refused rather
3052
+ // than joined as a line.
2124
3053
  if (info.format !== 'crdt') {
2125
3054
  throw new Error(
2126
3055
  `Stream "${name}" has format "${info.format}", which this ` +
2127
- 'SDK version cannot open as a live stream.',
3056
+ 'SDK version cannot open.',
2128
3057
  );
2129
3058
  }
2130
3059
  const inner = await runtime.openLine(info.line_id);
3060
+ inner._joinUndoGroup(this._undoGroup);
2131
3061
  const stream = new DocumentStream(name, inner, true);
2132
3062
  try {
2133
3063
  // Opened while a version preview is active: bring the new stream to
2134
3064
  // the same point in time rather than showing it live under a
2135
3065
  // historical default.
2136
- if (this._checkout !== null) {
3066
+ if (this._previewVersion !== null) {
2137
3067
  await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
2138
3068
  }
2139
3069
  } catch (err) {
@@ -2164,6 +3094,9 @@ export class Document {
2164
3094
  stream._inner._observeSavingCount(() => {
2165
3095
  this._fireSubscribers({ kind: 'saving-count' });
2166
3096
  });
3097
+ stream._inner._observeConnectionNotice((kind) => {
3098
+ this._showConnectionNotice(kind);
3099
+ });
2167
3100
  if (this._suspended) stream._suspend();
2168
3101
  },
2169
3102
  () => {
@@ -2251,6 +3184,7 @@ export class Document {
2251
3184
  // transport about to be torn down and is never persisted.
2252
3185
  for (const stream of this._openStreams.values()) {
2253
3186
  stream._inner._observeSavingCount(null);
3187
+ stream._inner._observeConnectionNotice(null);
2254
3188
  stream._close();
2255
3189
  }
2256
3190
  // An open still in flight has no Document to close yet, so it closes on
@@ -2282,17 +3216,20 @@ export class Document {
2282
3216
  }
2283
3217
  }
2284
3218
 
2285
- // Seal a deliberate checkpoint on this document's version-
2286
- // history timeline. Compaction snapshots continue to appear as
2287
- // no-action fallback markers, so an app that never calls this still
2288
- // gets a usable history.
2289
- //
2290
- // Anchors to `doc.currentSeq` synchronously, so a concurrent
2291
- // editor's op landing mid-call cannot shift the anchor. Pass an
2292
- // explicit `version` to seal at a different point in history (the
2293
- // server validates it's `<=` the branch's live head).
2294
- async createCheckpoint(): Promise<DocumentCheckpoint> {
2295
- return this._checkpoints.create();
3219
+ // Mark the end of a user action: seals the undo step at once, and records a
3220
+ // version-history marker covering this call (calls made while a write is
3221
+ // in flight share the next one, written at the latest state). Call it
3222
+ // after every action and do not await it in the action's handler.
3223
+ // Resolves once the marker covering the call is written. While `readOnly`
3224
+ // is true it writes nothing and resolves at once (as `undo` / `redo` do
3225
+ // nothing there).
3226
+ createCheckpoint(): Promise<void> {
3227
+ if (this._isLive && this.readOnly) return Promise.resolve();
3228
+ const written = this._checkpoints.create().then(() => undefined);
3229
+ // Apps call this without awaiting it; a rejection still reaches a caller
3230
+ // that awaits.
3231
+ written.catch(() => {});
3232
+ return written;
2296
3233
  }
2297
3234
 
2298
3235
  // Configure the implicit auto-checkpoint debounce. Default is 60s
@@ -2325,6 +3262,25 @@ export class Document {
2325
3262
  this._fireSubscribers({ kind: 'commands' });
2326
3263
  }
2327
3264
 
3265
+ /** When Brass will remove this document, or `null` when it is kept. Set
3266
+ * for a document created while signed out; a sign-in claims it and the
3267
+ * claimed document reads `null`. */
3268
+ get expiresAt(): Date | null {
3269
+ return this._expiresAt;
3270
+ }
3271
+
3272
+ /** @internal */
3273
+ _setExpiresAt(expiresAt: Date | null): void {
3274
+ const same =
3275
+ (this._expiresAt === null && expiresAt === null) ||
3276
+ (this._expiresAt !== null &&
3277
+ expiresAt !== null &&
3278
+ this._expiresAt.getTime() === expiresAt.getTime());
3279
+ if (same) return;
3280
+ this._expiresAt = expiresAt;
3281
+ this._fireSubscribers({ kind: 'commands' });
3282
+ }
3283
+
2328
3284
  /** @internal */
2329
3285
  setHostedUiProvider(provider: HostedUiProvider | null): void {
2330
3286
  this._commandMenu.setHostedUiProvider(provider);
@@ -2392,6 +3348,13 @@ export class Document {
2392
3348
  else console.error('[Brass]', err);
2393
3349
  }
2394
3350
 
3351
+ // The console keeps the original stack for the developer; the app's
3352
+ // `onError` gets a sentence it can show the reader.
3353
+ private _reportSubscriberThrow(err: unknown): void {
3354
+ console.error('[Brass] subscriber threw:', err);
3355
+ if (this._errorReporter) this._errorReporter(appCallbackError(err));
3356
+ }
3357
+
2395
3358
  /** @internal Report a failure from the SDK's own chrome (the version
2396
3359
  * bar's intents) through the same sink the built-in commands use, so a
2397
3360
  * refused checkout reaches the app's `onError` rather than being dropped
@@ -2416,6 +3379,9 @@ export class Document {
2416
3379
  // itself, so the registry's total already carries one entry per stream.
2417
3380
  trackUnsavedWork(this, this._outstandingByClientId.size);
2418
3381
  this._savingCountObserver?.();
3382
+ if (this._workStatus.notice !== null && this.savingCount === 0) {
3383
+ this._workStatus.setNotice(null);
3384
+ }
2419
3385
  }
2420
3386
  this._ensureLoroSubscribed();
2421
3387
  this._subs.emit(event);
@@ -2537,8 +3503,9 @@ export class Document {
2537
3503
  // Returns true if the clientOpId was tracked (lets the caller detect
2538
3504
  // a stale ack).
2539
3505
  /** @internal */
2540
- resolveOutstandingOp(clientOpId: string): boolean {
3506
+ resolveOutstandingOp(clientOpId: string, seq?: number): boolean {
2541
3507
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
3508
+ this._provisionalInputs.acked(clientOpId, seq);
2542
3509
  // Release this chunk from the resync-push loop guard once its op is
2543
3510
  // acked: the pushed op is now in the log, so the next handshake's
2544
3511
  // tail covers it and a fresh push is welcome (it would be a genuine
@@ -2566,7 +3533,7 @@ export class Document {
2566
3533
  // grant restored afterwards is refused by the server too until the
2567
3534
  // connection recycles, and a reopen is what picks the wider grant up.
2568
3535
  /** @internal */
2569
- _markReadOnly(reason: ReadOnlyReason): void {
3536
+ _markReadOnly(reason: ForcedReadOnlyReason): void {
2570
3537
  if (this._forcedReadOnly) return;
2571
3538
  this._forcedReadOnly = true;
2572
3539
  this._readOnlyReason = reason;
@@ -2590,10 +3557,189 @@ export class Document {
2590
3557
  * telling the reader first, because an app that renders nothing for it is
2591
3558
  * exactly the case that must not leave someone on a diverged copy.
2592
3559
  */
2593
- reload(): void {
3560
+ _reopen(): void {
2594
3561
  this._markGone('moved');
2595
3562
  }
2596
3563
 
3564
+ /** @internal The app registered its importer, so inputs whose open stopped
3565
+ * waiting for it run again. */
3566
+ _importerRegistered(): void {
3567
+ this._inputFollowers.importerRegistered();
3568
+ }
3569
+
3570
+ /**
3571
+ * @internal Typed on `ReadOnlyDocument`. A frozen copy loads the document as
3572
+ * it stands now, in place: the handles stay the same and their subscribers
3573
+ * are told what changed. A live read-only copy already holds the document as
3574
+ * it stands, so there is nothing to load. An editable copy is reopened
3575
+ * (`_reopen`).
3576
+ */
3577
+ reload(): Promise<void> {
3578
+ this._assertOpen();
3579
+ const reloader = this._frozenReloader;
3580
+ if (reloader === null) {
3581
+ if (!this.readOnly) this._reopen();
3582
+ return Promise.resolve();
3583
+ }
3584
+ // One reload at a time: a call while one runs shares it, since both ask
3585
+ // for the document as it stands once it lands.
3586
+ if (this._reloading !== null) return this._reloading;
3587
+ const run = (async () => {
3588
+ try {
3589
+ // Cleared before the join, so a change landing during it is reported
3590
+ // against the load it may or may not have reached.
3591
+ this._changed = false;
3592
+ this._stopChangeNotice?.();
3593
+ this._stopChangeNotice = null;
3594
+ await reloader();
3595
+ } finally {
3596
+ this._reloading = null;
3597
+ if (!this._closed && (this._changedListeners.size > 0 || this._statusWatches > 0)) {
3598
+ this._watchForChange();
3599
+ }
3600
+ }
3601
+ })();
3602
+ this._reloading = run;
3603
+ return run;
3604
+ }
3605
+
3606
+ /**
3607
+ * Opens this document again in another mode, on the same branch. With no
3608
+ * options the new copy is editable; `{ readOnly: true }` follows other
3609
+ * people's edits with nothing on it that writes; `{ frozen: true }` keeps
3610
+ * what it loaded until `reload()`. Resolves with this document when it is
3611
+ * already in that mode, and otherwise with the new copy, closing this one.
3612
+ *
3613
+ * Rejects with a `BrassError` (status 403), without a request, when the
3614
+ * reader may not edit and the reopen asks for an editable copy. Leaving an
3615
+ * editable copy first waits for its changes to save, and rejects, keeping
3616
+ * this copy open, when they do not.
3617
+ */
3618
+ reopen(options: { frozen: true; readOnly?: true }): Promise<ReadOnlyDocument>;
3619
+ reopen(options: { readOnly: true; frozen?: false }): Promise<ReadOnlyDocument>;
3620
+ reopen(options?: { readOnly?: false; frozen?: false }): Promise<OpenedDocument>;
3621
+ reopen(options: OpenModeOptions): Promise<OpenedDocument | ReadOnlyDocument>;
3622
+ async reopen(options?: {
3623
+ readOnly?: boolean;
3624
+ frozen?: boolean;
3625
+ }): Promise<OpenedDocument | ReadOnlyDocument> {
3626
+ this._assertOpen();
3627
+ if (options?.frozen === true && options.readOnly === false) {
3628
+ throw new TypeError('reopen: a frozen copy is read-only, so `readOnly: false` cannot apply');
3629
+ }
3630
+ const mode: ReopenMode =
3631
+ options?.frozen === true ? 'frozen' : options?.readOnly === true ? 'read' : 'edit';
3632
+ const pending = this._pendingReopen;
3633
+ if (pending !== null) {
3634
+ if (pending.mode === mode) return pending.promise;
3635
+ // The later mode wins: the earlier call rejects and the copy it opens is
3636
+ // reopened in this mode, so every copy opened is either returned or closed.
3637
+ pending.superseded = true;
3638
+ return this._trackReopen(mode, async () => {
3639
+ const earlier = await pending.result.catch(() => null);
3640
+ if (earlier !== null) {
3641
+ return earlier.reopen(
3642
+ mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' },
3643
+ ) as Promise<OpenedDocument>;
3644
+ }
3645
+ const reopener = this._reopenerFor(mode);
3646
+ if (reopener === null) return this as unknown as OpenedDocument;
3647
+ return this._runReopen(mode, reopener);
3648
+ });
3649
+ }
3650
+ const reopener = this._reopenerFor(mode);
3651
+ if (reopener === null) return this as unknown as OpenedDocument;
3652
+ return this._trackReopen(mode, () => this._runReopen(mode, reopener));
3653
+ }
3654
+
3655
+ // The layer that reopens this copy in `mode`, or `null` when it is already
3656
+ // in that mode. Throws when it cannot be reopened so.
3657
+ private _reopenerFor(mode: ReopenMode): ((mode: ReopenMode) => Promise<OpenedDocument>) | null {
3658
+ // A copy showing a historical version is not at the head in any mode, so
3659
+ // every reopen loads the head.
3660
+ // A copy locked over a refused change shows something the platform does
3661
+ // not, so even a reopen to read builds a new one.
3662
+ const diverged = this._forcedReadOnly && this._readOnlyReason === 'unsaved';
3663
+ if (mode === this._openMode && this._previewVersion === null && !diverged) {
3664
+ return null;
3665
+ }
3666
+ if (mode === 'edit') {
3667
+ const refusal = this._editRefusal;
3668
+ if (refusal !== null) throw new BrassError(refusal, { status: 403 });
3669
+ }
3670
+ const reopener = this._reopener;
3671
+ if (reopener === null) {
3672
+ throw new BrassError('This document cannot be reopened', { status: 0 });
3673
+ }
3674
+ return reopener;
3675
+ }
3676
+
3677
+ private async _runReopen(
3678
+ mode: ReopenMode,
3679
+ reopener: (mode: ReopenMode) => Promise<OpenedDocument>,
3680
+ ): Promise<OpenedDocument> {
3681
+ if (this._openMode === 'edit') {
3682
+ // A reopen called from inside a `mutate` callback runs before that
3683
+ // mutation commits, so the wait starts once it has.
3684
+ await new Promise((resolve) => setTimeout(resolve, 0));
3685
+ if (!(await whenSaved(this))) {
3686
+ throw new BrassError('Changes to this document have not finished saving', {
3687
+ status: 0,
3688
+ });
3689
+ }
3690
+ }
3691
+ return reopener(mode);
3692
+ }
3693
+
3694
+ // Records `run` as the reopen in flight, which a second call to the same
3695
+ // mode shares and a call to another mode supersedes. `reopening` holds until
3696
+ // the last overlapping reopen settles.
3697
+ private _trackReopen(
3698
+ mode: ReopenMode,
3699
+ run: () => Promise<OpenedDocument>,
3700
+ ): Promise<OpenedDocument> {
3701
+ this._setReopening(true);
3702
+ const entry = { mode, superseded: false } as PendingReopen;
3703
+ this._pendingReopen = entry;
3704
+ entry.result = run().finally(() => {
3705
+ if (this._pendingReopen !== entry) return;
3706
+ this._pendingReopen = null;
3707
+ this._setReopening(false);
3708
+ });
3709
+ entry.promise = entry.result.then((next) => {
3710
+ if (entry.superseded) {
3711
+ throw new BrassError('The reopen was replaced by a later reopen', { status: 0 });
3712
+ }
3713
+ return next;
3714
+ });
3715
+ return entry.promise;
3716
+ }
3717
+
3718
+ private _setReopening(reopening: boolean): void {
3719
+ if (this._reopening === reopening) return;
3720
+ this._reopening = reopening;
3721
+ if (!this._isLive) return;
3722
+ this._fireSubscribers({ kind: 'commands' });
3723
+ this._maybeFireUndoState();
3724
+ }
3725
+
3726
+ /** @internal Which of the three open modes this copy is in. */
3727
+ get _openMode(): ReopenMode {
3728
+ if (this._frozenReloader !== null) return 'frozen';
3729
+ return this._forcedReadOnly ? 'read' : 'edit';
3730
+ }
3731
+
3732
+ /** @internal Install what `reopen` calls. */
3733
+ _setReopener(reopener: ((mode: ReopenMode) => Promise<OpenedDocument>) | null): void {
3734
+ this._reopener = reopener;
3735
+ }
3736
+
3737
+ /** @internal What `reopen` calls, so a layer that takes over this copy can
3738
+ * fall back to the one it replaced. */
3739
+ get _reopenerFn(): ((mode: ReopenMode) => Promise<OpenedDocument>) | null {
3740
+ return this._reopener;
3741
+ }
3742
+
2597
3743
  // Internal: forget an outstanding op the server TERMINALLY rejected (an
2598
3744
  // `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
2599
3745
  // server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
@@ -2615,6 +3761,7 @@ export class Document {
2615
3761
  ): boolean {
2616
3762
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
2617
3763
  this._resyncPushClientOpIds.delete(clientOpId);
3764
+ this._provisionalInputs.failed(clientOpId);
2618
3765
  if (wasTracked) {
2619
3766
  this._fireSubscribers({ kind: 'saving-count' });
2620
3767
  if (opts.report !== false) this._reportError(new Error(message));
@@ -2675,6 +3822,8 @@ export class Document {
2675
3822
 
2676
3823
  private _ensureLocalUpdatesSubscribed(): void {
2677
3824
  if (this._unsubLocal) return;
3825
+ // Frozen content takes no write, so there is no local update to send.
3826
+ if (this._sealedRoots !== null) return;
2678
3827
  if (!this._localOpHook) {
2679
3828
  this._reportTransportUnavailable();
2680
3829
  return;
@@ -2684,25 +3833,30 @@ export class Document {
2684
3833
  // does NOT trigger this callback, so we don't risk feeding the
2685
3834
  // same op back to the server in a loop.
2686
3835
  this._unsubLocal = this._doc.subscribeLocalUpdates((bytes: Uint8Array) => {
2687
- const hook = this._localOpHook;
2688
- if (!hook) return;
2689
- // Copy: Loro reuses its internal buffer across callback calls in
2690
- // some builds. Hand the consumer a stable view so the eventual
2691
- // WS send sees the correct bytes regardless of when the send
2692
- // actually flushes (the WS client may queue the frame).
2693
- const copy = new Uint8Array(bytes.byteLength);
2694
- copy.set(bytes);
2695
- const chunks = this.splitLocalUpdate(copy);
2696
- if (chunks === null) {
2697
- this._shipLocalOp(copy);
2698
- return;
2699
- }
2700
- // In order: each chunk continues the one before it, so a peer applying
2701
- // them out of sequence holds the tail unapplied until the gap closes.
2702
- for (const chunk of chunks) this._shipLocalOp(chunk);
3836
+ // A replay's commits ship together once it ends (`_replayUndo`).
3837
+ if (this._replayingUndo) return;
3838
+ this._shipLocalUpdate(bytes);
2703
3839
  });
2704
3840
  }
2705
3841
 
3842
+ private _shipLocalUpdate(bytes: Uint8Array): void {
3843
+ if (!this._localOpHook) return;
3844
+ // Copy: Loro reuses its internal buffer across callback calls in
3845
+ // some builds. Hand the consumer a stable view so the eventual
3846
+ // WS send sees the correct bytes regardless of when the send
3847
+ // actually flushes (the WS client may queue the frame).
3848
+ const copy = new Uint8Array(bytes.byteLength);
3849
+ copy.set(bytes);
3850
+ const chunks = this.splitLocalUpdate(copy);
3851
+ if (chunks === null) {
3852
+ this._shipLocalOp(copy);
3853
+ return;
3854
+ }
3855
+ // In order: each chunk continues the one before it, so a peer applying
3856
+ // them out of sequence holds the tail unapplied until the gap closes.
3857
+ for (const chunk of chunks) this._shipLocalOp(chunk);
3858
+ }
3859
+
2706
3860
  // Hand one op blob to the transport and count it as unsaved, or keep it
2707
3861
  // local when the server has evicted this document.
2708
3862
  //
@@ -2723,6 +3877,7 @@ export class Document {
2723
3877
  if (this._gone.reason !== null) return null;
2724
3878
  const clientOpId = hook(copy);
2725
3879
  this._outstandingByClientId.set(clientOpId, copy);
3880
+ this._shipCapture?.push(clientOpId);
2726
3881
  // The local mutate has already fired `subscribe` (the Loro
2727
3882
  // event), but `savingCount` just incremented; fire one more
2728
3883
  // synthetic event so a subscribe handler bound on `savingCount`
@@ -2747,6 +3902,8 @@ export class Document {
2747
3902
  // it: close set it to null.
2748
3903
  if (this._closed) return;
2749
3904
  if (this._unsubLoro) return;
3905
+ // Frozen roots deliver their own subscribers; there is no engine to hear.
3906
+ if (this._sealedRoots !== null) return;
2750
3907
  this._unsubLoro = this._doc.subscribe((event) => {
2751
3908
  // One channel: every event reaches every subscriber, whatever
2752
3909
  // produced it. The app reconciles by value and reads the cause off
@@ -2758,10 +3915,14 @@ export class Document {
2758
3915
  // build on them, which pins the storage engine through a surface that
2759
3916
  // never promised it. The cause survives as `source`, the same
2760
3917
  // engine-neutral vocabulary the handle surface reports.
2761
- this._subs.emit({
2762
- kind: 'change',
2763
- source: changeSourceOf(event),
2764
- } satisfies SyntheticSubscribeEvent);
3918
+ if (this._replayingUndo) {
3919
+ this._replayChange = changeSourceOf(event);
3920
+ } else {
3921
+ this._subs.emit({
3922
+ kind: 'change',
3923
+ source: changeSourceOf(event),
3924
+ } satisfies SyntheticSubscribeEvent);
3925
+ }
2765
3926
  const isLocalCommit = (event as { by?: unknown }).by === 'local';
2766
3927
  this._maybeFireUndoState();
2767
3928
  // A local commit (mutate / undo / redo) schedules a debounced
@@ -2832,7 +3993,11 @@ export class Document {
2832
3993
  // suspend like every other app-side subscriber.
2833
3994
  subscribeUndoState(cb: UndoStateSubscriber): DocumentUnsubscribe {
2834
3995
  this._assertOpen();
2835
- this._ensureLoroSubscribed();
3996
+ // A copy that cannot write never has anything of its own to undo (the
3997
+ // reason it cannot write does not lift while it is open), so it does not
3998
+ // listen to its own engine. A stream of the document can still take
3999
+ // writes, and reports them through the undo group.
4000
+ if (!this._forcedReadOnly) this._ensureLoroSubscribed();
2836
4001
  this._undoStateSubs.add(cb);
2837
4002
  // Fire immediately with current state so the consumer does
2838
4003
  // not need separate initial-paint code. Capture the values
@@ -2859,7 +4024,13 @@ export class Document {
2859
4024
  // Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
2860
4025
  // Called from `_ensureLoroSubscribed`'s event dispatch.
2861
4026
  private _maybeFireUndoState(): void {
4027
+ // A stream's undo state is its document's: whatever moved here (a write
4028
+ // landing, the stream turning read-only) re-reads the document's.
4029
+ if (!this._undoGroup.isOwner(this)) this._undoGroup.changed();
2862
4030
  if (this._undoStateSubs.size === 0) return;
4031
+ // A step can replay as several commits, and each fires a Loro event
4032
+ // before the stacks settle; `_replayUndo` fires once at the end.
4033
+ if (this._replayingUndo) return;
2863
4034
  const canUndo = this.canUndo();
2864
4035
  const canRedo = this.canRedo();
2865
4036
  if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
@@ -2902,31 +4073,264 @@ export class Document {
2902
4073
  return this._gone.on(cb);
2903
4074
  }
2904
4075
 
2905
- // The latest opaque work-status for this document, or `null` when no
2906
- // slow server-side work is in flight. An app renders `message`
2907
- // verbatim, draws a bar from `progress` when present, and shows a
2908
- // waiting state while `pending`. When a freshly-opened drive-linked
2909
- // doc is still hydrating, `open()` surfaces the initial `pending`
2910
- // status here; subsequent phases arrive via `onStatus`.
4076
+ /** What the importer reported about the file this document was read
4077
+ * from, as the open found it. `null` on a document no import produced and
4078
+ * on a clean import. */
4079
+ get importReport(): ImportReport | null {
4080
+ return this._importReport;
4081
+ }
4082
+
4083
+ /** @internal Seeded from the join. */
4084
+ _setImportReport(report: ImportReport | null): void {
4085
+ this._importReport = report;
4086
+ }
4087
+
4088
+ /** @internal On a frozen open: whether the document has moved since this
4089
+ * copy last loaded. False on every other open, which applies changes as
4090
+ * they come. */
4091
+ get changed(): false {
4092
+ return this._changed as false;
4093
+ }
4094
+
4095
+ /** @internal On a frozen open, fires when the copy learns the document
4096
+ * moved since it last loaded (a content edit, an import landing or
4097
+ * failing, the document being deleted): once per load, and again after a
4098
+ * `reload()` once the document moves past what that load brought. Never
4099
+ * fires on any other open. The document is watched only while a listener is
4100
+ * registered and until the copy is closed. */
4101
+ onChanged(cb: () => void): () => void {
4102
+ this._assertOpen();
4103
+ this._changedListeners.add(cb);
4104
+ if (this._changed) {
4105
+ queueMicrotask(() => {
4106
+ if (!this._closed && this._changed && this._changedListeners.has(cb)) cb();
4107
+ });
4108
+ } else {
4109
+ this._watchForChange();
4110
+ }
4111
+ return () => {
4112
+ this._changedListeners.delete(cb);
4113
+ this._unwatchIfIdle();
4114
+ };
4115
+ }
4116
+
4117
+ private _unwatchIfIdle(): void {
4118
+ if (this._changedListeners.size === 0 && this._statusWatches === 0) {
4119
+ this._stopChangeNotice?.();
4120
+ this._stopChangeNotice = null;
4121
+ }
4122
+ }
4123
+
4124
+ private _watchForChange(): void {
4125
+ if (this._stopChangeNotice === null && this._changeNotice !== null) {
4126
+ this._stopChangeNotice = this._changeNotice(() => this._markChanged());
4127
+ }
4128
+ }
4129
+
4130
+ /**
4131
+ * Calls the handlers with the input files of `key`. `loaded` gets every
4132
+ * file on each subscribe, then each new version, on any copy. `changed`
4133
+ * gets the files changed since this document last read them, on a copy
4134
+ * that can edit, each recorded as read off the Undo stack once its handler
4135
+ * resolves. `removed` gets each file that left. Calls for one file never
4136
+ * overlap. Subscriptions to one key share one set of calls, and each
4137
+ * subscription's handlers are called for every input; a `changed` input is
4138
+ * recorded as read once all of them resolve. Returns an unsubscribe.
4139
+ */
4140
+ subscribeInputDocuments(
4141
+ key: string,
4142
+ handlers: InputDocumentHandlers,
4143
+ options?: InputDocumentOptions,
4144
+ ): () => void {
4145
+ this._assertOpen();
4146
+ if (handlers === null || typeof handlers !== 'object') {
4147
+ throw new TypeError('subscribeInputDocuments: pass an object of handlers');
4148
+ }
4149
+ if (!isInputKey(key)) throw new TypeError(`subscribeInputDocuments: "${String(key)}" is not a valid key`);
4150
+ const named = handlers as Record<string, unknown>;
4151
+ if (!['loaded', 'changed', 'removed', 'status', 'settled'].some((name) => typeof named[name] === 'function')) {
4152
+ throw new TypeError('subscribeInputDocuments: handlers must include loaded, changed, removed, status or settled');
4153
+ }
4154
+ const hub = this._inputStatus();
4155
+ return this._inputFollowers.subscribe(
4156
+ {
4157
+ subscribeStatus: (k, cb) => hub.subscribeKey(k, cb),
4158
+ canWrite: () => !this._closed && !this.readOnly,
4159
+ markRead: (status, options) => this.markRead(status, options),
4160
+ open: this._inputsRuntime?.open ?? null,
4161
+ },
4162
+ key,
4163
+ handlers,
4164
+ options,
4165
+ );
4166
+ }
4167
+
4168
+ /**
4169
+ * @internal Calls `cb` with the status of `key` once it is read, and again
4170
+ * whenever it changes: an input file was added, modified or removed, the
4171
+ * key's references changed, or this document's read marks moved (an Undo
4172
+ * of `markRead` included, which reports at once). Returns an unsubscribe.
4173
+ */
4174
+ subscribeInputStatus(key: string, cb: (status: InputStatus) => void): () => void {
4175
+ this._assertOpen();
4176
+ if (typeof cb !== 'function') throw new TypeError('subscribeInputStatus: pass a callback');
4177
+ if (!isInputKey(key)) throw new TypeError(`subscribeInputStatus: "${String(key)}" is not a valid key`);
4178
+ return this._inputStatus().subscribeKey(key, cb);
4179
+ }
4180
+
4181
+ /** @internal Wired by the SDK's open factory. */
4182
+ _setInputsRuntime(runtime: InputsStatusRuntime | null): void {
4183
+ this._inputsRuntime = runtime;
4184
+ }
4185
+
4186
+ /** @internal The platform moved `key`'s status to `rev`; both null after a
4187
+ * resubscribe, which may have missed a nudge for any key. */
4188
+ _inputStatusNudged(key: string | null, rev: number | null): void {
4189
+ this._inputStatusHub?.nudged(key, rev);
4190
+ }
4191
+
4192
+ private _inputStatus(): InputStatusHub {
4193
+ if (this._inputStatusHub !== null) return this._inputStatusHub;
4194
+ const runtime = this._inputsRuntime;
4195
+ if (runtime === null) {
4196
+ throw new Error('subscribeInputStatus: Document has no server identity. Opened via the SDK factory?');
4197
+ }
4198
+ this._inputStatusHub = new InputStatusHub({
4199
+ runtime,
4200
+ lineId: () => this._branchId,
4201
+ localRoots: () => this._inputsLocalRoots(),
4202
+ watchLocalRoots: (cb) => this._watchInputsRoots(cb),
4203
+ });
4204
+ return this._inputStatusHub;
4205
+ }
4206
+
4207
+ // The reserved roots' plain values. A root nobody has written is read as
4208
+ // empty without asking the engine for it, since asking creates it.
4209
+ private _inputsLocalRoots(): InputsLocalRoots | null {
4210
+ if (this._sealedRoots !== null || this._closed) return null;
4211
+ const engine = this._doc;
4212
+ const present = engine.getShallowValue() as Record<string, unknown>;
4213
+ return {
4214
+ refs: INPUTS_REFS_ROOT in present ? engine.getMap(INPUTS_REFS_ROOT).toJSON() : {},
4215
+ read: INPUTS_READ_ROOT in present ? engine.getMap(INPUTS_READ_ROOT).toJSON() : {},
4216
+ };
4217
+ }
4218
+
4219
+ private _watchInputsRoots(cb: () => void): () => void {
4220
+ if (this._sealedRoots !== null || this._closed) return () => {};
4221
+ const targets = new Set([`cid:root-${INPUTS_REFS_ROOT}:Map`, `cid:root-${INPUTS_READ_ROOT}:Map`]);
4222
+ return this._doc.subscribe((batch) => {
4223
+ if (batch.events.some((event) => targets.has(String(event.target)))) cb();
4224
+ });
4225
+ }
4226
+
4227
+ /** @internal How a frozen copy watches its document: starts watching,
4228
+ * calls `fire` when the document moves, and returns the stop. */
4229
+ _setChangeNotice(start: ((fire: () => void) => () => void) | null): void {
4230
+ this._changeNotice = start;
4231
+ }
4232
+
4233
+ /** @internal Stops a frozen copy's notice once it has reported a move and
4234
+ * nobody is waiting on pending work. */
4235
+ _settleChangeNotice(): void {
4236
+ if (!this._changed) return;
4237
+ if (this._statusWatches > 0 && this._workStatus.work?.pending === true) return;
4238
+ this._stopChangeNotice?.();
4239
+ this._stopChangeNotice = null;
4240
+ }
4241
+
4242
+ /** @internal Wire a frozen copy's reload: re-join, apply the result in
4243
+ * place, and re-point the change notice at what that join brought. */
4244
+ _setFrozenReloader(reload: () => Promise<void>): void {
4245
+ this._frozenReloader = reload;
4246
+ }
4247
+
4248
+ // The notice is spent on its first frame, so the watch stops here: the topic
4249
+ // carries every op of the document, and a copy that already knows it moved
4250
+ // has no use for the rest until it reloads. A copy whose work is still
4251
+ // pending keeps it while an `onStatus` listener waits, because the import
4252
+ // landing moves the document before its terminal status arrives.
4253
+ private _markChanged(): void {
4254
+ if (this._changed || this._closed) return;
4255
+ this._changed = true;
4256
+ this._settleChangeNotice();
4257
+ const listeners = [...this._changedListeners];
4258
+ for (const cb of listeners) {
4259
+ try {
4260
+ cb();
4261
+ } catch (err) {
4262
+ console.error('Brass: an onChanged listener threw', err);
4263
+ }
4264
+ }
4265
+ }
4266
+
4267
+ /**
4268
+ * The work in flight on this document (a new version importing, a drive
4269
+ * sync), or `null` when there is none. Render `message` verbatim, draw a
4270
+ * bar from `progress` when present, and show a waiting state while
4271
+ * `pending`. Later phases arrive through `onStatus`.
4272
+ */
2911
4273
  get status(): DocumentWorkStatus | null {
2912
4274
  return this._workStatus.status;
2913
4275
  }
2914
4276
 
2915
- // Subscribe to opaque work-status updates. The callback fires on
2916
- // every phase the server pushes; when `status.pending` flips to
2917
- // `false` the work is terminal (a success the app reacts to by
2918
- // re-opening the doc to load the now-available content, or a failure
2919
- // whose `message` the app shows). Fires once on attach with the
2920
- // current status when one is already known, so a late subscriber is
2921
- // not stranded. Returns an unsubscribe.
2922
- //
2923
- // The app must not parse `message` or infer phases from it: the
2924
- // server owns the wording and the lifecycle. This surface exists so
2925
- // the app can *display* progress, not understand it.
4277
+ // The platform's work status alone, without the SDK's own notice over it.
4278
+ // Every SDK-side decision about whether work is pending reads this, since
4279
+ // the notice describes this client's connection rather than the platform's
4280
+ // work.
4281
+ /** @internal */
4282
+ get _workStatusNow(): DocumentWorkStatus | null {
4283
+ return this._workStatus.work;
4284
+ }
4285
+
4286
+ // Tell the reader their edits are waiting to save, through `status`, while
4287
+ // they wait: on a connection that has not opened (`NEVER_OPENED_NOTICE_MS`),
4288
+ // or on one that leaves an edit unanswered (`UNANSWERED_OP_NOTICE_AFTER`).
4289
+ // The doc transport decides when; the notice clears itself once nothing is
4290
+ // waiting (`_fireSubscribers`). A stream raises it on the document that
4291
+ // opened it as well, since the app renders that document's status.
4292
+ /** @internal */
4293
+ _showConnectionNotice(kind: keyof typeof SAVE_NOTICES = 'never_opened'): void {
4294
+ if (this._closed || this.savingCount === 0) return;
4295
+ this._workStatus.setNotice(SAVE_NOTICES[kind]);
4296
+ this._connectionNoticeObserver?.(kind);
4297
+ }
4298
+
4299
+ private _connectionNoticeObserver: ((kind: keyof typeof SAVE_NOTICES) => void) | null = null;
4300
+
4301
+ /** @internal */
4302
+ _observeConnectionNotice(observer: ((kind: keyof typeof SAVE_NOTICES) => void) | null): void {
4303
+ this._connectionNoticeObserver = observer;
4304
+ }
4305
+
4306
+ /**
4307
+ * Calls `cb` with every work-status update, and once on attach when a
4308
+ * status is already known. `pending: false` marks the work finished, as a
4309
+ * success or as a failure whose `message` the app shows. Returns an
4310
+ * unsubscribe.
4311
+ *
4312
+ * Do not parse `message` or infer phases from it: its wording changes
4313
+ * without an SDK release.
4314
+ */
2926
4315
  onStatus(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
2927
4316
  // See `onGone`: guard the cross-session listener leak.
2928
4317
  this._assertOpen();
2929
- return this._workStatus.on(cb);
4318
+ const stop = this._workStatus.on(cb);
4319
+ // A frozen copy hears the work that was pending when it loaded on its
4320
+ // change notice, which runs only while somebody listens. A live copy's
4321
+ // transport always delivers status.
4322
+ if (this._changeNotice === null || this._workStatus.work?.pending !== true) return stop;
4323
+ this._statusWatches += 1;
4324
+ if (!this._changed) this._watchForChange();
4325
+ let stopped = false;
4326
+ return () => {
4327
+ stop();
4328
+ if (stopped) return;
4329
+ stopped = true;
4330
+ this._statusWatches -= 1;
4331
+ this._unwatchIfIdle();
4332
+ this._settleChangeNotice();
4333
+ };
2930
4334
  }
2931
4335
 
2932
4336
  // Internal: store the latest status and fire listeners. Not
@@ -2935,7 +4339,7 @@ export class Document {
2935
4339
  // final state.
2936
4340
  /** @internal */
2937
4341
  _emitStatus(
2938
- status: DocumentWorkStatus,
4342
+ incoming: DocumentWorkStatus,
2939
4343
  opts?: {
2940
4344
  // The caller imported the document's content immediately before this
2941
4345
  // emit, so the work delivered what it promised whatever the terminal
@@ -2946,6 +4350,16 @@ export class Document {
2946
4350
  // A late reload/reconcile closure that resolved after `close()` must not
2947
4351
  // re-emit onto a torn-down doc (its undo stack is freed below in `close`).
2948
4352
  if (this._closed) return;
4353
+ // An import in flight holds the document against a settled status from
4354
+ // anywhere else (a late frame for the work that stored the file), because
4355
+ // an edit made before the import lands leaves the file unimportable. A
4356
+ // join reporting the import done clears `_awaitsImport`, which releases
4357
+ // it for a client waiting on another's import.
4358
+ const status =
4359
+ this._importHold !== null && this._awaitsImport && incoming.pending !== true
4360
+ ? this._importHold
4361
+ : incoming;
4362
+ const wasReadOnly = this.readOnly;
2949
4363
  // Terminal work that still reports itself as locking editing is inbound
2950
4364
  // work that ended without content: the platform clears the status when
2951
4365
  // hydration / extraction succeeds, so what it reports terminally is a
@@ -2962,6 +4376,10 @@ export class Document {
2962
4376
  // Refresh the undo-state notifier here so a subscribeUndoState-bound
2963
4377
  // button tracks the lock, not only CRDT edits.
2964
4378
  this._maybeFireUndoState();
4379
+ // An app re-reads `readOnly` in its `subscribe` handler, so a status that
4380
+ // locks or releases a document it has already bound has to reach that
4381
+ // handler, or its edit controls stay as they were.
4382
+ if (this.readOnly !== wasReadOnly) this._fireSubscribers({ kind: 'commands' });
2965
4383
  }
2966
4384
 
2967
4385
  // The current drive sync state of this document, or `null` when the
@@ -3064,9 +4482,243 @@ export class Document {
3064
4482
  this._resyncPushClientOpIds.clear();
3065
4483
  this._fireSubscribers({ kind: 'saving-count' });
3066
4484
  }
4485
+ this._provisionalInputs.clear();
3067
4486
  this._gone.mark(reason);
3068
4487
  }
3069
4488
 
4489
+ // --- References and read marks -------------------------------------------
4490
+
4491
+ // How many handle `mutate` calls are running, and how many of those asked
4492
+ // to stay off the Undo stack. A commit inside any of them carries the
4493
+ // non-undoable origin, so a write the app asked to keep off the stack does
4494
+ // not reach it through a batch boundary or a nested handle.
4495
+ private _mutateDepth = 0;
4496
+ private _nonUndoableDepth = 0;
4497
+ // `dependsOn` / `markRead` writes waiting for the running mutate to finish.
4498
+ // A mutate can commit several times (`mutate-batching.ts`), and a write
4499
+ // left in the open transaction rides whichever commit comes next, so a
4500
+ // read mark could arrive before the results it acknowledges. Each runs as
4501
+ // a commit of its own after the mutate's last one.
4502
+ private _inputsQueue: InputsWrite[] = [];
4503
+ // The client op ids `_shipLocalOp` mints while a reference commit runs.
4504
+ private _shipCapture: string[] | null = null;
4505
+ private readonly _provisionalInputs = new ProvisionalInputsTracker();
4506
+
4507
+ /**
4508
+ * @internal The document's `updated_at` as this copy read it when it
4509
+ * opened. `markRead` records a document at this value. `null` for a
4510
+ * document not opened from the platform.
4511
+ */
4512
+ get updatedAt(): string | null {
4513
+ return joinStampOf(this) ?? null;
4514
+ }
4515
+
4516
+ /** @internal Where a key's provisional entry is posted once the op
4517
+ * carrying its change is acknowledged. */
4518
+ _setProvisionalInputsPoster(poster: ProvisionalInputsPoster | null): void {
4519
+ this._provisionalInputs.setPoster(poster);
4520
+ // A post's answer names the key's revision after it, which reads the same
4521
+ // as a nudge: a status behind it fetches the change.
4522
+ this._provisionalInputs.onAnswer(
4523
+ poster === null ? null : (answer) => this._inputStatusNudged(answer.key, answer.rev),
4524
+ );
4525
+ }
4526
+
4527
+ /** @internal Run a handle's mutate, so its commits carry the origin it asked
4528
+ * for and the reference writes queued inside it commit after it. */
4529
+ _runInMutate(undoable: boolean, fn: () => void): void {
4530
+ // A nested mutate that changes the origin first commits what the enclosing
4531
+ // one has written so far, and commits its own writes before handing back,
4532
+ // so each write reaches the Undo stack per the mutate that made it.
4533
+ const switchesOrigin = !undoable && this._nonUndoableDepth === 0 && this._mutateDepth > 0;
4534
+ if (switchesOrigin) this._commitPendingMutate();
4535
+ this._mutateDepth += 1;
4536
+ if (!undoable) this._nonUndoableDepth += 1;
4537
+ let threw = true;
4538
+ try {
4539
+ fn();
4540
+ threw = false;
4541
+ } finally {
4542
+ if (switchesOrigin) this._commitPendingMutate();
4543
+ this._mutateDepth -= 1;
4544
+ if (!undoable) this._nonUndoableDepth -= 1;
4545
+ if (this._mutateDepth === 0) {
4546
+ const queued = this._inputsQueue;
4547
+ this._inputsQueue = [];
4548
+ // A mutate that threw left its results partly written, so the marks
4549
+ // that acknowledge them are not written either.
4550
+ if (!threw) this._flushInputs(queued);
4551
+ }
4552
+ }
4553
+ }
4554
+
4555
+ private _commitPendingMutate(): void {
4556
+ if (this._doc.getPendingTxnLength() > 0) this._handleOwner.commitMutate();
4557
+ }
4558
+
4559
+ private get _mutateCommitOrigin(): string {
4560
+ return this._nonUndoableDepth > 0 ? NON_UNDOABLE_COMMIT_ORIGIN : MUTATE_COMMIT_ORIGIN;
4561
+ }
4562
+
4563
+ /**
4564
+ * Records the documents `key` depends on, replacing what it held. `null`
4565
+ * removes the key and its read marks. Writes only what differs from this
4566
+ * copy, so a reference another reader adds or removes at the same time
4567
+ * keeps their change. Undoable, as part of the current step, unless called
4568
+ * inside `mutate(fn, { undoable: false })`.
4569
+ */
4570
+ dependsOn(key: string, refs: readonly InputReference[] | null): void {
4571
+ this._handleOwner.assertWritable();
4572
+ if (!isInputKey(key)) throw new TypeError(`dependsOn: "${String(key)}" is not a valid key`);
4573
+ if (refs !== null && !Array.isArray(refs)) {
4574
+ throw new TypeError('dependsOn: refs must be an array of references or null');
4575
+ }
4576
+ const parsed: WireReference[] = [];
4577
+ for (const ref of refs ?? []) {
4578
+ const one = parseInputReference(ref);
4579
+ if (one === null) throw new TypeError('dependsOn: refs holds a value that is not a reference');
4580
+ if (one.extent === 'document' && this._docId !== null && one.doc_id === this._docId) {
4581
+ throw new Error('dependsOn: a document cannot reference itself');
4582
+ }
4583
+ parsed.push(one);
4584
+ }
4585
+ const refusal = this._inputsRuntime?.refusesReferences;
4586
+ if (refusal !== undefined && parsed.length > 0) throw new BrassError(refusal);
4587
+ const next = refs === null ? null : parsed;
4588
+ this._queueInputs(this._nonUndoableDepth === 0, () => {
4589
+ const refsMap = this._doc.getMap(INPUTS_REFS_ROOT);
4590
+ let changed = applyRootWrites(refsMap, inputRefsWrites(rootView(refsMap), key, next));
4591
+ if (next === null) {
4592
+ const readMap = this._doc.getMap(INPUTS_READ_ROOT);
4593
+ changed = applyRootWrites(readMap, inputReadClearWrites(rootView(readMap), key)) || changed;
4594
+ }
4595
+ return changed ? key : null;
4596
+ });
4597
+ }
4598
+
4599
+ /**
4600
+ * @internal Records that this reader read files for the key `status` reports, and
4601
+ * acknowledged the removed files that status lists. With no `files`, every
4602
+ * file the status lists as added or modified is recorded at the version the
4603
+ * status reports. Otherwise each entry of `files` is recorded: an opened
4604
+ * copy at the `updatedAt` it was opened at (left out while its import is
4605
+ * pending, so it stays changed), and an entry of the status's own `files`
4606
+ * or `changed` at the version the status reports. Joins the current Undo
4607
+ * step unless `undoable` is false.
4608
+ */
4609
+ markRead(status: MarkReadStatus, options?: MarkReadOptions): void {
4610
+ this._handleOwner.assertWritable();
4611
+ const key = status?.key;
4612
+ if (!isInputKey(key)) throw new TypeError('markRead: pass the status `subscribeInputStatus` reported');
4613
+ // A list of files passed where the options go would otherwise read as no
4614
+ // files, and record every changed one.
4615
+ if (Array.isArray(options)) throw new TypeError('markRead: pass the files as `{ files }`');
4616
+ const files = options?.files;
4617
+ if (files !== undefined && !Array.isArray(files)) {
4618
+ throw new TypeError('markRead: `files` must be an array');
4619
+ }
4620
+ // The version the status reports for each file, which is what an entry
4621
+ // taken from the status is recorded at: an edit that lands after the
4622
+ // status stays changed.
4623
+ const reported = new Map<string, string>();
4624
+ for (const file of status.inputs ?? []) reported.set(file.doc_id, file.updated_at);
4625
+ const items: ReadonlyArray<MarkReadHandle | MarkReadEntry> =
4626
+ files ?? status.changed.filter((c) => c.change !== 'removed');
4627
+ const reads: Array<{ docId: string; stamp: number }> = [];
4628
+ for (const item of items) {
4629
+ let docId: string | null;
4630
+ let version: string | null;
4631
+ if ('doc_id' in item) {
4632
+ // A removed file is acknowledged from `status.changed` below.
4633
+ if ((item as { change?: string }).change === 'removed') continue;
4634
+ docId = item.doc_id;
4635
+ version = reported.get(docId) ?? null;
4636
+ if (version === null) {
4637
+ throw new TypeError(`markRead: the status reports no version for ${docId}`);
4638
+ }
4639
+ } else {
4640
+ if (item.status?.pending === true) continue;
4641
+ docId = item.id;
4642
+ version = item.updatedAt;
4643
+ }
4644
+ const stamp = typeof version === 'string' ? inputStampFromUpdatedAt(version) : null;
4645
+ if (!isInputDocId(docId) || stamp === null) {
4646
+ throw new TypeError('markRead: each file must be a document opened from Brass or an entry of the status');
4647
+ }
4648
+ reads.push({ docId, stamp });
4649
+ }
4650
+ const removed = status.changed
4651
+ .filter((c) => c.change === 'removed')
4652
+ .filter((c) => isInputDocId(c.doc_id))
4653
+ .map((c) => ({ docId: c.doc_id, leftAt: c.updated_at ?? null }));
4654
+ this._queueInputs(options?.undoable !== false, () => {
4655
+ const readMap = this._doc.getMap(INPUTS_READ_ROOT);
4656
+ const view = rootView(readMap);
4657
+ let changed = false;
4658
+ const write = (docId: string, mark: InputReadMark): void => {
4659
+ const writes = inputReadMarkWrites(view, key, docId, mark);
4660
+ for (const k of writes.delete) delete view[k];
4661
+ Object.assign(view, writes.set);
4662
+ changed = applyRootWrites(readMap, writes) || changed;
4663
+ };
4664
+ for (const { docId, stamp } of reads) {
4665
+ write(docId, inputReadMarkFor(latestReadMark(view, key, docId), stamp));
4666
+ }
4667
+ for (const { docId, leftAt } of removed) {
4668
+ write(docId, inputRemovalMark(latestReadMark(view, key, docId), leftAt));
4669
+ }
4670
+ return changed ? key : null;
4671
+ });
4672
+ }
4673
+
4674
+ private _queueInputs(undoable: boolean, apply: () => string | null): void {
4675
+ const entry: InputsWrite = { undoable, apply };
4676
+ if (this._mutateDepth > 0) {
4677
+ this._inputsQueue.push(entry);
4678
+ return;
4679
+ }
4680
+ this._flushInputs([entry]);
4681
+ }
4682
+
4683
+ // Commit queued reference writes in call order, one commit per run of
4684
+ // writes that share an origin, and wait to post each key they changed.
4685
+ private _flushInputs(entries: readonly InputsWrite[]): void {
4686
+ if (entries.length === 0 || this._readOnlyBecause !== null) return;
4687
+ this._ensureLocalUpdatesSubscribed();
4688
+ let i = 0;
4689
+ while (i < entries.length) {
4690
+ const undoable = entries[i]!.undoable;
4691
+ const keys = new Set<string>();
4692
+ for (; i < entries.length && entries[i]!.undoable === undoable; i += 1) {
4693
+ const key = entries[i]!.apply();
4694
+ if (key !== null) keys.add(key);
4695
+ }
4696
+ if (keys.size === 0) continue;
4697
+ this._shipCapture = [];
4698
+ let shipped: string[];
4699
+ try {
4700
+ this._doc.setNextCommitOrigin(undoable ? MUTATE_COMMIT_ORIGIN : NON_UNDOABLE_COMMIT_ORIGIN);
4701
+ this._doc.commit();
4702
+ } finally {
4703
+ shipped = this._shipCapture;
4704
+ this._shipCapture = null;
4705
+ }
4706
+ if (shipped.length === 0) continue;
4707
+ const state = decodeInputsRoots(
4708
+ this._doc.getMap(INPUTS_REFS_ROOT).toJSON(),
4709
+ this._doc.getMap(INPUTS_READ_ROOT).toJSON(),
4710
+ );
4711
+ for (const key of keys) {
4712
+ this._provisionalInputs.register(
4713
+ key,
4714
+ this._branchId,
4715
+ state.get(key) ?? { refs: [], read: {} },
4716
+ shipped,
4717
+ );
4718
+ }
4719
+ }
4720
+ }
4721
+
3070
4722
  /**
3071
4723
  * The top-level collections of the stream this document is open at, keyed
3072
4724
  * as it keys them, each naming the kind of handle that reads it.
@@ -3086,17 +4738,19 @@ export class Document {
3086
4738
  */
3087
4739
  roots(): Record<string, RootKind> {
3088
4740
  this._assertNotClosed();
4741
+ if (this._sealedRoots !== null) return this._sealedRoots.roots();
3089
4742
  const source =
3090
4743
  this._checkout !== null ? this._checkout.previewDoc : this._doc;
3091
4744
  const found: [string, RootKind][] = [];
3092
4745
  for (const [key, containerId] of Object.entries(source.getShallowValue())) {
3093
4746
  const kind = rootKindOf(containerId);
3094
- if (kind === null) continue;
4747
+ if (kind === null || isReservedRootName(key)) continue;
3095
4748
  // Reading the container's own count, rather than its value, is what
3096
4749
  // keeps this off the projection: an empty collection is skipped without
3097
4750
  // anything being decoded.
3098
4751
  const container = source.getContainerById(containerId);
3099
4752
  if (container === undefined || rootIsEmpty(container)) continue;
4753
+ if (holdsOnlyMarkers(key, container)) continue;
3100
4754
  found.push([key, kind]);
3101
4755
  }
3102
4756
  // Loro reports roots in registration order, which is an accident of who
@@ -3106,26 +4760,23 @@ export class Document {
3106
4760
  return Object.fromEntries(found);
3107
4761
  }
3108
4762
 
3109
- // Plain-JS projection of the current state.
4763
+ // Plain-JS projection of the current state, leaving out a root that holds
4764
+ // nothing. A CRDT cannot store an empty collection, and the root a Document
4765
+ // registers on open is empty until written, so a projection that kept empty
4766
+ // roots would change shape when the storage swaps in place under a reader.
4767
+ // Every other root stays whatever its kind: the sealed-copy builder reads
4768
+ // this projection to refuse a kind the frozen form cannot carry.
3110
4769
  toJSON(): Record<string, unknown> {
3111
4770
  this._assertNotClosed();
3112
4771
  // When in checkout, read from the preview LoroDoc so the app
3113
4772
  // renders the historical state. Returning to head clears
3114
4773
  // `_checkout` and reads go back to the live `_doc`.
3115
- const source =
3116
- this._checkout !== null ? this._checkout.previewDoc : this._doc;
3117
- const json = source.toJSON() as Record<string, unknown>;
3118
- // A paged root holds its records inside page entries beside the store's
3119
- // own edit bookkeeping, so flatten it to the same record lookup
3120
- // `doc.map(root).read()` hands back: a serializer and a whole-document
3121
- // renderer read this surface, and a store that crossed the paging
3122
- // threshold has to reach them as records. Every root is asked, because a
3123
- // root no handle was ever taken on is paged by the same rule as one that
3124
- // was; a root the marker does not name comes back as loro produced it.
3125
- for (const [rootKey, value] of Object.entries(json)) {
3126
- if (!isRootLookup(value)) continue;
3127
- if (!readRootPaged(source, rootKey)) continue;
3128
- json[rootKey] = flattenPagedLookup(value, true);
4774
+ const json =
4775
+ this._sealedRoots !== null
4776
+ ? this._sealedRoots.toJSON()
4777
+ : engineJSON(this._checkout !== null ? this._checkout.previewDoc : this._doc);
4778
+ for (const [key, value] of Object.entries(json)) {
4779
+ if (projectsEmpty(value)) delete json[key];
3129
4780
  }
3130
4781
  return json;
3131
4782
  }
@@ -3155,6 +4806,7 @@ export class Document {
3155
4806
  maybeResyncPush(
3156
4807
  persistedSnapshotVersionVector: Uint8Array,
3157
4808
  durableTailBlobs: Uint8Array[],
4809
+ deliveringBlobs: readonly Uint8Array[] = [],
3158
4810
  ): ResyncPushOutcome {
3159
4811
  // `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
3160
4812
  // closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
@@ -3168,6 +4820,7 @@ export class Document {
3168
4820
  const chunks = this.computeResyncPush(
3169
4821
  persistedSnapshotVersionVector,
3170
4822
  durableTailBlobs,
4823
+ deliveringBlobs,
3171
4824
  );
3172
4825
  if (chunks === 'covered') return 'covered';
3173
4826
  if (chunks === 'undecodable') return 'undecodable';
@@ -3183,14 +4836,15 @@ export class Document {
3183
4836
  // this client holds CRDT knowledge the durable server state lacks,
3184
4837
  // and if so return the update blob that fills exactly those holes.
3185
4838
  //
3186
- // The durable frontier is the snapshot's version vector advanced by
3187
- // every tail op the server delivered (those ops are durable in the
3188
- // log even though no snapshot has absorbed them yet). Comparing the
3189
- // local doc against the SNAPSHOT vector alone would falsely flag the
3190
- // whole durable tail as missing and push it back on every join, which
3191
- // then grows the log and loops; folding the tail in is what keeps the
3192
- // handshake a no-op in the common "server already has everything"
3193
- // case. `decodeImportBlobMeta` reads each blob's end vector from its
4839
+ // What the server holds is the snapshot (every author's ops from zero to
4840
+ // its version vector) plus each tail op it served, range by range: a tail
4841
+ // op is durable in the log even though no snapshot has absorbed it. Holding
4842
+ // the tail as ranges rather than folding it into one counter per author is
4843
+ // what sees a hole: a log that lost an author's op and stored that author's
4844
+ // later ones can apply none of them, so the op that fills the hole is
4845
+ // exactly what has to go back. Leaving the tail out altogether would flag
4846
+ // the whole tail as missing and push it back on every join, which grows the
4847
+ // log and loops. `decodeImportBlobMeta` reads each blob's range from its
3194
4848
  // header without a full import, so this stays cheap.
3195
4849
  //
3196
4850
  // Returns the chunks to resync-push, `'covered'` when the durable state
@@ -3201,10 +4855,11 @@ export class Document {
3201
4855
  // nothing about whether the doc has diverged, so a caller healing a known
3202
4856
  // divergence must keep asking.
3203
4857
  //
3204
- // The common case is one chunk: an `update` export from the durable
3205
- // frontier carries only the missing ops plus their dependencies, never
3206
- // the whole history, and a realistic gap is far under the per-frame
3207
- // cap. When the missing slice exceeds the cap (a BIM-sized edit set:
4858
+ // The common case is one chunk: when each author is missing only its
4859
+ // newest ops, an `update` export carries exactly those, and a realistic
4860
+ // gap is far under the per-frame cap. A hole below ops the server holds
4861
+ // ships as its own `updates-in-range` span, one range per author per blob,
4862
+ // so a blob never re-sends what the server holds. When the missing slice exceeds the cap (a BIM-sized edit set:
3208
4863
  // a geometry import or bulk parametric change discarded then healed, or
3209
4864
  // a large offline divergence on reconnect), it is split into multiple
3210
4865
  // chunks via `updates-in-range` spans, each under the cap, so even a
@@ -3231,7 +4886,7 @@ export class Document {
3231
4886
  * @internal
3232
4887
  */
3233
4888
  _snapshotHasUnseenAuthor(persistedSnapshotVersionVector: Uint8Array): boolean {
3234
- if (this._closed) return false;
4889
+ if (this._closed || this._sealedRoots !== null) return false;
3235
4890
  let durable: VersionVector;
3236
4891
  try {
3237
4892
  durable = VersionVector.decode(persistedSnapshotVersionVector);
@@ -3251,14 +4906,19 @@ export class Document {
3251
4906
  computeResyncPush(
3252
4907
  persistedSnapshotVersionVector: Uint8Array,
3253
4908
  durableTailBlobs: Uint8Array[],
4909
+ deliveringBlobs: readonly Uint8Array[] = [],
3254
4910
  ): Uint8Array[] | 'covered' | 'undecodable' {
3255
- let durable: VersionVector;
4911
+ // Frozen content holds no local ops, so the durable frontier covers it.
4912
+ if (this._sealedRoots !== null) return 'covered';
4913
+ let held: HeldRanges;
3256
4914
  try {
3257
- durable = VersionVector.decode(persistedSnapshotVersionVector);
3258
- for (const blob of durableTailBlobs) {
3259
- const meta = decodeImportBlobMeta(blob, false);
3260
- durable = mergeVersionVectors(durable, meta.partialEndVersionVector);
3261
- }
4915
+ // An op the transport has only just sent is on its way rather than
4916
+ // missing; counting it as held keeps the handshake from writing a
4917
+ // second copy of it beside the transport's own send.
4918
+ held = durableHoldings(persistedSnapshotVersionVector, [
4919
+ ...durableTailBlobs,
4920
+ ...deliveringBlobs,
4921
+ ]);
3262
4922
  } catch (err) {
3263
4923
  console.warn(
3264
4924
  '[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob',
@@ -3267,37 +4927,27 @@ export class Document {
3267
4927
  return 'undecodable';
3268
4928
  }
3269
4929
  const local = this._doc.version();
3270
- const cmp = local.compare(durable);
3271
- // `compare` returns -1/0 when local is at or behind the durable
3272
- // frontier (nothing to push), 1 when local is strictly ahead, and
3273
- // undefined when the two are concurrent (local holds ops the server
3274
- // lacks AND vice versa). Push in the ahead and concurrent cases; the
3275
- // export from `durable` carries only the local-only ops either way.
3276
- if (cmp !== undefined && cmp <= 0) return 'covered';
3277
- // Common case: the whole missing slice fits in one frame. Keep this
3278
- // path byte-identical to a plain `update` export so the no-chunking
3279
- // case is unchanged.
3280
- const full = this._doc.export({ mode: 'update', from: durable });
3281
- if (full.byteLength <= RESYNC_PUSH_MAX_BYTES) return [full];
3282
- // Large divergence: split the per-peer missing ranges into chunks.
3283
- return this.chunkMissingSlice(local, durable);
3284
- }
3285
-
3286
- // The per-peer ranges local holds beyond `durable`, as half-open
3287
- // [from, to) counter ranges. `export({ mode: 'update', from })` covers
3288
- // exactly these ops; we re-express them as `updates-in-range` spans so
3289
- // they can be split.
3290
- private missingRanges(
3291
- local: VersionVector,
3292
- durable: VersionVector,
3293
- ): { peer: PeerID; from: number; to: number }[] {
3294
- const durableMap = durable.toJSON();
3295
- const ranges: { peer: PeerID; from: number; to: number }[] = [];
3296
- for (const [peer, to] of local.toJSON()) {
3297
- const from = durableMap.get(peer) ?? 0;
3298
- if (to > from) ranges.push({ peer, from, to });
3299
- }
3300
- return ranges;
4930
+ const missing = rangesMissingFrom(local, held);
4931
+ if (missing.length === 0) return 'covered';
4932
+ // Common case: each author is missing only its newest ops, which is an
4933
+ // `update` export from a version vector, and the slice fits in one frame.
4934
+ const tails = new Map(missing.map((r) => [r.peer, r]));
4935
+ const localMap = local.toJSON();
4936
+ if (
4937
+ tails.size === missing.length &&
4938
+ missing.every((r) => r.to === localMap.get(r.peer))
4939
+ ) {
4940
+ const from = new Map(localMap);
4941
+ for (const r of missing) from.set(r.peer, r.from);
4942
+ const full = this._doc.export({
4943
+ mode: 'update',
4944
+ from: VersionVector.parseJSON(from),
4945
+ });
4946
+ if (full.byteLength <= RESYNC_PUSH_MAX_BYTES) return [full];
4947
+ }
4948
+ return oneRangePerAuthor(missing).flatMap((group) =>
4949
+ this.chunkRanges(group, true),
4950
+ );
3301
4951
  }
3302
4952
 
3303
4953
  // Recursively export the given ranges as one `updates-in-range` blob,
@@ -3339,13 +4989,6 @@ export class Document {
3339
4989
  ];
3340
4990
  }
3341
4991
 
3342
- private chunkMissingSlice(
3343
- local: VersionVector,
3344
- durable: VersionVector,
3345
- ): Uint8Array[] {
3346
- return this.chunkRanges(this.missingRanges(local, durable), true);
3347
- }
3348
-
3349
4992
  // A local commit re-exported as several op blobs, or null to ship it whole.
3350
4993
  //
3351
4994
  // `mutate` bounds what one commit carries by committing as it goes, but a
@@ -3424,17 +5067,19 @@ export class Document {
3424
5067
  if (this._closed || this._suspended) return;
3425
5068
  this._suspended = true;
3426
5069
  this._setStreamsSuspended(true);
3427
- // A pending auto-checkpoint would otherwise fire against a parked
3428
- // Document and silently no-op via the scheduler's guard.
3429
- // Cancelling here keeps the timer count clean across long-lived
3430
- // caches and avoids the no-op fetch flush.
3431
- this._checkpoints.cancelAuto();
5070
+ // Calls still waiting for a marker get one now rather than after the
5071
+ // spacing, and a pending auto-checkpoint is cancelled rather than left to
5072
+ // fire against a parked Document.
5073
+ this._checkpoints.detach();
3432
5074
  this._subs.clear();
3433
5075
  this._undoStateSubs.clear();
3434
5076
  this._gone.clearListeners();
3435
5077
  this._workStatus.clearListeners();
3436
5078
  this._driveStatus.clearListeners();
3437
5079
  this._drivePublishTarget.clearListeners();
5080
+ this._inputFollowers.stopAll();
5081
+ this._inputStatusHub?.dispose();
5082
+ this._inputStatusHub = null;
3438
5083
  // Presence subscribes its OWN WS topic and keeps it alive across a park
3439
5084
  // (resume does not re-create the controller), so drop its app-side
3440
5085
  // change listeners here the same way as the sets above: a parked doc
@@ -3449,9 +5094,11 @@ export class Document {
3449
5094
  // so a subscription an app registered before `close()` would keep firing
3450
5095
  // the app's callback on a doc it has closed. Detaching first stops that;
3451
5096
  // fresh handles are minted on resume + next access.
3452
- for (const entry of this._rootHandles.values()) {
3453
- if (entry.kind === 'text') entry.handle._teardown();
3454
- else teardownRootHandle(entry.handle);
5097
+ // The handles over the ones torn down go with them, so a resumed open is
5098
+ // handed fresh handles like any other.
5099
+ if (this._sealedRoots === null) {
5100
+ this._teardownRootHandles();
5101
+ this._handles.clear();
3455
5102
  }
3456
5103
  this._rootHandles.clear();
3457
5104
  // Drop the ordered-map cache too. Each wrapper holds the specific root
@@ -3494,8 +5141,26 @@ export class Document {
3494
5141
  return this._openEpoch;
3495
5142
  }
3496
5143
 
5144
+ /** @internal Whether the latest join reported the document as its stored
5145
+ * file with no import run yet. Set by the open layer on every join, so an
5146
+ * importer can hold the document before its claim goes out. */
5147
+ _awaitsImport = false;
5148
+
5149
+ /** @internal The status an importer holds the document under, or null. */
5150
+ _importHold: DocumentWorkStatus | null = null;
5151
+
5152
+ /** @internal Why this client stopped waiting to import the document, or
5153
+ * null. */
5154
+ _importAbandoned: string | null = null;
5155
+
3497
5156
  close(): void {
3498
5157
  if (this._closed) return;
5158
+ // Before anything is torn down: the final marker reads the seq and the
5159
+ // unacked-op count this close is about to drop.
5160
+ this._checkpoints.detach();
5161
+ this._stopChangeNotice?.();
5162
+ this._stopChangeNotice = null;
5163
+ this._changedListeners.clear();
3499
5164
  this._closeStreams();
3500
5165
  this._closed = true;
3501
5166
  this._suspended = false;
@@ -3511,6 +5176,10 @@ export class Document {
3511
5176
  this._undo.free();
3512
5177
  this._undo = null;
3513
5178
  }
5179
+ if (!this._undoGroup.isOwner(this)) {
5180
+ this._undoGroup.delete(this);
5181
+ this._undoGroup.changed();
5182
+ }
3514
5183
  this._thumbnails.destroy();
3515
5184
  this._checkpoints.cancelAuto();
3516
5185
  this._localOpHook = null;
@@ -3528,13 +5197,13 @@ export class Document {
3528
5197
  this._workStatus.clearListeners();
3529
5198
  this._driveStatus.clearListeners();
3530
5199
  this._drivePublishTarget.clearListeners();
5200
+ this._inputFollowers.stopAll();
5201
+ this._inputStatusHub?.dispose();
5202
+ this._inputStatusHub = null;
3531
5203
  // Detach handle subscriptions before freeing the doc (below): a handle's
3532
5204
  // per-container Loro subscription outlives the cache reference, and
3533
5205
  // `_doc.free()` must not run with subscriptions still attached.
3534
- for (const entry of this._rootHandles.values()) {
3535
- if (entry.kind === 'text') entry.handle._teardown();
3536
- else teardownRootHandle(entry.handle);
3537
- }
5206
+ if (this._sealedRoots === null) this._teardownRootHandles();
3538
5207
  this._rootHandles.clear();
3539
5208
  this._orderedMapHandles.clear();
3540
5209
  // Free a live historical-checkout preview: it is a distinct LoroDoc
@@ -3543,7 +5212,7 @@ export class Document {
3543
5212
  // mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
3544
5213
  // has `previewDoc === _doc`, where `_doc.free()` below already covers it
3545
5214
  // and an unguarded free would double-free.
3546
- if (this._checkout && this._checkout.previewDoc !== this._doc) {
5215
+ if (this._checkout && this._checkout.previewDoc !== this._engine) {
3547
5216
  try {
3548
5217
  this._checkout.previewDoc.free();
3549
5218
  } catch {
@@ -3554,6 +5223,6 @@ export class Document {
3554
5223
  // Loro's WASM has a `free()` to drop the heap-allocated doc state.
3555
5224
  // Calling it after `close()` invalidates the doc, which is what we
3556
5225
  // want; future mutate/subscribe calls throw above.
3557
- this._doc.free();
5226
+ this._engine?.free();
3558
5227
  }
3559
5228
  }