@brass-build/client 0.4.0 → 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 (327) hide show
  1. package/AGENTS.md +142 -41
  2. package/CHANGELOG.md +93 -4
  3. package/README.md +666 -179
  4. package/dist/acting-organization.d.ts.map +1 -1
  5. package/dist/acting-organization.js +6 -11
  6. package/dist/acting-organization.js.map +1 -1
  7. package/dist/api-fetch.d.ts +8 -0
  8. package/dist/api-fetch.d.ts.map +1 -1
  9. package/dist/api-fetch.js +35 -8
  10. package/dist/api-fetch.js.map +1 -1
  11. package/dist/batched-get.d.ts +11 -0
  12. package/dist/batched-get.d.ts.map +1 -0
  13. package/dist/batched-get.js +92 -0
  14. package/dist/batched-get.js.map +1 -0
  15. package/dist/call-service.d.ts +4 -0
  16. package/dist/call-service.d.ts.map +1 -1
  17. package/dist/call-service.js +66 -3
  18. package/dist/call-service.js.map +1 -1
  19. package/dist/client.d.ts +51 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +982 -124
  22. package/dist/client.js.map +1 -1
  23. package/dist/document-checkpoints.d.ts +25 -2
  24. package/dist/document-checkpoints.d.ts.map +1 -1
  25. package/dist/document-checkpoints.js +258 -61
  26. package/dist/document-checkpoints.js.map +1 -1
  27. package/dist/document-commands.d.ts.map +1 -1
  28. package/dist/document-commands.js +5 -3
  29. package/dist/document-commands.js.map +1 -1
  30. package/dist/document-contents.d.ts +10 -0
  31. package/dist/document-contents.d.ts.map +1 -1
  32. package/dist/document-contents.js +23 -1
  33. package/dist/document-contents.js.map +1 -1
  34. package/dist/document-set-subscription.d.ts +56 -0
  35. package/dist/document-set-subscription.d.ts.map +1 -0
  36. package/dist/document-set-subscription.js +240 -0
  37. package/dist/document-set-subscription.js.map +1 -0
  38. package/dist/document-signals.d.ts +6 -0
  39. package/dist/document-signals.d.ts.map +1 -1
  40. package/dist/document-signals.js +31 -1
  41. package/dist/document-signals.js.map +1 -1
  42. package/dist/document-streams.d.ts +36 -2
  43. package/dist/document-streams.d.ts.map +1 -1
  44. package/dist/document-streams.js +69 -18
  45. package/dist/document-streams.js.map +1 -1
  46. package/dist/document-thumbnail-capture.d.ts +12 -2
  47. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  48. package/dist/document-thumbnail-capture.js +103 -27
  49. package/dist/document-thumbnail-capture.js.map +1 -1
  50. package/dist/documents-namespace.d.ts +92 -10
  51. package/dist/documents-namespace.d.ts.map +1 -1
  52. package/dist/documents-namespace.js +285 -39
  53. package/dist/documents-namespace.js.map +1 -1
  54. package/dist/documents.d.ts +173 -8
  55. package/dist/documents.d.ts.map +1 -1
  56. package/dist/documents.js +1778 -310
  57. package/dist/documents.js.map +1 -1
  58. package/dist/errors.d.ts +3 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js.map +1 -1
  61. package/dist/fetch-retry.d.ts +1 -0
  62. package/dist/fetch-retry.d.ts.map +1 -1
  63. package/dist/fetch-retry.js +2 -0
  64. package/dist/fetch-retry.js.map +1 -1
  65. package/dist/file-choice.d.ts +2 -0
  66. package/dist/file-choice.d.ts.map +1 -0
  67. package/dist/file-choice.js +27 -0
  68. package/dist/file-choice.js.map +1 -0
  69. package/dist/guest-link.d.ts +18 -1
  70. package/dist/guest-link.d.ts.map +1 -1
  71. package/dist/guest-link.js +329 -1
  72. package/dist/guest-link.js.map +1 -1
  73. package/dist/handle-types.d.ts +67 -5
  74. package/dist/handle-types.d.ts.map +1 -1
  75. package/dist/handles.d.ts +15 -19
  76. package/dist/handles.d.ts.map +1 -1
  77. package/dist/handles.js +231 -76
  78. package/dist/handles.js.map +1 -1
  79. package/dist/hosted-ui.d.ts +61 -0
  80. package/dist/hosted-ui.d.ts.map +1 -1
  81. package/dist/hosted-ui.js +94 -42
  82. package/dist/hosted-ui.js.map +1 -1
  83. package/dist/import-contract.d.ts +74 -0
  84. package/dist/import-contract.d.ts.map +1 -0
  85. package/dist/import-contract.js +163 -0
  86. package/dist/import-contract.js.map +1 -0
  87. package/dist/import-guest.d.ts +9 -0
  88. package/dist/import-guest.d.ts.map +1 -0
  89. package/dist/import-guest.js +155 -0
  90. package/dist/import-guest.js.map +1 -0
  91. package/dist/import-report.d.ts +17 -0
  92. package/dist/import-report.d.ts.map +1 -0
  93. package/dist/import-report.js +7 -0
  94. package/dist/import-report.js.map +1 -0
  95. package/dist/importer-wait.d.ts +2 -0
  96. package/dist/importer-wait.d.ts.map +1 -0
  97. package/dist/importer-wait.js +5 -0
  98. package/dist/importer-wait.js.map +1 -0
  99. package/dist/importer.d.ts +18 -4
  100. package/dist/importer.d.ts.map +1 -1
  101. package/dist/importer.js +322 -105
  102. package/dist/importer.js.map +1 -1
  103. package/dist/in-development.d.ts +2 -0
  104. package/dist/in-development.d.ts.map +1 -0
  105. package/dist/in-development.js +10 -0
  106. package/dist/in-development.js.map +1 -0
  107. package/dist/index.d.ts +16 -8
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +24 -8
  110. package/dist/index.js.map +1 -1
  111. package/dist/init-keys.d.ts +1 -1
  112. package/dist/init-keys.d.ts.map +1 -1
  113. package/dist/init-keys.js +6 -1
  114. package/dist/init-keys.js.map +1 -1
  115. package/dist/input-documents.d.ts +67 -0
  116. package/dist/input-documents.d.ts.map +1 -0
  117. package/dist/input-documents.js +693 -0
  118. package/dist/input-documents.js.map +1 -0
  119. package/dist/inputs-status.d.ts +80 -0
  120. package/dist/inputs-status.d.ts.map +1 -0
  121. package/dist/inputs-status.js +460 -0
  122. package/dist/inputs-status.js.map +1 -0
  123. package/dist/inputs.d.ts +2 -0
  124. package/dist/inputs.d.ts.map +1 -0
  125. package/dist/inputs.js +793 -0
  126. package/dist/inputs.js.map +1 -0
  127. package/dist/internal.d.ts +6 -1
  128. package/dist/internal.d.ts.map +1 -1
  129. package/dist/internal.js +4 -1
  130. package/dist/internal.js.map +1 -1
  131. package/dist/join-content.d.ts +33 -0
  132. package/dist/join-content.d.ts.map +1 -0
  133. package/dist/join-content.js +114 -0
  134. package/dist/join-content.js.map +1 -0
  135. package/dist/join-stamp.d.ts +3 -0
  136. package/dist/join-stamp.d.ts.map +1 -0
  137. package/dist/join-stamp.js +12 -0
  138. package/dist/join-stamp.js.map +1 -0
  139. package/dist/manifest-drift.d.ts +9 -0
  140. package/dist/manifest-drift.d.ts.map +1 -0
  141. package/dist/manifest-drift.js +46 -0
  142. package/dist/manifest-drift.js.map +1 -0
  143. package/dist/open-document.d.ts +109 -2
  144. package/dist/open-document.d.ts.map +1 -1
  145. package/dist/open-document.js +1822 -811
  146. package/dist/open-document.js.map +1 -1
  147. package/dist/page-restore.d.ts +12 -0
  148. package/dist/page-restore.d.ts.map +1 -0
  149. package/dist/page-restore.js +23 -0
  150. package/dist/page-restore.js.map +1 -0
  151. package/dist/placement.d.ts +13 -0
  152. package/dist/placement.d.ts.map +1 -0
  153. package/dist/placement.js +34 -0
  154. package/dist/placement.js.map +1 -0
  155. package/dist/presence.d.ts +1 -0
  156. package/dist/presence.d.ts.map +1 -1
  157. package/dist/presence.js +16 -4
  158. package/dist/presence.js.map +1 -1
  159. package/dist/read-only-document.d.ts +87 -0
  160. package/dist/read-only-document.d.ts.map +1 -0
  161. package/dist/read-only-document.js +4 -0
  162. package/dist/read-only-document.js.map +1 -0
  163. package/dist/record-paging.d.ts +63 -6
  164. package/dist/record-paging.d.ts.map +1 -1
  165. package/dist/record-paging.js +122 -29
  166. package/dist/record-paging.js.map +1 -1
  167. package/dist/references.d.ts +40 -0
  168. package/dist/references.d.ts.map +1 -0
  169. package/dist/references.js +135 -0
  170. package/dist/references.js.map +1 -0
  171. package/dist/request-lane.d.ts +5 -0
  172. package/dist/request-lane.d.ts.map +1 -0
  173. package/dist/request-lane.js +48 -0
  174. package/dist/request-lane.js.map +1 -0
  175. package/dist/sealed-arrow.d.ts +170 -0
  176. package/dist/sealed-arrow.d.ts.map +1 -0
  177. package/dist/sealed-arrow.js +769 -0
  178. package/dist/sealed-arrow.js.map +1 -0
  179. package/dist/sealed-cache.d.ts +21 -0
  180. package/dist/sealed-cache.d.ts.map +1 -0
  181. package/dist/sealed-cache.js +247 -0
  182. package/dist/sealed-cache.js.map +1 -0
  183. package/dist/sealed-encode.d.ts +87 -0
  184. package/dist/sealed-encode.d.ts.map +1 -0
  185. package/dist/sealed-encode.js +876 -0
  186. package/dist/sealed-encode.js.map +1 -0
  187. package/dist/sealed-handles.d.ts +45 -0
  188. package/dist/sealed-handles.d.ts.map +1 -0
  189. package/dist/sealed-handles.js +408 -0
  190. package/dist/sealed-handles.js.map +1 -0
  191. package/dist/sealed-store.d.ts +242 -0
  192. package/dist/sealed-store.d.ts.map +1 -0
  193. package/dist/sealed-store.js +1155 -0
  194. package/dist/sealed-store.js.map +1 -0
  195. package/dist/serializer.d.ts +6 -15
  196. package/dist/serializer.d.ts.map +1 -1
  197. package/dist/session-toolbar.d.ts +20 -5
  198. package/dist/session-toolbar.d.ts.map +1 -1
  199. package/dist/session-toolbar.js +419 -67
  200. package/dist/session-toolbar.js.map +1 -1
  201. package/dist/session.d.ts +38 -1
  202. package/dist/session.d.ts.map +1 -1
  203. package/dist/session.js +562 -79
  204. package/dist/session.js.map +1 -1
  205. package/dist/sha256.d.ts +3 -0
  206. package/dist/sha256.d.ts.map +1 -0
  207. package/dist/sha256.js +74 -0
  208. package/dist/sha256.js.map +1 -0
  209. package/dist/stable-handles.d.ts +19 -0
  210. package/dist/stable-handles.d.ts.map +1 -0
  211. package/dist/stable-handles.js +193 -0
  212. package/dist/stable-handles.js.map +1 -0
  213. package/dist/staged-seal.d.ts +2 -0
  214. package/dist/staged-seal.d.ts.map +1 -0
  215. package/dist/staged-seal.js +76 -0
  216. package/dist/staged-seal.js.map +1 -0
  217. package/dist/subscriber-delivery.d.ts +8 -0
  218. package/dist/subscriber-delivery.d.ts.map +1 -0
  219. package/dist/subscriber-delivery.js +31 -0
  220. package/dist/subscriber-delivery.js.map +1 -0
  221. package/dist/tail-op-import.d.ts +10 -0
  222. package/dist/tail-op-import.d.ts.map +1 -0
  223. package/dist/tail-op-import.js +33 -0
  224. package/dist/tail-op-import.js.map +1 -0
  225. package/dist/topic-lifecycle.d.ts +6 -0
  226. package/dist/topic-lifecycle.d.ts.map +1 -1
  227. package/dist/topic-lifecycle.js +32 -2
  228. package/dist/topic-lifecycle.js.map +1 -1
  229. package/dist/trial-replace-dialog.d.ts +10 -0
  230. package/dist/trial-replace-dialog.d.ts.map +1 -0
  231. package/dist/trial-replace-dialog.js +153 -0
  232. package/dist/trial-replace-dialog.js.map +1 -0
  233. package/dist/types.d.ts +8 -1
  234. package/dist/types.d.ts.map +1 -1
  235. package/dist/undo.d.ts +60 -5
  236. package/dist/undo.d.ts.map +1 -1
  237. package/dist/undo.js +344 -75
  238. package/dist/undo.js.map +1 -1
  239. package/dist/version-bar.d.ts +1 -0
  240. package/dist/version-bar.d.ts.map +1 -1
  241. package/dist/version-bar.js +2 -1
  242. package/dist/version-bar.js.map +1 -1
  243. package/dist/version.d.ts +2 -2
  244. package/dist/version.js +1 -1
  245. package/dist/ws-client.d.ts +21 -0
  246. package/dist/ws-client.d.ts.map +1 -1
  247. package/dist/ws-client.js +192 -8
  248. package/dist/ws-client.js.map +1 -1
  249. package/dist/ws-frame-limit.d.ts +1 -0
  250. package/dist/ws-frame-limit.d.ts.map +1 -1
  251. package/dist/ws-frame-limit.js.map +1 -1
  252. package/docs/app-manifest.md +121 -17
  253. package/docs/custom-chrome.md +24 -16
  254. package/docs/hosted-ui-advanced.md +38 -6
  255. package/docs/imported-documents.md +104 -51
  256. package/docs/presence.md +16 -4
  257. package/docs/schemas.md +8 -5
  258. package/docs/services.md +6 -6
  259. package/docs/streams.md +139 -150
  260. package/docs/text-fields.md +26 -19
  261. package/package.json +5 -2
  262. package/src/acting-organization.ts +6 -12
  263. package/src/api-fetch.ts +53 -3
  264. package/src/auth-flow.ts +1 -1
  265. package/src/batched-get.ts +117 -0
  266. package/src/call-service.ts +78 -3
  267. package/src/client.ts +1092 -121
  268. package/src/document-checkpoints.ts +307 -65
  269. package/src/document-commands.ts +5 -5
  270. package/src/document-contents.ts +27 -1
  271. package/src/document-set-subscription.ts +274 -0
  272. package/src/document-signals.ts +47 -5
  273. package/src/document-streams.ts +85 -16
  274. package/src/document-thumbnail-capture.ts +118 -32
  275. package/src/documents-namespace.ts +539 -84
  276. package/src/documents.ts +2017 -348
  277. package/src/errors.ts +17 -6
  278. package/src/fetch-retry.ts +5 -0
  279. package/src/file-choice.ts +26 -0
  280. package/src/guest-link.ts +362 -1
  281. package/src/handle-types.ts +73 -47
  282. package/src/handles.ts +253 -83
  283. package/src/hosted-ui.ts +211 -50
  284. package/src/import-contract.ts +227 -0
  285. package/src/import-guest.ts +193 -0
  286. package/src/import-report.ts +24 -0
  287. package/src/importer-wait.ts +4 -0
  288. package/src/importer.ts +386 -131
  289. package/src/in-development.ts +10 -0
  290. package/src/index.ts +76 -16
  291. package/src/init-keys.ts +6 -1
  292. package/src/input-documents.ts +819 -0
  293. package/src/inputs-status.ts +598 -0
  294. package/src/inputs.ts +1090 -0
  295. package/src/internal.ts +7 -0
  296. package/src/join-content.ts +135 -0
  297. package/src/join-stamp.ts +12 -0
  298. package/src/manifest-drift.ts +54 -0
  299. package/src/open-document.ts +2346 -930
  300. package/src/page-restore.ts +38 -0
  301. package/src/placement.ts +54 -0
  302. package/src/presence.ts +21 -5
  303. package/src/read-only-document.ts +133 -0
  304. package/src/record-paging.ts +147 -25
  305. package/src/references.ts +207 -0
  306. package/src/request-lane.ts +59 -0
  307. package/src/sealed-arrow.ts +843 -0
  308. package/src/sealed-cache.ts +267 -0
  309. package/src/sealed-encode.ts +1015 -0
  310. package/src/sealed-handles.ts +486 -0
  311. package/src/sealed-store.ts +1330 -0
  312. package/src/serializer.ts +8 -20
  313. package/src/session-toolbar.ts +463 -72
  314. package/src/session.ts +740 -99
  315. package/src/sha256.ts +65 -0
  316. package/src/stable-handles.ts +235 -0
  317. package/src/staged-seal.ts +90 -0
  318. package/src/subscriber-delivery.ts +37 -0
  319. package/src/tail-op-import.ts +30 -0
  320. package/src/topic-lifecycle.ts +38 -2
  321. package/src/trial-replace-dialog.ts +163 -0
  322. package/src/types.ts +6 -6
  323. package/src/undo.ts +386 -73
  324. package/src/version-bar.ts +5 -1
  325. package/src/version.ts +1 -1
  326. package/src/ws-client.ts +208 -16
  327. package/src/ws-frame-limit.ts +1 -0
package/dist/documents.js CHANGED
@@ -8,20 +8,32 @@
8
8
  // 2. The `getOwnPropertyDescriptor` trap participates in
9
9
  // `Object.keys`; doing `loroMap.keys().includes(key)` there makes
10
10
  // `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
11
+ import { INPUTS_READ_ROOT, INPUTS_REFS_ROOT } from './inputs.js';
12
+ import { importTailOp } from './tail-op-import.js';
13
+ import { InputStatusHub, } from './inputs-status.js';
14
+ import { InputFollowers, } from './input-documents.js';
11
15
  import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from './wasm-runtime.js';
12
- import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
16
+ import { NON_UNDOABLE_COMMIT_ORIGIN, PAGING_COMMIT_ORIGIN, UndoController, UndoGroup, } from './undo.js';
17
+ import { joinStampOf } from './join-stamp.js';
18
+ import { decodeInputsRoots, inputReadClearWrites, inputReadMarkFor, inputReadMarkWrites, inputRemovalMark, inputRefsWrites, inputStampFromUpdatedAt, isInputDocId, isInputKey, parseInputReference, } from './inputs.js';
19
+ import { ProvisionalInputsTracker, isReservedRootName, latestReadMark, } from './references.js';
13
20
  import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
14
- import { flattenPagedLookup, isPagedMarker, META_KEY, pagedRootKey, } from './record-paging.js';
21
+ import { appCallbackError } from './subscriber-delivery.js';
22
+ import { flattenPagedLookup, isPagedMarker, isPagedMarkerEntry, META_KEY, pagedRootKey, RECORDS_KEY, rootCarriesMarkers, } from './record-paging.js';
15
23
  import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
16
24
  import { GoneSignal, WorkStatusSignal, DriveStatusSignal, DrivePublishTargetSignal, } from './document-signals.js';
17
25
  import { ThumbnailCapture, } from './document-thumbnail-capture.js';
18
26
  import { CheckpointScheduler, } from './document-checkpoints.js';
19
27
  import { whenSaved } from './when-saved.js';
28
+ import { BrassError } from './errors.js';
20
29
  import { BranchController, } from './document-branches.js';
21
30
  import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
22
31
  import { CommandMenu, } from './document-commands.js';
23
32
  import { DocumentStream } from './document-streams.js';
24
33
  import { createEmitter } from './emitter.js';
34
+ import { CONVERSION_PEER_ID, SealedContent, } from './sealed-store.js';
35
+ import { sealedRoots } from './sealed-handles.js';
36
+ import { StableHandles } from './stable-handles.js';
25
37
  // The stream name `doc.stream()` resolves to the document's own default
26
38
  // state; mirrors the server's reserved name.
27
39
  export const DEFAULT_STREAM_NAME = 'default';
@@ -42,6 +54,24 @@ function rootKindOf(containerId) {
42
54
  }
43
55
  // Maps count entries, lists and text count length. Both are the container's
44
56
  // own counter rather than a projection of what it holds.
57
+ /** Whether a root map holds nothing but paged markers, which its handle hides,
58
+ * so the collection reads as empty. */
59
+ function holdsOnlyMarkers(rootKey, container) {
60
+ if (!rootCarriesMarkers(rootKey) || !(container instanceof LoroMap))
61
+ return false;
62
+ return container.keys().every((key) => isPagedMarkerEntry(rootKey, key));
63
+ }
64
+ // A root's projected value when the collection holds nothing: a map, list
65
+ // or text with no content.
66
+ function projectsEmpty(value) {
67
+ if (value === '')
68
+ return true;
69
+ if (Array.isArray(value))
70
+ return value.length === 0;
71
+ if (value !== null && typeof value === 'object')
72
+ return Object.keys(value).length === 0;
73
+ return false;
74
+ }
45
75
  function rootIsEmpty(container) {
46
76
  const sized = container;
47
77
  if (typeof sized.size === 'number')
@@ -65,6 +95,25 @@ const REDO_COMMIT_ORIGIN = 'brass.redo';
65
95
  // this (it reconciles by value); it backs a change's `origin.source` for
66
96
  // attribution and the SDK's own local-vs-remote side-effect gating
67
97
  // (thumbnail capture, auto-checkpoint).
98
+ // A reserved root map as plain entries, the view the write rules diff against.
99
+ function rootView(map) {
100
+ return map.toJSON();
101
+ }
102
+ // Applies one reserved root's writes per entry. Returns whether anything moved.
103
+ function applyRootWrites(map, writes) {
104
+ for (const key of writes.delete)
105
+ map.delete(key);
106
+ for (const [key, value] of Object.entries(writes.set))
107
+ map.set(key, value);
108
+ return writes.delete.length > 0 || Object.keys(writes.set).length > 0;
109
+ }
110
+ // The reserved roots hold the document's references and read marks, which
111
+ // only `dependsOn` and `markRead` write.
112
+ function assertAppRootName(key) {
113
+ if (isReservedRootName(key)) {
114
+ throw new TypeError(`"${key}" is reserved for Brass; choose another collection name`);
115
+ }
116
+ }
68
117
  function changeSourceOf(event) {
69
118
  const by = event.by;
70
119
  if (by === 'import')
@@ -125,19 +174,71 @@ const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
125
174
  // the staged upload rather than a request body, a path the write already
126
175
  // takes on its own size.
127
176
  const RESYNC_PUSH_HARD_MAX_BYTES = Math.floor((MAX_OP_BLOB_CHARS * 3) / 4);
128
- // Element-wise max of two Loro version vectors. `VersionVector` exposes
129
- // no public join, so round-trip through its `Map<PeerID, number>` JSON
130
- // form. Counters are exclusive ends, so the larger is more advanced.
131
- // Used by the durability handshake to fold the durable tail into the
132
- // snapshot's version vector.
133
- function mergeVersionVectors(a, b) {
134
- const merged = a.toJSON();
135
- for (const [peer, counter] of b.toJSON()) {
136
- const current = merged.get(peer);
137
- if (current === undefined || counter > current)
138
- merged.set(peer, counter);
139
- }
140
- return VersionVector.parseJSON(merged);
177
+ function holdRange(held, peer, from, to) {
178
+ if (to <= from)
179
+ return;
180
+ const merged = [];
181
+ let next = [from, to];
182
+ for (const range of held.get(peer) ?? []) {
183
+ if (range[1] < next[0] || range[0] > next[1]) {
184
+ merged.push(range);
185
+ }
186
+ else {
187
+ next = [Math.min(range[0], next[0]), Math.max(range[1], next[1])];
188
+ }
189
+ }
190
+ merged.push(next);
191
+ merged.sort((a, b) => a[0] - b[0]);
192
+ held.set(peer, merged);
193
+ }
194
+ // What the durable state holds: the snapshot, which is causally complete so
195
+ // every author's ops from zero, plus each tail blob's own per-author range.
196
+ // Throws on a malformed vector or blob.
197
+ function durableHoldings(persistedSnapshotVersionVector, durableTailBlobs) {
198
+ const held = new Map();
199
+ for (const [peer, to] of VersionVector.decode(persistedSnapshotVersionVector).toJSON()) {
200
+ holdRange(held, peer, 0, to);
201
+ }
202
+ for (const blob of durableTailBlobs) {
203
+ const meta = decodeImportBlobMeta(blob, false);
204
+ const start = meta.partialStartVersionVector.toJSON();
205
+ for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
206
+ holdRange(held, peer, start.get(peer) ?? 0, to);
207
+ }
208
+ }
209
+ return held;
210
+ }
211
+ // The ranges of `local` the durable state does not hold, in counter order
212
+ // per author.
213
+ function rangesMissingFrom(local, held) {
214
+ const missing = [];
215
+ for (const [peer, to] of local.toJSON()) {
216
+ let from = 0;
217
+ for (const [heldFrom, heldTo] of held.get(peer) ?? []) {
218
+ if (heldFrom >= to)
219
+ break;
220
+ if (heldFrom > from)
221
+ missing.push({ peer, from, to: heldFrom });
222
+ from = Math.max(from, heldTo);
223
+ }
224
+ if (from < to)
225
+ missing.push({ peer, from, to });
226
+ }
227
+ return missing;
228
+ }
229
+ // Group ranges so no group carries two ranges of one author. A blob's header
230
+ // names one start and one end per author, so a blob spanning two separate
231
+ // ranges of an author would read, to whoever folds it into a frontier, as
232
+ // holding the ops between them as well.
233
+ function oneRangePerAuthor(ranges) {
234
+ const groups = [];
235
+ const seen = new Map();
236
+ for (const range of ranges) {
237
+ const index = seen.get(range.peer) ?? 0;
238
+ seen.set(range.peer, index + 1);
239
+ (groups[index] ??= []).push(range);
240
+ }
241
+ return groups;
141
242
  }
142
243
  function rangeOpCount(ranges) {
143
244
  return ranges.reduce((n, r) => n + (r.to - r.from), 0);
@@ -293,9 +394,259 @@ const READ_ONLY_MESSAGES = {
293
394
  'Reopen it to keep working from the saved state.',
294
395
  no_connection: 'Document is read-only: it opened with no live connection, so a change ' +
295
396
  'would stay on this device.',
397
+ snapshot: 'Document is read-only: it was opened as a snapshot. Open it without ' +
398
+ 'asking for one to edit.',
399
+ too_large: 'Document is read-only: it is too large for Brass to make editable ' +
400
+ '(see `doc.status`).',
401
+ not_imported: 'Document is read-only: it holds an uploaded file that has not been ' +
402
+ 'imported, and no importer takes its type.',
403
+ version: 'Document is read-only: it is showing a historical version. ' +
404
+ 'Return to the latest version to edit.',
405
+ browsing: 'Document is read-only: the reader is browsing its versions. ' +
406
+ 'Close the version bar to edit.',
407
+ loading: 'Document is still loading its content; wait for the load to finish ' +
408
+ 'before editing (see `doc.onStatus`).',
409
+ reopening: 'Document is read-only while it reopens; edit the copy `reopen` resolves with.',
410
+ load_failed: 'Document is read-only: its content could not be prepared ' +
411
+ '(see `doc.status`). Reopen it to try again.',
412
+ };
413
+ // What `status` reads while this client's edits wait on a connection that has
414
+ // never opened. Pending, because Brass keeps trying and the edits save the
415
+ // moment it connects; not blocking, because editing stays open.
416
+ const NOT_SAVED_NOTICE = {
417
+ message: 'Your changes are not saved yet. Brass cannot connect to save them and ' +
418
+ 'keeps trying. Keep this page open until they are saved.',
419
+ pending: true,
420
+ blocking: false,
296
421
  };
422
+ // What `status` reads while one of this client's edits goes unanswered by a
423
+ // connection that answers its others. Pending and not blocking for the same
424
+ // reasons as `NOT_SAVED_NOTICE`.
425
+ const UNANSWERED_NOTICE = {
426
+ message: 'Some of your changes are not saved yet. Brass keeps trying to save them. ' +
427
+ 'Keep this page open until they are saved.',
428
+ pending: true,
429
+ blocking: false,
430
+ };
431
+ const SAVE_NOTICES = { never_opened: NOT_SAVED_NOTICE, unanswered: UNANSWERED_NOTICE };
432
+ // The roots a document's referenced inputs live in are written only by the
433
+ // SDK's own input writers, so a frozen root carrying one of those names
434
+ // converts to nothing.
435
+ const INPUTS_ROOTS = new Set([INPUTS_REFS_ROOT, INPUTS_READ_ROOT]);
436
+ /**
437
+ * The CRDT a frozen stream becomes: its content written through the same
438
+ * handles an app writes through, so what a conversion produces is what an app
439
+ * writing the same content would have produced, paging and all.
440
+ *
441
+ * Every side converts by calling this. It is authored by the pinned
442
+ * conversion peer (`CONVERSION_PEER_ID`) over the content in stored order, so
443
+ * one frozen stream always converts to the same document; it is NOT a licence
444
+ * for two builds to convert separately and expect to agree, because the rest
445
+ * of the output moves with the paging constants and the batching budget, which
446
+ * is why the platform converts once and stores the result.
447
+ *
448
+ * A list that is a map's key walk is that map's order, so the two are written
449
+ * as one ordered map.
450
+ *
451
+ * @internal
452
+ */
453
+ export function snapshotFromSealedContent(content) {
454
+ const names = content.rootNames().filter((n) => !INPUTS_ROOTS.has(n));
455
+ const walked = new Map();
456
+ for (const name of names) {
457
+ const map = content.keyWalkOf(name);
458
+ if (map !== null && !walked.has(map))
459
+ walked.set(map, name);
460
+ }
461
+ // A `Document` registers its root container on construction, so the root
462
+ // key is one the content writes anyway rather than an empty `entities` map
463
+ // in every snapshot.
464
+ const rootKey = [...walked.keys()][0] ??
465
+ names.find((n) => content.kindOf(n) === 'map') ??
466
+ 'entities';
467
+ const doc = Document.create({ rootKey, peerId: CONVERSION_PEER_ID });
468
+ try {
469
+ for (const name of names) {
470
+ // A reserved root is written only through `dependsOn` / `markRead`.
471
+ if (isReservedRootName(name))
472
+ continue;
473
+ const kind = content.kindOf(name);
474
+ if (kind === 'map') {
475
+ const order = walked.get(name);
476
+ const write = (draft) => {
477
+ content.forEachMapEntry(name, (key, value) => {
478
+ draft[key] = value;
479
+ });
480
+ };
481
+ if (order !== undefined)
482
+ doc.orderedMap(name, order).mutate(write);
483
+ else
484
+ doc.map(name).mutate(write);
485
+ }
486
+ else if (kind === 'list') {
487
+ const map = content.keyWalkOf(name);
488
+ if (map !== null && walked.get(map) === name)
489
+ continue;
490
+ const items = content.list(name) ?? [];
491
+ if (items.length === 0)
492
+ continue;
493
+ doc.list(name).mutate((list) => {
494
+ for (const item of items)
495
+ list.push(item);
496
+ });
497
+ }
498
+ else if (kind === 'text') {
499
+ const text = content.text(name) ?? '';
500
+ if (text !== '')
501
+ doc.text(name).update(text);
502
+ }
503
+ }
504
+ return doc._exportSnapshot();
505
+ }
506
+ finally {
507
+ doc.close();
508
+ }
509
+ }
510
+ /**
511
+ * A CRDT snapshot's content as JSON, read the way an app reads it (a paged
512
+ * root flattened to its records), holding exactly the roots the snapshot
513
+ * holds. A Document registers its root map on construction, so it is opened
514
+ * on a root the snapshot already has rather than on the default.
515
+ * @internal
516
+ */
517
+ export function snapshotContentJSON(snapshot) {
518
+ // One engine, and the roots named from its shallow value: a whole-engine
519
+ // `toJSON` here would hold a second copy of the content beside the one the
520
+ // read below builds.
521
+ const engine = new LoroDoc();
522
+ engine.import(snapshot);
523
+ const shallow = engine.getShallowValue();
524
+ const held = Object.keys(shallow);
525
+ const rootKey = held.find((k) => {
526
+ if (isReservedRootName(k))
527
+ return false;
528
+ const id = shallow[k];
529
+ return typeof id === 'string' && id.endsWith(':Map');
530
+ });
531
+ const doc = Document._fromEngine(engine, rootKey !== undefined ? { rootKey } : undefined);
532
+ try {
533
+ const json = doc.toJSON();
534
+ for (const key of Object.keys(json))
535
+ if (!held.includes(key))
536
+ delete json[key];
537
+ return json;
538
+ }
539
+ finally {
540
+ doc.close();
541
+ }
542
+ }
543
+ /** The CRDT a stored record store becomes. The same conversion as
544
+ * `snapshotFromSealedContent`, reached from the record-store view.
545
+ * @internal */
546
+ /** A whole CRDT document as plain JSON, its paged roots as records. */
547
+ function engineJSON(source) {
548
+ const json = source.toJSON();
549
+ // The document's references and read marks are the platform's, read
550
+ // through `dependsOn` and the input status rather than as content.
551
+ for (const key of Object.keys(json))
552
+ if (isReservedRootName(key))
553
+ delete json[key];
554
+ const meta = json[META_KEY];
555
+ if (isRootLookup(meta)) {
556
+ for (const key of Object.keys(meta)) {
557
+ if (isPagedMarkerEntry(META_KEY, key))
558
+ delete meta[key];
559
+ }
560
+ }
561
+ // A paged root holds its records inside page entries beside the store's
562
+ // own edit bookkeeping, so flatten it to the same record lookup
563
+ // `doc.map(root).read()` hands back: a serializer and a whole-document
564
+ // renderer read this surface, and a store that crossed the paging
565
+ // threshold has to reach them as records. Every root is asked, because a
566
+ // root no handle was ever taken on is paged by the same rule as one that
567
+ // was; a root the marker does not name comes back as loro produced it.
568
+ for (const [rootKey, value] of Object.entries(json)) {
569
+ if (!isRootLookup(value))
570
+ continue;
571
+ if (!readRootPaged(source, rootKey))
572
+ continue;
573
+ json[rootKey] = flattenPagedLookup(value, true);
574
+ }
575
+ return json;
576
+ }
577
+ /** A CRDT engine holding decoded join content. */
578
+ function engineFrom(content) {
579
+ const doc = new LoroDoc();
580
+ if (content.kind === 'crdt')
581
+ doc.import(content.bytes);
582
+ else if (content.kind === 'frozen')
583
+ doc.import(snapshotFromSealedContent(content.content));
584
+ return doc;
585
+ }
586
+ /**
587
+ * A version of a Document over frozen content, as the content its handles read.
588
+ *
589
+ * Frozen content with nothing above it is the version itself. Anything else
590
+ * (a version stored as a CRDT, or a tail over the seal) is materialised and
591
+ * read back as content: a preview takes no write, so the engine it needs is
592
+ * built for the read and freed after it.
593
+ */
594
+ function frozenPreview(content, tailOpBlobs) {
595
+ if (content.kind === 'frozen' && tailOpBlobs.length === 0)
596
+ return content.content;
597
+ const engine = engineFrom(content);
598
+ try {
599
+ for (const blob of tailOpBlobs)
600
+ importTailOp(engine, blob);
601
+ return SealedContent.fromJSON(engineJSON(engine));
602
+ }
603
+ finally {
604
+ engine.free();
605
+ }
606
+ }
607
+ export function snapshotFromSealedStore(store) {
608
+ return snapshotFromSealedContent(store.content);
609
+ }
610
+ /**
611
+ * Write a record store as a CRDT snapshot through the one conversion every
612
+ * frozen stream takes.
613
+ */
614
+ export function recordStoreSnapshot(source) {
615
+ const records = [];
616
+ source.forEachRecord((key, tag, body) => {
617
+ records.push([key, tag, body]);
618
+ });
619
+ return snapshotFromSealedContent(SealedContent.fromRecords({
620
+ schemaVersion: source.schemaVersion,
621
+ header: source.header,
622
+ records,
623
+ }));
624
+ }
297
625
  export class Document {
298
- _doc;
626
+ _importReport = null;
627
+ _changed = false;
628
+ _changeNotice = null;
629
+ _stopChangeNotice = null;
630
+ _changedListeners = new Set();
631
+ // A frozen copy's reload, wired by the transport: re-joins and applies the
632
+ // result in place. `null` on every other open.
633
+ _frozenReloader = null;
634
+ // Opens this document again in another mode. Installed by whichever layer
635
+ // opened it: the session for the document it holds, the client otherwise.
636
+ _reopener = null;
637
+ _reloading = null;
638
+ // `onStatus` listeners on a frozen copy, which hears status on the same
639
+ // notice the change report rides.
640
+ _statusWatches = 0;
641
+ _inputsRuntime = null;
642
+ // Built on the first status subscription.
643
+ _inputStatusHub = null;
644
+ // The `subscribeInputDocuments` followers, which close and park stop so no
645
+ // retry, handler or kept copy outlives the document.
646
+ _inputFollowers = new InputFollowers();
647
+ // The CRDT engine. `null` until something reads it: a Document over frozen
648
+ // content never does, so it holds no engine at all.
649
+ _engine;
299
650
  _rootKey;
300
651
  // Server-minted `doc_id`, or `null` when constructed without one
301
652
  // (test / offline path).
@@ -324,6 +675,32 @@ export class Document {
324
675
  // and `returnToHead` reject. The live + checkout-on-demand path
325
676
  // leaves this `false`.
326
677
  _isFrozen;
678
+ // The frozen content this Document reads, when the platform holds its
679
+ // content in that encoding. Its roots serve `map` / `list` / `orderedMap`
680
+ // and `toJSON` in place of the CRDT's, so an app reads one document
681
+ // surface whichever way the content is stored. `null` for a CRDT document.
682
+ _sealedRoots;
683
+ // The frozen content at the head, and the content the roots read now: the
684
+ // head, or a version a checkout swapped in. `null` with `_sealedRoots`.
685
+ _frozenHead;
686
+ _frozenShown;
687
+ // The version a Document over frozen content is showing, or `null` for its
688
+ // head.
689
+ _frozenVersion = null;
690
+ // The seq the frozen content was sealed at, which is what says whether a
691
+ // later join's frozen content is the same content.
692
+ _frozenSeal;
693
+ // What `map` / `list` / `text` / `orderedMap` hand out: one handle per
694
+ // root, over whichever storage this Document holds now.
695
+ _handles = new StableHandles((err) => this._handleOwner.reportSubscriberThrow(err));
696
+ // What an engine built for this Document after construction is built with:
697
+ // the root a CRDT open would root `mutate` at, and the caller's engine
698
+ // options.
699
+ _engineRootKey;
700
+ _engineOptions;
701
+ // Swaps this Document's frozen content for the CRDT the server now holds,
702
+ // wired by the transport. Absent, a remote op over frozen content reloads.
703
+ _storageUpgrader = null;
327
704
  // Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
328
705
  // is a version-pinned open with no live channel): the Document keeps a
329
706
  // live WS subscription and applies incoming remote ops, but local
@@ -332,6 +709,9 @@ export class Document {
332
709
  // grant, a view-only link), and latched on afterwards by
333
710
  // `_markReadOnly` when the server refuses a write on the same grounds.
334
711
  _forcedReadOnly;
712
+ // Set while `reopen` replaces this copy (see the `reopening` reason).
713
+ _reopening = false;
714
+ _pendingReopen = null;
335
715
  // Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
336
716
  _readOnlyReason;
337
717
  // The UndoManager is constructed eagerly so the first call to
@@ -342,6 +722,10 @@ export class Document {
342
722
  // only; it does NOT fire on snapshot import, so the
343
723
  // cold-load contract is unaffected (imports are not local commits).
344
724
  _undo;
725
+ // The document's one undo stack across its streams. A Document owns the
726
+ // group it was created with; a stream's own Document joins its document's
727
+ // group when the stream opens (`_joinUndoGroup`).
728
+ _undoGroup = new UndoGroup();
345
729
  // One subscriber channel. Every event reaches every subscriber, whatever
346
730
  // produced it (local mutate, remote import, undo, redo, checkout); the
347
731
  // app reconciles by value and reads `origin.source` only when it has a
@@ -359,6 +743,11 @@ export class Document {
359
743
  _undoStateSubs = new Set();
360
744
  _lastCanUndo = false;
361
745
  _lastCanRedo = false;
746
+ _replayingUndo = false;
747
+ // The change a replay produced, held until the replay ends so `subscribe`
748
+ // fires once per `undo()` / `redo()`. A listener that writes in reaction
749
+ // would otherwise commit mid-replay, joining the step being replayed.
750
+ _replayChange = null;
362
751
  _unsubLoro = null;
363
752
  _unsubLocal = null;
364
753
  // Local commits drive this hook with the resulting op blob; the hook
@@ -400,22 +789,30 @@ export class Document {
400
789
  // other half of `readOnly` and must NOT gate this, because a document
401
790
  // still hydrating is exactly the one the seed watch is armed for, and
402
791
  // the arm happens while that lock is still on.
403
- isCaptureAllowed: () => this._isLive && this._checkout === null && !this._forcedReadOnly,
792
+ isCaptureAllowed: () => this._isLive && this._previewVersion === null && !this._forcedReadOnly,
404
793
  isAttached: () => this._isLive,
405
- onCaptured: () => {
406
- this._thumbnailVersion = this.currentSeq ?? 0;
794
+ currentVersion: () => this.currentSeq,
795
+ onCaptured: (version) => {
796
+ this._thumbnailVersion = version ?? this.currentSeq ?? 0;
407
797
  },
408
798
  });
409
799
  _checkpoints = new CheckpointScheduler({
410
800
  docId: () => this._docId,
411
801
  isAttached: () => this._isLive,
412
- isAutoAllowed: () => this._isLive && this._checkout === null,
802
+ isAutoAllowed: () => this._isLive && this._previewVersion === null,
413
803
  currentSeq: () => this.currentSeq,
414
804
  sealUndoStep: () => {
415
- if (this._undo)
416
- this._undo.sealStep();
805
+ this._undoGroup.seal();
417
806
  },
807
+ hasUnsavedOps: () => this.savingCount > 0,
418
808
  whenLocalOpsSaved: () => whenSaved(this),
809
+ captureFrame: () => this._thumbnails.captureFrame(),
810
+ uploadFrame: (frame, version) => {
811
+ // The debounced capture usually lands first, at the same version.
812
+ if (this._thumbnailVersion === version)
813
+ return;
814
+ void this._thumbnails.uploadFrame(frame, version);
815
+ },
419
816
  });
420
817
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
421
818
  // then throws with a clear message rather than silently dropping.
@@ -433,6 +830,9 @@ export class Document {
433
830
  _sourceBlobFetcher = null;
434
831
  // Cached doc name. `null` on offline / test docs.
435
832
  _name = null;
833
+ // When the platform will remove this document, for one created without an
834
+ // account; `null` for every other document.
835
+ _expiresAt = null;
436
836
  // True once the reader has asked for the version-browsing surface without
437
837
  // checking anything out (the hosted history dialog's browse-in-the-app
438
838
  // outcome). The host's version bar latches on this as well as on a live
@@ -548,8 +948,20 @@ export class Document {
548
948
  // Owning construction is what lets this class declare an engine-neutral
549
949
  // surface: app code holds a Document, never the doc underneath it.
550
950
  constructor(loroDoc, options) {
551
- this._doc = loroDoc;
951
+ this._undoGroup.setOwner(this, {
952
+ blocked: () => this._undoGroupBlocked(),
953
+ notify: () => this._maybeFireUndoState(),
954
+ // A write to another stream is an edit like one here, so it arms the
955
+ // same idle checkpoint, which is what seals the step it opened.
956
+ memberCommitted: () => this._checkpoints.scheduleAuto(),
957
+ });
958
+ this._engine = loroDoc;
552
959
  this._rootKey = options?.rootKey ?? 'entities';
960
+ this._engineRootKey = this._rootKey;
961
+ this._engineOptions = {
962
+ ...(options?.peerId !== undefined ? { peerId: options.peerId } : {}),
963
+ ...(options?.undo !== undefined ? { undo: options.undo } : {}),
964
+ };
553
965
  this._docId = options?.docId ?? null;
554
966
  this._branchId = options?.branchId ?? null;
555
967
  // A Document constructed with a `version` is a "frozen" cold-link
@@ -557,14 +969,21 @@ export class Document {
557
969
  // `loroDoc` and there is no live WS channel. The Document is
558
970
  // permanently in checkout against itself; `returnToHead` /
559
971
  // `_checkoutVersion` are not meaningful on this shape.
972
+ const frozen = options?.frozenContent;
560
973
  const initialVersion = options?.version ?? null;
561
974
  if (initialVersion !== null) {
562
975
  this._isFrozen = true;
563
- this._checkout = {
564
- previewDoc: loroDoc,
565
- previewRoot: loroDoc.getMap(this._rootKey),
566
- version: initialVersion,
567
- };
976
+ if (loroDoc !== null) {
977
+ this._checkout = {
978
+ previewDoc: loroDoc,
979
+ previewRoot: loroDoc.getMap(this._rootKey),
980
+ version: initialVersion,
981
+ };
982
+ }
983
+ else {
984
+ this._checkout = null;
985
+ this._frozenVersion = initialVersion;
986
+ }
568
987
  }
569
988
  else {
570
989
  this._isFrozen = false;
@@ -574,12 +993,57 @@ export class Document {
574
993
  // reader the server will not accept writes from, whether because their
575
994
  // grant is view-only or because the link they came through is. A
576
995
  // version open is already frozen, so it does not also need the flag.
996
+ // A stored record set answers every read directly, and takes no write: a
997
+ // record set holds no op log for an edit to become, and making one is the
998
+ // platform's (see `open-document.ts`, where the join serves a record set
999
+ // only to a reader). So an app reads one document surface and never
1000
+ // learns which encoding it was handed, and the write it cannot make is
1001
+ // refused by the same read-only gate every other unwritable open uses.
1002
+ this._frozenHead = frozen ?? null;
1003
+ this._frozenShown = frozen ?? null;
1004
+ this._frozenSeal = options?.frozenSeal ?? null;
1005
+ this._sealedRoots =
1006
+ frozen === undefined
1007
+ ? null
1008
+ : sealedRoots(() => this._frozenShown, () => this._handleOwner.assertWritable());
577
1009
  this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
578
1010
  this._readOnlyReason = options?.readOnlyReason ?? 'link';
579
- // Materialize the root container, so a document nothing has written to
580
- // still carries its root in the shape a reader opens it with.
581
- loroDoc.getMap(this._rootKey);
582
- this._undo = new UndoController(this._doc, options?.undo);
1011
+ if (loroDoc !== null) {
1012
+ if (options?.peerId !== undefined)
1013
+ loroDoc.setPeerId(options.peerId);
1014
+ // Materialize the root container, so a document nothing has written to
1015
+ // still carries its root in the shape a reader opens it with.
1016
+ loroDoc.getMap(this._rootKey);
1017
+ this._undo = this._newUndoController(loroDoc, options?.undo);
1018
+ }
1019
+ else {
1020
+ this._undo = null;
1021
+ }
1022
+ }
1023
+ // The engine, built on first use for a Document constructed without one.
1024
+ get _doc() {
1025
+ if (this._engine === null) {
1026
+ this._engine = new LoroDoc();
1027
+ this._engine.getMap(this._rootKey);
1028
+ }
1029
+ return this._engine;
1030
+ }
1031
+ /** Whether this Document has been closed. @internal */
1032
+ get _isClosed() {
1033
+ return this._closed;
1034
+ }
1035
+ /** Whether this Document has built a CRDT engine. @internal */
1036
+ _holdsEngine() {
1037
+ return this._engine !== null;
1038
+ }
1039
+ /** The seq this Document's frozen content was sealed at, or `null` for a
1040
+ * CRDT document or a seal the join did not name. @internal */
1041
+ get _frozenSealSeq() {
1042
+ return this._frozenSeal;
1043
+ }
1044
+ // The version this Document is showing instead of its head, or `null`.
1045
+ get _previewVersion() {
1046
+ return this._checkout?.version ?? this._frozenVersion;
583
1047
  }
584
1048
  // ---- Typed container handles (the primary read/write/subscribe surface) --
585
1049
  //
@@ -604,16 +1068,30 @@ export class Document {
604
1068
  get _pagingSessionToken() {
605
1069
  return this._doc.peerIdStr;
606
1070
  }
1071
+ /** Whether this document's reads are answered by a stored record set.
1072
+ * @internal */
1073
+ _holdsRecordSet() {
1074
+ return this._sealedRoots !== null;
1075
+ }
607
1076
  // Get a handle for a top-level LoroMap at `key`. The container is
608
1077
  // materialized in the underlying LoroDoc immediately (Loro's
609
1078
  // `getMap` is "get or create"); mutations through the handle do
610
1079
  // not need to create the container themselves.
611
1080
  map(key) {
612
1081
  this._assertOpen();
1082
+ assertAppRootName(key);
1083
+ return this._handles.root(`map:${key}`, 'map', () => this._rootMap(key));
1084
+ }
1085
+ _rootMap(key) {
613
1086
  const cacheKey = `map:${key}`;
614
1087
  const existing = this._rootHandles.get(cacheKey);
615
1088
  if (existing)
616
1089
  return existing.handle;
1090
+ if (this._sealedRoots !== null) {
1091
+ const handle = this._sealedRoots.map(key);
1092
+ this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
1093
+ return handle;
1094
+ }
617
1095
  // Source the LoroMap from the active source (live or checkout
618
1096
  // preview), same as `toJSON` does. Handles bound to a Document
619
1097
  // in checkout see the historical state via the preview doc.
@@ -642,17 +1120,26 @@ export class Document {
642
1120
  },
643
1121
  sessionToken: () => this._pagingSessionToken,
644
1122
  stateVersion: () => activeSource().frontiers(),
645
- });
1123
+ }, key);
646
1124
  this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
647
1125
  return handle;
648
1126
  }
649
1127
  // Get a handle for a top-level LoroMovableList at `key`.
650
1128
  list(key) {
651
1129
  this._assertOpen();
1130
+ assertAppRootName(key);
1131
+ return this._handles.root(`list:${key}`, 'list', () => this._rootList(key));
1132
+ }
1133
+ _rootList(key) {
652
1134
  const cacheKey = `list:${key}`;
653
1135
  const existing = this._rootHandles.get(cacheKey);
654
1136
  if (existing)
655
1137
  return existing.handle;
1138
+ if (this._sealedRoots !== null) {
1139
+ const handle = this._sealedRoots.list(key);
1140
+ this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
1141
+ return handle;
1142
+ }
656
1143
  const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
657
1144
  const loroList = source.getMovableList(key);
658
1145
  const handle = createRootListHandle(loroList, this._handleOwner);
@@ -678,10 +1165,16 @@ export class Document {
678
1165
  */
679
1166
  text(key) {
680
1167
  this._assertOpen();
1168
+ assertAppRootName(key);
1169
+ return this._handles.root(`text:${key}`, 'text', () => this._rootText(key));
1170
+ }
1171
+ _rootText(key) {
681
1172
  const cacheKey = `text:${key}`;
682
1173
  const existing = this._rootHandles.get(cacheKey);
683
1174
  if (existing)
684
1175
  return existing.handle;
1176
+ if (this._sealedRoots !== null)
1177
+ return this._sealedRoots.text(key);
685
1178
  const owner = this._handleOwner;
686
1179
  const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
687
1180
  // Reads and writes resolve the active source per call, so a checkout
@@ -790,10 +1283,20 @@ export class Document {
790
1283
  }
791
1284
  orderedMap(mapKey, orderKey) {
792
1285
  this._assertOpen();
1286
+ return this._handles.root(`ordered:${mapKey}\u0000${orderKey}`, 'map', () => this._rootOrderedMap(mapKey, orderKey));
1287
+ }
1288
+ _rootOrderedMap(mapKey, orderKey) {
793
1289
  const cacheKey = `${mapKey}\u0000${orderKey}`;
794
1290
  let h = this._orderedMapHandles.get(cacheKey);
1291
+ if (!h && this._sealedRoots !== null) {
1292
+ // Taken from the store rather than composed from this document's own
1293
+ // map + list handles: the composition exists to keep a map and its
1294
+ // order list in step through writes, and a stored record set has none.
1295
+ h = this._sealedRoots.orderedMap(mapKey, orderKey);
1296
+ this._orderedMapHandles.set(cacheKey, h);
1297
+ }
795
1298
  if (!h) {
796
- h = createOrderedMapHandle(this.map(mapKey), this.list(orderKey), this._handleOwner);
1299
+ h = createOrderedMapHandle(this._rootMap(mapKey), this._rootList(orderKey), this._handleOwner);
797
1300
  this._orderedMapHandles.set(cacheKey, h);
798
1301
  }
799
1302
  return h;
@@ -813,34 +1316,24 @@ export class Document {
813
1316
  },
814
1317
  assertWritable: () => {
815
1318
  this._assertOpen();
816
- if (this._checkout !== null) {
817
- throw new Error('Document is read-only: it is showing a historical version. ' +
818
- 'Call `doc.returnToHead()` to edit.');
819
- }
820
- if (this._versionBrowsing) {
821
- throw new Error('Document is read-only: the reader is browsing its versions. ' +
822
- 'Close the version bar to edit.');
823
- }
824
- if (this._forcedReadOnly) {
825
- throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
826
- }
827
- if (this._loadFailed) {
828
- throw new Error('Document is read-only: its content could not be prepared ' +
829
- '(see `doc.status`). Reopen it to try again.');
830
- }
831
- if (this._isLoadLocked()) {
832
- throw new Error('Document is still loading its content; wait for the load to ' +
833
- 'finish before editing (see `doc.onStatus`).');
834
- }
1319
+ const reason = this._readOnlyBecause;
1320
+ if (reason !== null)
1321
+ throw new Error(READ_ONLY_MESSAGES[reason]);
835
1322
  },
836
1323
  ensureLocalUpdatesSubscribed: () => {
837
1324
  this._ensureLocalUpdatesSubscribed();
838
1325
  },
839
1326
  commitMutate: () => {
840
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
1327
+ this._doc.setNextCommitOrigin(this._mutateCommitOrigin);
841
1328
  this._doc.commit();
842
1329
  },
1330
+ runMutate: (options, fn) => {
1331
+ this._runInMutate(options?.undoable !== false, fn);
1332
+ },
843
1333
  originOf: (event) => changeSourceOf(event),
1334
+ reportSubscriberThrow: (err) => {
1335
+ this._reportSubscriberThrow(err);
1336
+ },
844
1337
  };
845
1338
  return this._handleOwnerCache;
846
1339
  }
@@ -869,26 +1362,49 @@ export class Document {
869
1362
  // and follows a grant the server withdraws mid-session as well as a
870
1363
  // checkout the reader entered.
871
1364
  get readOnly() {
872
- // A doc that is still loading its first content (a drive-linked
873
- // stub hydrating on first open) is read-only until the bytes
874
- // arrive: editing it would write ops onto an empty doc that the
875
- // incoming snapshot is about to replace. The SDK reloads the
876
- // content automatically when the work finishes (see open-document's
877
- // `reloadAfterHydration`); until then mutation is rejected. This is
878
- // also what stops an app's "seed an empty doc" path from clobbering
879
- // a doc that only looks empty because it has not loaded yet.
880
- // Browsing versions locks the document at the live head as well as at an
881
- // earlier one. Stepping moves between the points history KEEPS, and an
882
- // edit at the head makes no such point, so an edit made under the open
883
- // bar puts the reader's own newest work somewhere the bar cannot travel
884
- // to: Back then steps past it to the newest marker, and the way forward
885
- // is a different number of presses from the way back. Reading history
886
- // and writing it are separate modes, and the bar carries the Close that
887
- // leaves this one.
888
- return (this._forcedReadOnly ||
889
- this._checkout !== null ||
890
- this._versionBrowsing ||
891
- this._isLoadLocked());
1365
+ return this._readOnlyBecause !== null;
1366
+ }
1367
+ /**
1368
+ * The refusal message when this reader's access, a view-only link, or this
1369
+ * app's standing keeps them from writing however the document is opened,
1370
+ * else null. `session.edit()` rejects with it rather than reopening.
1371
+ * @internal
1372
+ */
1373
+ get _editRefusal() {
1374
+ const reason = this._forcedReadOnly ? this._readOnlyReason : null;
1375
+ if (reason === 'grant' || reason === 'link' || reason === 'app') {
1376
+ return READ_ONLY_MESSAGES[reason];
1377
+ }
1378
+ return null;
1379
+ }
1380
+ /**
1381
+ * Why this reader cannot write, or null when they can.
1382
+ *
1383
+ * The one place the conditions are listed. `readOnly` asks whether there is
1384
+ * a reason and `mutate` refuses with the reason's own message, so the two
1385
+ * cannot answer differently about the same document, and a new reason
1386
+ * reaches both by being added here.
1387
+ *
1388
+ * Order decides which message a reader sees when several hold at once, and
1389
+ * it runs from the state they are in to the state they were put in: a
1390
+ * checkout and an open version bar are things this reader did and can
1391
+ * undo, so naming those first tells them what to do. A load that has not
1392
+ * finished comes last because it clears on its own.
1393
+ */
1394
+ get _readOnlyBecause() {
1395
+ if (this._previewVersion !== null)
1396
+ return 'version';
1397
+ if (this._versionBrowsing)
1398
+ return 'browsing';
1399
+ if (this._forcedReadOnly)
1400
+ return this._readOnlyReason;
1401
+ if (this._reopening)
1402
+ return 'reopening';
1403
+ if (this._loadFailed)
1404
+ return 'load_failed';
1405
+ if (this._isLoadLocked())
1406
+ return 'loading';
1407
+ return null;
892
1408
  }
893
1409
  // True when content-replacing work (hydration / extraction) is pending
894
1410
  // and editing must be locked so incoming bytes do not clobber local
@@ -903,7 +1419,7 @@ export class Document {
903
1419
  _isLoadLocked() {
904
1420
  if (this._loadFailed)
905
1421
  return true;
906
- const s = this._workStatus.status;
1422
+ const s = this._workStatus.work;
907
1423
  return s?.pending === true && s.blocking !== false;
908
1424
  }
909
1425
  /**
@@ -929,8 +1445,8 @@ export class Document {
929
1445
  * `doc.readOnly` (true for a preview, a view-only link, or a load lock).
930
1446
  * @internal
931
1447
  */
932
- get version() {
933
- return this._checkout?.version ?? null;
1448
+ get previewVersion() {
1449
+ return this._previewVersion;
934
1450
  }
935
1451
  /** @internal Whether the host should be showing its version-browsing
936
1452
  * surface for this document. True from a browse-in-the-app outcome and
@@ -955,8 +1471,9 @@ export class Document {
955
1471
  * upload pins to it); no app-facing operation takes a seq, since version
956
1472
  * history is hosted chrome the app does not drive with numbers. */
957
1473
  get currentSeq() {
958
- if (this._checkout !== null)
959
- return this._checkout.version;
1474
+ const previewing = this._previewVersion;
1475
+ if (previewing !== null)
1476
+ return previewing;
960
1477
  if (this._currentSeqProvider)
961
1478
  return this._currentSeqProvider();
962
1479
  return null;
@@ -972,6 +1489,36 @@ export class Document {
972
1489
  loroDoc.import(snapshot);
973
1490
  return new Document(loroDoc, options);
974
1491
  }
1492
+ /**
1493
+ * Open a Document over frozen content: the document's content as it was
1494
+ * sealed, read with no CRDT engine.
1495
+ *
1496
+ * No CRDT engine is built: every root answers from the content, and the
1497
+ * machinery a Document carries regardless (its id, its subscriber channel,
1498
+ * its close path) runs without one.
1499
+ * @internal
1500
+ */
1501
+ static openFromSealedContent(content, options) {
1502
+ // Rooted at a map the content holds, so a document reading frozen content
1503
+ // registers no empty root of its own.
1504
+ //
1505
+ // Read-only is stated here rather than left to the caller, because it is
1506
+ // a property of the bytes: frozen content holds no op log, so there is
1507
+ // nothing an edit could be written into, and making one is the platform's
1508
+ // (`runRecordSetConversion`). Stating it at the factory is what lets the
1509
+ // refusal come from the document's own gate, which names the reader's
1510
+ // actual limit, instead of from each handle describing the encoding.
1511
+ const rootKey = content.rootNames().find((n) => content.kindOf(n) === 'map') ?? RECORDS_KEY;
1512
+ const doc = new Document(null, {
1513
+ ...options,
1514
+ readOnly: true,
1515
+ readOnlyReason: options?.readOnlyReason ?? 'snapshot',
1516
+ rootKey,
1517
+ frozenContent: content,
1518
+ });
1519
+ doc._engineRootKey = options?.rootKey ?? 'entities';
1520
+ return doc;
1521
+ }
975
1522
  /**
976
1523
  * Construct an empty Document (empty-snapshot join case, and tests).
977
1524
  * @internal
@@ -1010,7 +1557,17 @@ export class Document {
1010
1557
  // subscribers.
1011
1558
  return;
1012
1559
  }
1013
- this._doc.import(opBlob);
1560
+ // An op exists, so the document is now a CRDT and somebody has edited it.
1561
+ // The op is authored against that CRDT, so imported over frozen content it
1562
+ // would land on an empty LoroDoc and sit causally incomplete. The storage
1563
+ // is upgraded instead: the transport re-joins, and the join's CRDT
1564
+ // replaces the frozen content under the same handles (`_upgradeStorage`).
1565
+ // The op is in what that join serves.
1566
+ if (this._sealedRoots !== null) {
1567
+ this._requestStorageUpgrade();
1568
+ return;
1569
+ }
1570
+ importTailOp(this._doc, opBlob);
1014
1571
  }
1015
1572
  // Apply a snapshot blob over the top of an existing Document.
1016
1573
  // Internal: the SDK calls this when the server delivers a fresh
@@ -1027,6 +1584,13 @@ export class Document {
1027
1584
  // arrives and the Loro subscriber cannot be relied on to report it: it is
1028
1585
  // attached lazily, so a document whose content lands before the session has
1029
1586
  // bound its canvas has nobody subscribed and the import fires nothing.
1587
+ // CRDT bytes over frozen content are a storage upgrade, which keeps the
1588
+ // app's handles (see `_upgradeStorage`).
1589
+ if (this._sealedRoots !== null) {
1590
+ if (!this._upgradeStorage(snapshot, []))
1591
+ this._reopen();
1592
+ return;
1593
+ }
1030
1594
  this._armSeedIfPictureIsStale();
1031
1595
  this._doc.import(snapshot);
1032
1596
  // The undo stack pre-snapshot referenced ops the new snapshot
@@ -1037,7 +1601,113 @@ export class Document {
1037
1601
  if (this._undo)
1038
1602
  this._undo.clear();
1039
1603
  }
1040
- // Revert the most recent local commit. Returns `true` if an undo
1604
+ /** @internal Wire the re-join that upgrades this Document's frozen content
1605
+ * to the CRDT the server now holds. */
1606
+ _setStorageUpgrader(upgrade) {
1607
+ this._storageUpgrader = upgrade;
1608
+ }
1609
+ _requestStorageUpgrade() {
1610
+ const upgrade = this._storageUpgrader;
1611
+ if (upgrade === null) {
1612
+ this._reopen();
1613
+ return;
1614
+ }
1615
+ upgrade();
1616
+ }
1617
+ /**
1618
+ * Swap this Document's storage from frozen content to a CRDT, in place.
1619
+ * @internal
1620
+ *
1621
+ * Every handle an app took over the frozen content keeps working, now over
1622
+ * the CRDT, and its subscribers move there as a whole-container resync (keys
1623
+ * the CRDT does not hold arrive as `deleted`, and `onSettled` fires again).
1624
+ * Read-only state is unchanged, because the reader's intent is.
1625
+ *
1626
+ * Returns false, leaving the frozen content in place, when the Document is
1627
+ * showing a version of the frozen content: the preview has no CRDT
1628
+ * counterpart to rebind to, so the caller reloads instead.
1629
+ */
1630
+ _upgradeStorage(snapshot, tailOps) {
1631
+ if (this._closed)
1632
+ return false;
1633
+ if (this._sealedRoots === null)
1634
+ return true;
1635
+ if (this._frozenVersion !== null)
1636
+ return false;
1637
+ const engine = new LoroDoc();
1638
+ if (snapshot !== null)
1639
+ engine.import(snapshot);
1640
+ for (const op of tailOps)
1641
+ importTailOp(engine, op);
1642
+ if (this._engineOptions.peerId !== undefined)
1643
+ engine.setPeerId(this._engineOptions.peerId);
1644
+ this._handles.swap(() => {
1645
+ this._rootKey = this._engineRootKey;
1646
+ engine.getMap(this._rootKey);
1647
+ this._engine = engine;
1648
+ this._undo = this._newUndoController(engine, this._engineOptions.undo);
1649
+ this._sealedRoots = null;
1650
+ this._frozenHead = null;
1651
+ this._frozenShown = null;
1652
+ this._frozenSeal = null;
1653
+ this._rootHandles.clear();
1654
+ this._orderedMapHandles.clear();
1655
+ });
1656
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
1657
+ return true;
1658
+ }
1659
+ /**
1660
+ * Swap this Document's storage from a CRDT to frozen content, in place: the
1661
+ * counterpart of `_upgradeStorage`, for a frozen copy whose reload is served
1662
+ * the line as stored after the copy was opened on a CRDT.
1663
+ * @internal
1664
+ *
1665
+ * Returns false, leaving the CRDT in place, when the Document is showing a
1666
+ * version: the preview has no frozen counterpart, so the caller reloads.
1667
+ */
1668
+ _downgradeStorage(content, seal) {
1669
+ if (this._closed || this._sealedRoots !== null || this._checkout !== null)
1670
+ return false;
1671
+ this._handles.swap(() => {
1672
+ this._frozenHead = content;
1673
+ this._frozenShown = content;
1674
+ this._frozenSeal = seal;
1675
+ this._sealedRoots = sealedRoots(() => this._frozenShown, () => this._handleOwner.assertWritable());
1676
+ this._rootHandles.clear();
1677
+ this._orderedMapHandles.clear();
1678
+ // Nothing reads the engine from here on. It is dropped rather than
1679
+ // freed, because a watcher an input follower attached to it may still
1680
+ // detach.
1681
+ this._unsubLoro?.();
1682
+ this._unsubLoro = null;
1683
+ this._unsubLocal?.();
1684
+ this._unsubLocal = null;
1685
+ this._undo?.free();
1686
+ this._undo = null;
1687
+ this._engine = null;
1688
+ });
1689
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
1690
+ return true;
1691
+ }
1692
+ /**
1693
+ * Replace this Document's frozen content with other frozen content, in
1694
+ * place, re-delivering every handle's subscribers. A join serves a newer
1695
+ * seal when the document was edited and frozen again in between.
1696
+ * @internal
1697
+ */
1698
+ _replaceSealedContent(content, seal) {
1699
+ if (this._closed || this._sealedRoots === null)
1700
+ return false;
1701
+ if (this._frozenVersion !== null)
1702
+ return false;
1703
+ this._frozenHead = content;
1704
+ this._frozenShown = content;
1705
+ this._frozenSeal = seal;
1706
+ this._sealedRoots.resync();
1707
+ this._fireSubscribers({ kind: 'change', source: 'remote' });
1708
+ return true;
1709
+ }
1710
+ // Revert the most recent local step. Returns `true` if an undo
1041
1711
  // step was applied, `false` if the stack is empty. Remote peers'
1042
1712
  // edits are not in this stack: Loro's UndoManager is local-peer-
1043
1713
  // scoped, which is the user-visible behaviour every collaborative
@@ -1046,25 +1716,18 @@ export class Document {
1046
1716
  // The reverted change is itself a local commit, so it ships over
1047
1717
  // the WS transport like any other mutation and other peers see the
1048
1718
  // state revert.
1719
+ //
1720
+ // The stack is the document's, across its streams: a step is every local
1721
+ // commit between two seals in whichever streams took one, and one `undo()`
1722
+ // reverts it in each of them. A stream that refuses writes takes no part.
1049
1723
  undo() {
1050
1724
  this._assertOpen();
1051
- // During a checkout the UndoManager still tracks the live head, but reads
1052
- // go through the preview. Undoing would commit against the live `_doc` and
1053
- // ship over the WS transport while the previewing user sees nothing, a
1054
- // silent live edit. Match `canUndo()` (false during a checkout) and no-op.
1055
- if (this._checkout !== null)
1056
- return false;
1057
- // An undo produces a local commit exactly as `mutate` does, so it obeys
1058
- // the same read-only gates: a view-only guest link (`_forcedReadOnly`)
1059
- // and a pending content-replacing work-status (`_isLoadLocked()`), whose
1060
- // whole purpose is to keep local ops from racing the incoming bytes.
1061
- if (this._forcedReadOnly || this._isLoadLocked())
1062
- return false;
1063
- this._ensureLocalUpdatesSubscribed();
1064
- // Tag the commit the UndoManager is about to make so the resulting
1065
- // change reads back as `origin.source === 'undo'`, not `'local'`.
1066
- this._doc.setNextCommitOrigin(UNDO_COMMIT_ORIGIN);
1067
- return this._undo.undo();
1725
+ try {
1726
+ return this._undoGroup.replay('undo');
1727
+ }
1728
+ finally {
1729
+ this._maybeFireUndoState();
1730
+ }
1068
1731
  }
1069
1732
  // Re-apply the most recently undone local commit. Returns `true`
1070
1733
  // if a redo step was applied, `false` if the stack is empty. The
@@ -1075,15 +1738,70 @@ export class Document {
1075
1738
  // composes correctly.
1076
1739
  redo() {
1077
1740
  this._assertOpen();
1078
- // No-op during a checkout, same reasoning as `undo()`.
1079
- if (this._checkout !== null)
1080
- return false;
1081
- // Same read-only gates as `undo()` / `mutate()`.
1082
- if (this._forcedReadOnly || this._isLoadLocked())
1083
- return false;
1741
+ try {
1742
+ return this._undoGroup.replay('redo');
1743
+ }
1744
+ finally {
1745
+ this._maybeFireUndoState();
1746
+ }
1747
+ }
1748
+ // Whether `undo()` / `redo()` must no-op. A replay produces local commits
1749
+ // exactly as `mutate` does, so it obeys every gate `mutate` does. During a
1750
+ // checkout the UndoManager still tracks the live head while reads go
1751
+ // through the preview, so a replay would be a silent live edit. A copy with
1752
+ // no CRDT (frozen content) has no undo stack at all. `undo()` / `redo()`
1753
+ // also no-op inside a `mutate` callback, whose open transaction holds
1754
+ // writes the replay would commit outside the op it ships.
1755
+ _undoBlocked() {
1756
+ return this._checkout !== null || this._undo === null || this._readOnlyBecause !== null;
1757
+ }
1758
+ // Tags each commit the step's replay makes, so the resulting change reads
1759
+ // back as `origin.source === 'undo'` / `'redo'`, not `'local'`.
1760
+ //
1761
+ // A step replays as one commit per commit it took, and those ship as ONE op
1762
+ // exported from the version before the replay, so an Undo of a long step is
1763
+ // one write for the server and one import for each peer.
1764
+ /** @internal Replay this stream's own part of the newest step that touched
1765
+ * it (undo) or of the step it most recently undid (redo), leaving the
1766
+ * document's other streams alone. `DocumentStream.undo` / `redo`. */
1767
+ _replayOwnUndoStep(dir) {
1768
+ this._assertOpen();
1769
+ try {
1770
+ if (this._mutateDepth > 0 || this._undoBlocked())
1771
+ return false;
1772
+ const tag = this._undo.peek(dir);
1773
+ return tag !== undefined && this._replayUndoStep(dir, tag);
1774
+ }
1775
+ finally {
1776
+ this._maybeFireUndoState();
1777
+ }
1778
+ }
1779
+ /** @internal */
1780
+ _replayUndoStep(dir, tag) {
1084
1781
  this._ensureLocalUpdatesSubscribed();
1085
- this._doc.setNextCommitOrigin(REDO_COMMIT_ORIGIN);
1086
- return this._undo.redo();
1782
+ const origin = dir === 'undo' ? UNDO_COMMIT_ORIGIN : REDO_COMMIT_ORIGIN;
1783
+ const tagCommit = () => this._doc.setNextCommitOrigin(origin);
1784
+ const before = this._doc.oplogVersion();
1785
+ const beforeHead = JSON.stringify(this._doc.oplogFrontiers());
1786
+ this._replayingUndo = true;
1787
+ this._replayChange = null;
1788
+ try {
1789
+ return this._undo.replayStep(dir, tag, tagCommit);
1790
+ }
1791
+ finally {
1792
+ this._replayingUndo = false;
1793
+ // Whatever the replay committed ships, a replay that threw part-way included.
1794
+ if (this._unsubLocal && JSON.stringify(this._doc.oplogFrontiers()) !== beforeHead) {
1795
+ this._shipLocalUpdate(this._doc.export({ mode: 'update', from: before }));
1796
+ }
1797
+ before.free();
1798
+ const source = this._replayChange;
1799
+ this._replayChange = null;
1800
+ if (source !== null) {
1801
+ this._subs.emit({ kind: 'change', source });
1802
+ }
1803
+ this._maybeFireUndoState();
1804
+ }
1087
1805
  }
1088
1806
  /**
1089
1807
  * Seal the open undo group locally, so the NEXT local commit starts a
@@ -1094,38 +1812,64 @@ export class Document {
1094
1812
  * @internal
1095
1813
  */
1096
1814
  _sealUndoStep() {
1097
- if (this._undo)
1098
- this._undo.sealStep();
1815
+ this._undoGroup.seal();
1816
+ }
1817
+ /** @internal */
1818
+ _undoController() {
1819
+ return this._undo;
1820
+ }
1821
+ /** @internal */
1822
+ _undoEligible() {
1823
+ return !this._closed && !this._undoBlocked();
1824
+ }
1825
+ /** @internal */
1826
+ _undoMutating() {
1827
+ return this._mutateDepth > 0;
1828
+ }
1829
+ // A version on screen holds every stream of the document at that moment,
1830
+ // so no stream replays a step while the document shows one, even a stream
1831
+ // whose own preview has not landed yet.
1832
+ _undoGroupBlocked() {
1833
+ return (this._closed ||
1834
+ this._checkout !== null ||
1835
+ this._previewVersion !== null ||
1836
+ this._versionBrowsing);
1837
+ }
1838
+ _newUndoController(loroDoc, options) {
1839
+ return new UndoController(loroDoc, options, this._undoGroup, {
1840
+ onFreshCommit: () => this._undoGroup.freshCommit(this),
1841
+ onStackChange: () => {
1842
+ if (!this._undoGroup.isOwner(this))
1843
+ this._undoGroup.changed();
1844
+ },
1845
+ });
1846
+ }
1847
+ /** @internal Make this stream's undo part of its document's stack. Called
1848
+ * when the stream opens, before it has taken a local commit. */
1849
+ _joinUndoGroup(group) {
1850
+ this._undoGroup.delete(this);
1851
+ this._undoGroup = group;
1852
+ group.add(this);
1853
+ this._undo?.useClock(group);
1099
1854
  }
1100
1855
  // Whether `undo()` would have an effect right now. Feeds the
1101
1856
  // commands machinery and the undo-state notifier; apps consume it
1102
1857
  // through `subscribeUndoState` snapshots.
1858
+ //
1859
+ // The gates `undo()` enforces, so a subscribeUndoState-bound button
1860
+ // disables rather than inviting an undo the call would refuse: during a
1861
+ // checkout the stacks still track the live head while the UI shows the
1862
+ // preview, and a stream that refuses writes has nothing it may replay.
1103
1863
  canUndo() {
1104
1864
  if (this._closed)
1105
1865
  return false;
1106
- // During a checkout, the undo stack still tracks the live doc's
1107
- // history, but the UI is showing the historical preview and
1108
- // mutate is rejected. Surface that as "can't undo right now" so
1109
- // subscribeUndoState-bound buttons disable correctly.
1110
- if (this._checkout !== null)
1111
- return false;
1112
- // Fold the same read-only gates `undo()` enforces so a
1113
- // subscribeUndoState-bound button disables while the doc is a view-only
1114
- // guest link or load-locked, rather than inviting an undo the call
1115
- // would refuse.
1116
- if (this._forcedReadOnly || this._isLoadLocked())
1117
- return false;
1118
- return this._undo.canUndo();
1866
+ return this._undoGroup.canUndo();
1119
1867
  }
1120
1868
  // Whether `redo()` would have an effect right now. As above.
1121
1869
  canRedo() {
1122
1870
  if (this._closed)
1123
1871
  return false;
1124
- if (this._checkout !== null)
1125
- return false;
1126
- if (this._forcedReadOnly || this._isLoadLocked())
1127
- return false;
1128
- return this._undo.canRedo();
1872
+ return this._undoGroup.canRedo();
1129
1873
  }
1130
1874
  // The plain-object proxy callers mutate. The mutation runs against
1131
1875
  // the local LoroDoc immediately (optimistic UI), then commit + the
@@ -1155,10 +1899,10 @@ export class Document {
1155
1899
  // edits here exactly as it does through `doc.map(rootKey)`, and a write
1156
1900
  // large enough to cross the paging threshold takes the paged shape from
1157
1901
  // either.
1158
- mutate(fn) {
1902
+ mutate(fn, options) {
1159
1903
  this.map(this._rootKey).mutate((entities) => {
1160
1904
  fn({ entities });
1161
- });
1905
+ }, options);
1162
1906
  }
1163
1907
  // Install / replace the local-op send hook; `null` detaches it.
1164
1908
  //
@@ -1200,7 +1944,7 @@ export class Document {
1200
1944
  /** @internal Whether this Document is currently showing a historical
1201
1945
  * preview rather than its live head. */
1202
1946
  _isCheckedOut() {
1203
- return this._checkout !== null;
1947
+ return this._previewVersion !== null;
1204
1948
  }
1205
1949
  /** @internal Whether this Document is pinned to the version it was opened
1206
1950
  * at, with no live channel behind it. Stepping and returning to the head
@@ -1211,7 +1955,7 @@ export class Document {
1211
1955
  // Switch the Document to a historical version, in place. @internal
1212
1956
  // (the hosted `history` command drives this). After the promise
1213
1957
  // resolves, `doc.readOnly` is `true`,
1214
- // `doc.version` is the server's effective version (which may round
1958
+ // `doc.previewVersion` is the server's effective version (which may round
1215
1959
  // down from the requested one when compaction has TTL'd the exact
1216
1960
  // op), and `mutate` throws. `subscribe` fires once after the swap so
1217
1961
  // the app's render loop redraws, and the app returns to live through
@@ -1239,7 +1983,7 @@ export class Document {
1239
1983
  throw new Error('_checkoutVersion: no fetcher attached. Opened via the SDK factory?');
1240
1984
  }
1241
1985
  const generation = ++this._checkoutGeneration;
1242
- const { snapshotBytes, tailOpBlobs, effectiveVersion, streamSeqs } = await this._checkoutFetcher(this._docId, version, this._branchId);
1986
+ const { content, tailOpBlobs, effectiveVersion, streamSeqs } = await this._checkoutFetcher(this._docId, version, this._branchId);
1243
1987
  if (this._closed || this._suspended)
1244
1988
  return;
1245
1989
  // A `returnToHead` or a newer `_checkoutVersion` fired while this fetch
@@ -1248,13 +1992,21 @@ export class Document {
1248
1992
  if (generation !== this._checkoutGeneration)
1249
1993
  return;
1250
1994
  this._checkoutStreamSeqs = streamSeqs ?? {};
1995
+ // Handles over frozen content read frozen content, so the version is
1996
+ // swapped in as frozen content whatever encoding the server stores it in.
1997
+ if (this._sealedRoots !== null) {
1998
+ this._frozenShown = frozenPreview(content, tailOpBlobs);
1999
+ this._frozenVersion = effectiveVersion;
2000
+ this._sealedRoots.resync();
2001
+ this._fireSubscribers({ kind: 'history', version: effectiveVersion });
2002
+ await this._checkoutOpenStreams(generation);
2003
+ return;
2004
+ }
1251
2005
  // Materialise the preview into a fresh LoroDoc. The previous
1252
2006
  // checkout's preview LoroDoc, if any, is freed.
1253
- const previewDoc = new LoroDoc();
1254
- if (snapshotBytes !== null)
1255
- previewDoc.import(snapshotBytes);
2007
+ const previewDoc = engineFrom(content);
1256
2008
  for (const blob of tailOpBlobs)
1257
- previewDoc.import(blob);
2009
+ importTailOp(previewDoc, blob);
1258
2010
  const previewRoot = previewDoc.getMap(this._rootKey);
1259
2011
  const old = this._checkout;
1260
2012
  // Snapshot the handles' keys before the active-source pointer moves (see
@@ -1280,7 +2032,7 @@ export class Document {
1280
2032
  // container captured at attach time (the OLD preview's); freeing first
1281
2033
  // would run that detach against a freed LoroDoc. This is the same
1282
2034
  // teardown-before-free order `close()` documents.
1283
- if (old && old.previewDoc !== this._doc) {
2035
+ if (old && old.previewDoc !== this._engine) {
1284
2036
  try {
1285
2037
  old.previewDoc.free();
1286
2038
  }
@@ -1348,6 +2100,14 @@ export class Document {
1348
2100
  // `_checkout` still null, and this is the signal that supersedes it when
1349
2101
  // it resolves.
1350
2102
  const generation = (this._checkoutGeneration += 1);
2103
+ if (this._sealedRoots !== null && this._frozenVersion !== null) {
2104
+ this._frozenShown = this._frozenHead;
2105
+ this._frozenVersion = null;
2106
+ this._sealedRoots.resync();
2107
+ this._fireSubscribers({ kind: 'return-to-head' });
2108
+ this._returnOpenStreams(generation);
2109
+ return;
2110
+ }
1351
2111
  if (this._checkout === null)
1352
2112
  return;
1353
2113
  const old = this._checkout;
@@ -1362,7 +2122,7 @@ export class Document {
1362
2122
  // Free the preview only after the rebind: `_rebindRootHandles` detaches
1363
2123
  // each subscriber against the preview container it was attached to, so
1364
2124
  // freeing first would be a use-after-free (see `_checkoutVersion`).
1365
- if (old.previewDoc !== this._doc) {
2125
+ if (old.previewDoc !== this._engine) {
1366
2126
  try {
1367
2127
  old.previewDoc.free();
1368
2128
  }
@@ -1372,9 +2132,12 @@ export class Document {
1372
2132
  }
1373
2133
  this._fireSubscribers({ kind: 'return-to-head' });
1374
2134
  this._maybeFireUndoState();
1375
- // Return every opened stream to its live head too, symmetric with the
1376
- // checkout path. Fire-and-forget (returnToHead is synchronous and has no
1377
- // fetch): a stream not previewing is a no-op.
2135
+ this._returnOpenStreams(generation);
2136
+ }
2137
+ // Return every opened stream to its live head too, symmetric with the
2138
+ // checkout path. Fire-and-forget (returnToHead is synchronous and has no
2139
+ // fetch): a stream not previewing is a no-op.
2140
+ _returnOpenStreams(generation) {
1378
2141
  this._checkoutStreamSeqs = null;
1379
2142
  for (const openPromise of this._streamOpens.values()) {
1380
2143
  void openPromise
@@ -1399,7 +2162,22 @@ export class Document {
1399
2162
  // and the caller moves the active-source pointer between the two calls.
1400
2163
  // Callers must not return early in that window; a snapshot left unconsumed
1401
2164
  // would be diffed against a later, unrelated swap.
2165
+ // Detach every cached root handle's live subscriptions. Reached only on a
2166
+ // CRDT document: the teardown, the key snapshot and the rebind below are
2167
+ // `handles.ts`'s own internal protocol, spoken by the handles it produces,
2168
+ // and a stored record set's roots hold no container subscription to detach,
2169
+ // no anchor to re-point and no prior keys to diff against a swap.
2170
+ _teardownRootHandles() {
2171
+ for (const entry of this._rootHandles.values()) {
2172
+ if (entry.kind === 'text')
2173
+ entry.handle._teardown();
2174
+ else
2175
+ teardownRootHandle(entry.handle);
2176
+ }
2177
+ }
1402
2178
  _snapshotRootHandleKeys() {
2179
+ if (this._sealedRoots !== null)
2180
+ return;
1403
2181
  for (const entry of this._rootHandles.values()) {
1404
2182
  if (entry.kind === 'map')
1405
2183
  snapshotRootMapKeys(entry.handle);
@@ -1412,6 +2190,8 @@ export class Document {
1412
2190
  // `getMap` / `getList` are get-or-create, so a container absent in the
1413
2191
  // preview resolves to an empty one and its subscribers redraw to empty.
1414
2192
  _rebindRootHandles(source) {
2193
+ if (this._sealedRoots !== null)
2194
+ return;
1415
2195
  // Two passes, not one per handle: rebind every anchor to the new source
1416
2196
  // first, then resync. A composed handle can read a sibling root while
1417
2197
  // firing (the ordered map reads its order list during its map redraw), so
@@ -1570,13 +2350,11 @@ export class Document {
1570
2350
  // both alongside the publish-over that `pushToDrive` does. Driven by the
1571
2351
  // hosted toolbar.
1572
2352
  //
1573
- // Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
1574
- // takes over the drive link (take-source trashes this doc, keep-both
1575
- // detaches it into a standalone fork). For a session-backed app the
1576
- // SDK re-opens onto that new doc automatically (the resolver routes it
1577
- // through the same `openSiblingDoc` path the history fork uses); the
1578
- // returned `newDocId` lets a multi-doc app do its own switch. Resolves
1579
- // with the outcome, or `null` when the user dismissed the dialog.
2353
+ // Taking the drive's version keeps this document's id: the open copy moves
2354
+ // onto that version when it lands (`onGone('moved')`, which a session
2355
+ // re-opens). A `newDocId` in the result is opened through the same
2356
+ // `openSiblingDoc` path the history fork uses. Resolves with the outcome,
2357
+ // or `null` when the user dismissed the dialog.
1580
2358
  // Throws when there is no doc identity or no resolver is attached.
1581
2359
  /** @internal */
1582
2360
  async resolveDrive() {
@@ -1590,20 +2368,8 @@ export class Document {
1590
2368
  return await this._driveResolver();
1591
2369
  }
1592
2370
  // Fetch the file a document was uploaded as: a short-lived presigned GET
1593
- // plus the file's name / content type / size. An app that knows the format
1594
- // fetches the bytes from `url`, parses them, and writes the structured
1595
- // content into this document through the normal handle surface
1596
- // (`doc.map('instances').mutate(...)`):
1597
- //
1598
- // const src = await doc.sourceBlob();
1599
- // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
1600
- // const records = parseMyFormat(bytes); // app's own parser
1601
- // doc.map('instances').mutate((m) => { for (const r of records) m[r.id] = r; });
1602
- //
1603
- // A source file is a set of one or more streams: `src.streams` lists
1604
- // every stream (primary first, so a single-stream file has one entry),
1605
- // each with its own URL, so a multi-stream format (a glTF with its
1606
- // `.bin` buffer) fetches only the streams it needs.
2371
+ // plus the file's name / content type / size. The document's content is
2372
+ // what its importer returned for this file, sealed by the platform.
1607
2373
  //
1608
2374
  // Rejects with a `BrassError` when the document holds no such file, or
1609
2375
  // the caller cannot reach it (both 404).
@@ -1693,21 +2459,22 @@ export class Document {
1693
2459
  throw new Error(`This document has no stream named "${name}". ` +
1694
2460
  `doc.getOrCreateStream(${JSON.stringify(name)}) makes one.`);
1695
2461
  }
1696
- // Only a CRDT stream opens as live collaborative state. Any other
1697
- // value fails loudly here rather than joining a line that is not
1698
- // one, so a format added after this SDK shipped is refused by the
1699
- // version that cannot read it.
2462
+ // Every stream the platform writes is a document line and reports
2463
+ // `'crdt'`, whether it is stored as a CRDT or frozen. Any other value
2464
+ // names a kind this version does not know, so it is refused rather
2465
+ // than joined as a line.
1700
2466
  if (info.format !== 'crdt') {
1701
2467
  throw new Error(`Stream "${name}" has format "${info.format}", which this ` +
1702
- 'SDK version cannot open as a live stream.');
2468
+ 'SDK version cannot open.');
1703
2469
  }
1704
2470
  const inner = await runtime.openLine(info.line_id);
2471
+ inner._joinUndoGroup(this._undoGroup);
1705
2472
  const stream = new DocumentStream(name, inner, true);
1706
2473
  try {
1707
2474
  // Opened while a version preview is active: bring the new stream to
1708
2475
  // the same point in time rather than showing it live under a
1709
2476
  // historical default.
1710
- if (this._checkout !== null) {
2477
+ if (this._previewVersion !== null) {
1711
2478
  await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
1712
2479
  }
1713
2480
  }
@@ -1739,6 +2506,9 @@ export class Document {
1739
2506
  stream._inner._observeSavingCount(() => {
1740
2507
  this._fireSubscribers({ kind: 'saving-count' });
1741
2508
  });
2509
+ stream._inner._observeConnectionNotice((kind) => {
2510
+ this._showConnectionNotice(kind);
2511
+ });
1742
2512
  if (this._suspended)
1743
2513
  stream._suspend();
1744
2514
  }, () => {
@@ -1819,6 +2589,7 @@ export class Document {
1819
2589
  // transport about to be torn down and is never persisted.
1820
2590
  for (const stream of this._openStreams.values()) {
1821
2591
  stream._inner._observeSavingCount(null);
2592
+ stream._inner._observeConnectionNotice(null);
1822
2593
  stream._close();
1823
2594
  }
1824
2595
  // An open still in flight has no Document to close yet, so it closes on
@@ -1850,17 +2621,21 @@ export class Document {
1850
2621
  stream._resume();
1851
2622
  }
1852
2623
  }
1853
- // Seal a deliberate checkpoint on this document's version-
1854
- // history timeline. Compaction snapshots continue to appear as
1855
- // no-action fallback markers, so an app that never calls this still
1856
- // gets a usable history.
1857
- //
1858
- // Anchors to `doc.currentSeq` synchronously, so a concurrent
1859
- // editor's op landing mid-call cannot shift the anchor. Pass an
1860
- // explicit `version` to seal at a different point in history (the
1861
- // server validates it's `<=` the branch's live head).
1862
- async createCheckpoint() {
1863
- return this._checkpoints.create();
2624
+ // Mark the end of a user action: seals the undo step at once, and records a
2625
+ // version-history marker covering this call (calls made while a write is
2626
+ // in flight share the next one, written at the latest state). Call it
2627
+ // after every action and do not await it in the action's handler.
2628
+ // Resolves once the marker covering the call is written. While `readOnly`
2629
+ // is true it writes nothing and resolves at once (as `undo` / `redo` do
2630
+ // nothing there).
2631
+ createCheckpoint() {
2632
+ if (this._isLive && this.readOnly)
2633
+ return Promise.resolve();
2634
+ const written = this._checkpoints.create().then(() => undefined);
2635
+ // Apps call this without awaiting it; a rejection still reaches a caller
2636
+ // that awaits.
2637
+ written.catch(() => { });
2638
+ return written;
1864
2639
  }
1865
2640
  // Configure the implicit auto-checkpoint debounce. Default is 60s
1866
2641
  // after the last local edit; pass `0` to disable. Apps in test
@@ -1888,6 +2663,23 @@ export class Document {
1888
2663
  this._name = name;
1889
2664
  this._fireSubscribers({ kind: 'commands' });
1890
2665
  }
2666
+ /** When Brass will remove this document, or `null` when it is kept. Set
2667
+ * for a document created while signed out; a sign-in claims it and the
2668
+ * claimed document reads `null`. */
2669
+ get expiresAt() {
2670
+ return this._expiresAt;
2671
+ }
2672
+ /** @internal */
2673
+ _setExpiresAt(expiresAt) {
2674
+ const same = (this._expiresAt === null && expiresAt === null) ||
2675
+ (this._expiresAt !== null &&
2676
+ expiresAt !== null &&
2677
+ this._expiresAt.getTime() === expiresAt.getTime());
2678
+ if (same)
2679
+ return;
2680
+ this._expiresAt = expiresAt;
2681
+ this._fireSubscribers({ kind: 'commands' });
2682
+ }
1891
2683
  /** @internal */
1892
2684
  setHostedUiProvider(provider) {
1893
2685
  this._commandMenu.setHostedUiProvider(provider);
@@ -1948,6 +2740,13 @@ export class Document {
1948
2740
  else
1949
2741
  console.error('[Brass]', err);
1950
2742
  }
2743
+ // The console keeps the original stack for the developer; the app's
2744
+ // `onError` gets a sentence it can show the reader.
2745
+ _reportSubscriberThrow(err) {
2746
+ console.error('[Brass] subscriber threw:', err);
2747
+ if (this._errorReporter)
2748
+ this._errorReporter(appCallbackError(err));
2749
+ }
1951
2750
  /** @internal Report a failure from the SDK's own chrome (the version
1952
2751
  * bar's intents) through the same sink the built-in commands use, so a
1953
2752
  * refused checkout reaches the app's `onError` rather than being dropped
@@ -1971,6 +2770,9 @@ export class Document {
1971
2770
  // itself, so the registry's total already carries one entry per stream.
1972
2771
  trackUnsavedWork(this, this._outstandingByClientId.size);
1973
2772
  this._savingCountObserver?.();
2773
+ if (this._workStatus.notice !== null && this.savingCount === 0) {
2774
+ this._workStatus.setNotice(null);
2775
+ }
1974
2776
  }
1975
2777
  this._ensureLoroSubscribed();
1976
2778
  this._subs.emit(event);
@@ -2082,8 +2884,9 @@ export class Document {
2082
2884
  // Returns true if the clientOpId was tracked (lets the caller detect
2083
2885
  // a stale ack).
2084
2886
  /** @internal */
2085
- resolveOutstandingOp(clientOpId) {
2887
+ resolveOutstandingOp(clientOpId, seq) {
2086
2888
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
2889
+ this._provisionalInputs.acked(clientOpId, seq);
2087
2890
  // Release this chunk from the resync-push loop guard once its op is
2088
2891
  // acked: the pushed op is now in the log, so the next handshake's
2089
2892
  // tail covers it and a fresh push is welcome (it would be a genuine
@@ -2135,9 +2938,161 @@ export class Document {
2135
2938
  * telling the reader first, because an app that renders nothing for it is
2136
2939
  * exactly the case that must not leave someone on a diverged copy.
2137
2940
  */
2138
- reload() {
2941
+ _reopen() {
2139
2942
  this._markGone('moved');
2140
2943
  }
2944
+ /** @internal The app registered its importer, so inputs whose open stopped
2945
+ * waiting for it run again. */
2946
+ _importerRegistered() {
2947
+ this._inputFollowers.importerRegistered();
2948
+ }
2949
+ /**
2950
+ * @internal Typed on `ReadOnlyDocument`. A frozen copy loads the document as
2951
+ * it stands now, in place: the handles stay the same and their subscribers
2952
+ * are told what changed. A live read-only copy already holds the document as
2953
+ * it stands, so there is nothing to load. An editable copy is reopened
2954
+ * (`_reopen`).
2955
+ */
2956
+ reload() {
2957
+ this._assertOpen();
2958
+ const reloader = this._frozenReloader;
2959
+ if (reloader === null) {
2960
+ if (!this.readOnly)
2961
+ this._reopen();
2962
+ return Promise.resolve();
2963
+ }
2964
+ // One reload at a time: a call while one runs shares it, since both ask
2965
+ // for the document as it stands once it lands.
2966
+ if (this._reloading !== null)
2967
+ return this._reloading;
2968
+ const run = (async () => {
2969
+ try {
2970
+ // Cleared before the join, so a change landing during it is reported
2971
+ // against the load it may or may not have reached.
2972
+ this._changed = false;
2973
+ this._stopChangeNotice?.();
2974
+ this._stopChangeNotice = null;
2975
+ await reloader();
2976
+ }
2977
+ finally {
2978
+ this._reloading = null;
2979
+ if (!this._closed && (this._changedListeners.size > 0 || this._statusWatches > 0)) {
2980
+ this._watchForChange();
2981
+ }
2982
+ }
2983
+ })();
2984
+ this._reloading = run;
2985
+ return run;
2986
+ }
2987
+ async reopen(options) {
2988
+ this._assertOpen();
2989
+ if (options?.frozen === true && options.readOnly === false) {
2990
+ throw new TypeError('reopen: a frozen copy is read-only, so `readOnly: false` cannot apply');
2991
+ }
2992
+ const mode = options?.frozen === true ? 'frozen' : options?.readOnly === true ? 'read' : 'edit';
2993
+ const pending = this._pendingReopen;
2994
+ if (pending !== null) {
2995
+ if (pending.mode === mode)
2996
+ return pending.promise;
2997
+ // The later mode wins: the earlier call rejects and the copy it opens is
2998
+ // reopened in this mode, so every copy opened is either returned or closed.
2999
+ pending.superseded = true;
3000
+ return this._trackReopen(mode, async () => {
3001
+ const earlier = await pending.result.catch(() => null);
3002
+ if (earlier !== null) {
3003
+ return earlier.reopen(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' });
3004
+ }
3005
+ const reopener = this._reopenerFor(mode);
3006
+ if (reopener === null)
3007
+ return this;
3008
+ return this._runReopen(mode, reopener);
3009
+ });
3010
+ }
3011
+ const reopener = this._reopenerFor(mode);
3012
+ if (reopener === null)
3013
+ return this;
3014
+ return this._trackReopen(mode, () => this._runReopen(mode, reopener));
3015
+ }
3016
+ // The layer that reopens this copy in `mode`, or `null` when it is already
3017
+ // in that mode. Throws when it cannot be reopened so.
3018
+ _reopenerFor(mode) {
3019
+ // A copy showing a historical version is not at the head in any mode, so
3020
+ // every reopen loads the head.
3021
+ // A copy locked over a refused change shows something the platform does
3022
+ // not, so even a reopen to read builds a new one.
3023
+ const diverged = this._forcedReadOnly && this._readOnlyReason === 'unsaved';
3024
+ if (mode === this._openMode && this._previewVersion === null && !diverged) {
3025
+ return null;
3026
+ }
3027
+ if (mode === 'edit') {
3028
+ const refusal = this._editRefusal;
3029
+ if (refusal !== null)
3030
+ throw new BrassError(refusal, { status: 403 });
3031
+ }
3032
+ const reopener = this._reopener;
3033
+ if (reopener === null) {
3034
+ throw new BrassError('This document cannot be reopened', { status: 0 });
3035
+ }
3036
+ return reopener;
3037
+ }
3038
+ async _runReopen(mode, reopener) {
3039
+ if (this._openMode === 'edit') {
3040
+ // A reopen called from inside a `mutate` callback runs before that
3041
+ // mutation commits, so the wait starts once it has.
3042
+ await new Promise((resolve) => setTimeout(resolve, 0));
3043
+ if (!(await whenSaved(this))) {
3044
+ throw new BrassError('Changes to this document have not finished saving', {
3045
+ status: 0,
3046
+ });
3047
+ }
3048
+ }
3049
+ return reopener(mode);
3050
+ }
3051
+ // Records `run` as the reopen in flight, which a second call to the same
3052
+ // mode shares and a call to another mode supersedes. `reopening` holds until
3053
+ // the last overlapping reopen settles.
3054
+ _trackReopen(mode, run) {
3055
+ this._setReopening(true);
3056
+ const entry = { mode, superseded: false };
3057
+ this._pendingReopen = entry;
3058
+ entry.result = run().finally(() => {
3059
+ if (this._pendingReopen !== entry)
3060
+ return;
3061
+ this._pendingReopen = null;
3062
+ this._setReopening(false);
3063
+ });
3064
+ entry.promise = entry.result.then((next) => {
3065
+ if (entry.superseded) {
3066
+ throw new BrassError('The reopen was replaced by a later reopen', { status: 0 });
3067
+ }
3068
+ return next;
3069
+ });
3070
+ return entry.promise;
3071
+ }
3072
+ _setReopening(reopening) {
3073
+ if (this._reopening === reopening)
3074
+ return;
3075
+ this._reopening = reopening;
3076
+ if (!this._isLive)
3077
+ return;
3078
+ this._fireSubscribers({ kind: 'commands' });
3079
+ this._maybeFireUndoState();
3080
+ }
3081
+ /** @internal Which of the three open modes this copy is in. */
3082
+ get _openMode() {
3083
+ if (this._frozenReloader !== null)
3084
+ return 'frozen';
3085
+ return this._forcedReadOnly ? 'read' : 'edit';
3086
+ }
3087
+ /** @internal Install what `reopen` calls. */
3088
+ _setReopener(reopener) {
3089
+ this._reopener = reopener;
3090
+ }
3091
+ /** @internal What `reopen` calls, so a layer that takes over this copy can
3092
+ * fall back to the one it replaced. */
3093
+ get _reopenerFn() {
3094
+ return this._reopener;
3095
+ }
2141
3096
  // Internal: forget an outstanding op the server TERMINALLY rejected (an
2142
3097
  // `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
2143
3098
  // server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
@@ -2156,6 +3111,7 @@ export class Document {
2156
3111
  opts = {}) {
2157
3112
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
2158
3113
  this._resyncPushClientOpIds.delete(clientOpId);
3114
+ this._provisionalInputs.failed(clientOpId);
2159
3115
  if (wasTracked) {
2160
3116
  this._fireSubscribers({ kind: 'saving-count' });
2161
3117
  if (opts.report !== false)
@@ -2214,6 +3170,9 @@ export class Document {
2214
3170
  _ensureLocalUpdatesSubscribed() {
2215
3171
  if (this._unsubLocal)
2216
3172
  return;
3173
+ // Frozen content takes no write, so there is no local update to send.
3174
+ if (this._sealedRoots !== null)
3175
+ return;
2217
3176
  if (!this._localOpHook) {
2218
3177
  this._reportTransportUnavailable();
2219
3178
  return;
@@ -2223,26 +3182,31 @@ export class Document {
2223
3182
  // does NOT trigger this callback, so we don't risk feeding the
2224
3183
  // same op back to the server in a loop.
2225
3184
  this._unsubLocal = this._doc.subscribeLocalUpdates((bytes) => {
2226
- const hook = this._localOpHook;
2227
- if (!hook)
2228
- return;
2229
- // Copy: Loro reuses its internal buffer across callback calls in
2230
- // some builds. Hand the consumer a stable view so the eventual
2231
- // WS send sees the correct bytes regardless of when the send
2232
- // actually flushes (the WS client may queue the frame).
2233
- const copy = new Uint8Array(bytes.byteLength);
2234
- copy.set(bytes);
2235
- const chunks = this.splitLocalUpdate(copy);
2236
- if (chunks === null) {
2237
- this._shipLocalOp(copy);
3185
+ // A replay's commits ship together once it ends (`_replayUndo`).
3186
+ if (this._replayingUndo)
2238
3187
  return;
2239
- }
2240
- // In order: each chunk continues the one before it, so a peer applying
2241
- // them out of sequence holds the tail unapplied until the gap closes.
2242
- for (const chunk of chunks)
2243
- this._shipLocalOp(chunk);
3188
+ this._shipLocalUpdate(bytes);
2244
3189
  });
2245
3190
  }
3191
+ _shipLocalUpdate(bytes) {
3192
+ if (!this._localOpHook)
3193
+ return;
3194
+ // Copy: Loro reuses its internal buffer across callback calls in
3195
+ // some builds. Hand the consumer a stable view so the eventual
3196
+ // WS send sees the correct bytes regardless of when the send
3197
+ // actually flushes (the WS client may queue the frame).
3198
+ const copy = new Uint8Array(bytes.byteLength);
3199
+ copy.set(bytes);
3200
+ const chunks = this.splitLocalUpdate(copy);
3201
+ if (chunks === null) {
3202
+ this._shipLocalOp(copy);
3203
+ return;
3204
+ }
3205
+ // In order: each chunk continues the one before it, so a peer applying
3206
+ // them out of sequence holds the tail unapplied until the gap closes.
3207
+ for (const chunk of chunks)
3208
+ this._shipLocalOp(chunk);
3209
+ }
2246
3210
  // Hand one op blob to the transport and count it as unsaved, or keep it
2247
3211
  // local when the server has evicted this document.
2248
3212
  //
@@ -2265,6 +3229,7 @@ export class Document {
2265
3229
  return null;
2266
3230
  const clientOpId = hook(copy);
2267
3231
  this._outstandingByClientId.set(clientOpId, copy);
3232
+ this._shipCapture?.push(clientOpId);
2268
3233
  // The local mutate has already fired `subscribe` (the Loro
2269
3234
  // event), but `savingCount` just incremented; fire one more
2270
3235
  // synthetic event so a subscribe handler bound on `savingCount`
@@ -2290,6 +3255,9 @@ export class Document {
2290
3255
  return;
2291
3256
  if (this._unsubLoro)
2292
3257
  return;
3258
+ // Frozen roots deliver their own subscribers; there is no engine to hear.
3259
+ if (this._sealedRoots !== null)
3260
+ return;
2293
3261
  this._unsubLoro = this._doc.subscribe((event) => {
2294
3262
  // One channel: every event reaches every subscriber, whatever
2295
3263
  // produced it. The app reconciles by value and reads the cause off
@@ -2301,10 +3269,15 @@ export class Document {
2301
3269
  // build on them, which pins the storage engine through a surface that
2302
3270
  // never promised it. The cause survives as `source`, the same
2303
3271
  // engine-neutral vocabulary the handle surface reports.
2304
- this._subs.emit({
2305
- kind: 'change',
2306
- source: changeSourceOf(event),
2307
- });
3272
+ if (this._replayingUndo) {
3273
+ this._replayChange = changeSourceOf(event);
3274
+ }
3275
+ else {
3276
+ this._subs.emit({
3277
+ kind: 'change',
3278
+ source: changeSourceOf(event),
3279
+ });
3280
+ }
2308
3281
  const isLocalCommit = event.by === 'local';
2309
3282
  this._maybeFireUndoState();
2310
3283
  // A local commit (mutate / undo / redo) schedules a debounced
@@ -2374,7 +3347,12 @@ export class Document {
2374
3347
  // suspend like every other app-side subscriber.
2375
3348
  subscribeUndoState(cb) {
2376
3349
  this._assertOpen();
2377
- this._ensureLoroSubscribed();
3350
+ // A copy that cannot write never has anything of its own to undo (the
3351
+ // reason it cannot write does not lift while it is open), so it does not
3352
+ // listen to its own engine. A stream of the document can still take
3353
+ // writes, and reports them through the undo group.
3354
+ if (!this._forcedReadOnly)
3355
+ this._ensureLoroSubscribed();
2378
3356
  this._undoStateSubs.add(cb);
2379
3357
  // Fire immediately with current state so the consumer does
2380
3358
  // not need separate initial-paint code. Capture the values
@@ -2401,8 +3379,16 @@ export class Document {
2401
3379
  // Internal: fire `_undoStateSubs` if canUndo / canRedo flipped.
2402
3380
  // Called from `_ensureLoroSubscribed`'s event dispatch.
2403
3381
  _maybeFireUndoState() {
3382
+ // A stream's undo state is its document's: whatever moved here (a write
3383
+ // landing, the stream turning read-only) re-reads the document's.
3384
+ if (!this._undoGroup.isOwner(this))
3385
+ this._undoGroup.changed();
2404
3386
  if (this._undoStateSubs.size === 0)
2405
3387
  return;
3388
+ // A step can replay as several commits, and each fires a Loro event
3389
+ // before the stacks settle; `_replayUndo` fires once at the end.
3390
+ if (this._replayingUndo)
3391
+ return;
2406
3392
  const canUndo = this.canUndo();
2407
3393
  const canRedo = this.canRedo();
2408
3394
  if (canUndo === this._lastCanUndo && canRedo === this._lastCanRedo) {
@@ -2444,41 +3430,270 @@ export class Document {
2444
3430
  this._assertOpen();
2445
3431
  return this._gone.on(cb);
2446
3432
  }
2447
- // The latest opaque work-status for this document, or `null` when no
2448
- // slow server-side work is in flight. An app renders `message`
2449
- // verbatim, draws a bar from `progress` when present, and shows a
2450
- // waiting state while `pending`. When a freshly-opened drive-linked
2451
- // doc is still hydrating, `open()` surfaces the initial `pending`
2452
- // status here; subsequent phases arrive via `onStatus`.
3433
+ /** What the importer reported about the file this document was read
3434
+ * from, as the open found it. `null` on a document no import produced and
3435
+ * on a clean import. */
3436
+ get importReport() {
3437
+ return this._importReport;
3438
+ }
3439
+ /** @internal Seeded from the join. */
3440
+ _setImportReport(report) {
3441
+ this._importReport = report;
3442
+ }
3443
+ /** @internal On a frozen open: whether the document has moved since this
3444
+ * copy last loaded. False on every other open, which applies changes as
3445
+ * they come. */
3446
+ get changed() {
3447
+ return this._changed;
3448
+ }
3449
+ /** @internal On a frozen open, fires when the copy learns the document
3450
+ * moved since it last loaded (a content edit, an import landing or
3451
+ * failing, the document being deleted): once per load, and again after a
3452
+ * `reload()` once the document moves past what that load brought. Never
3453
+ * fires on any other open. The document is watched only while a listener is
3454
+ * registered and until the copy is closed. */
3455
+ onChanged(cb) {
3456
+ this._assertOpen();
3457
+ this._changedListeners.add(cb);
3458
+ if (this._changed) {
3459
+ queueMicrotask(() => {
3460
+ if (!this._closed && this._changed && this._changedListeners.has(cb))
3461
+ cb();
3462
+ });
3463
+ }
3464
+ else {
3465
+ this._watchForChange();
3466
+ }
3467
+ return () => {
3468
+ this._changedListeners.delete(cb);
3469
+ this._unwatchIfIdle();
3470
+ };
3471
+ }
3472
+ _unwatchIfIdle() {
3473
+ if (this._changedListeners.size === 0 && this._statusWatches === 0) {
3474
+ this._stopChangeNotice?.();
3475
+ this._stopChangeNotice = null;
3476
+ }
3477
+ }
3478
+ _watchForChange() {
3479
+ if (this._stopChangeNotice === null && this._changeNotice !== null) {
3480
+ this._stopChangeNotice = this._changeNotice(() => this._markChanged());
3481
+ }
3482
+ }
3483
+ /**
3484
+ * Calls the handlers with the input files of `key`. `loaded` gets every
3485
+ * file on each subscribe, then each new version, on any copy. `changed`
3486
+ * gets the files changed since this document last read them, on a copy
3487
+ * that can edit, each recorded as read off the Undo stack once its handler
3488
+ * resolves. `removed` gets each file that left. Calls for one file never
3489
+ * overlap. Subscriptions to one key share one set of calls, and each
3490
+ * subscription's handlers are called for every input; a `changed` input is
3491
+ * recorded as read once all of them resolve. Returns an unsubscribe.
3492
+ */
3493
+ subscribeInputDocuments(key, handlers, options) {
3494
+ this._assertOpen();
3495
+ if (handlers === null || typeof handlers !== 'object') {
3496
+ throw new TypeError('subscribeInputDocuments: pass an object of handlers');
3497
+ }
3498
+ if (!isInputKey(key))
3499
+ throw new TypeError(`subscribeInputDocuments: "${String(key)}" is not a valid key`);
3500
+ const named = handlers;
3501
+ if (!['loaded', 'changed', 'removed', 'status', 'settled'].some((name) => typeof named[name] === 'function')) {
3502
+ throw new TypeError('subscribeInputDocuments: handlers must include loaded, changed, removed, status or settled');
3503
+ }
3504
+ const hub = this._inputStatus();
3505
+ return this._inputFollowers.subscribe({
3506
+ subscribeStatus: (k, cb) => hub.subscribeKey(k, cb),
3507
+ canWrite: () => !this._closed && !this.readOnly,
3508
+ markRead: (status, options) => this.markRead(status, options),
3509
+ open: this._inputsRuntime?.open ?? null,
3510
+ }, key, handlers, options);
3511
+ }
3512
+ /**
3513
+ * @internal Calls `cb` with the status of `key` once it is read, and again
3514
+ * whenever it changes: an input file was added, modified or removed, the
3515
+ * key's references changed, or this document's read marks moved (an Undo
3516
+ * of `markRead` included, which reports at once). Returns an unsubscribe.
3517
+ */
3518
+ subscribeInputStatus(key, cb) {
3519
+ this._assertOpen();
3520
+ if (typeof cb !== 'function')
3521
+ throw new TypeError('subscribeInputStatus: pass a callback');
3522
+ if (!isInputKey(key))
3523
+ throw new TypeError(`subscribeInputStatus: "${String(key)}" is not a valid key`);
3524
+ return this._inputStatus().subscribeKey(key, cb);
3525
+ }
3526
+ /** @internal Wired by the SDK's open factory. */
3527
+ _setInputsRuntime(runtime) {
3528
+ this._inputsRuntime = runtime;
3529
+ }
3530
+ /** @internal The platform moved `key`'s status to `rev`; both null after a
3531
+ * resubscribe, which may have missed a nudge for any key. */
3532
+ _inputStatusNudged(key, rev) {
3533
+ this._inputStatusHub?.nudged(key, rev);
3534
+ }
3535
+ _inputStatus() {
3536
+ if (this._inputStatusHub !== null)
3537
+ return this._inputStatusHub;
3538
+ const runtime = this._inputsRuntime;
3539
+ if (runtime === null) {
3540
+ throw new Error('subscribeInputStatus: Document has no server identity. Opened via the SDK factory?');
3541
+ }
3542
+ this._inputStatusHub = new InputStatusHub({
3543
+ runtime,
3544
+ lineId: () => this._branchId,
3545
+ localRoots: () => this._inputsLocalRoots(),
3546
+ watchLocalRoots: (cb) => this._watchInputsRoots(cb),
3547
+ });
3548
+ return this._inputStatusHub;
3549
+ }
3550
+ // The reserved roots' plain values. A root nobody has written is read as
3551
+ // empty without asking the engine for it, since asking creates it.
3552
+ _inputsLocalRoots() {
3553
+ if (this._sealedRoots !== null || this._closed)
3554
+ return null;
3555
+ const engine = this._doc;
3556
+ const present = engine.getShallowValue();
3557
+ return {
3558
+ refs: INPUTS_REFS_ROOT in present ? engine.getMap(INPUTS_REFS_ROOT).toJSON() : {},
3559
+ read: INPUTS_READ_ROOT in present ? engine.getMap(INPUTS_READ_ROOT).toJSON() : {},
3560
+ };
3561
+ }
3562
+ _watchInputsRoots(cb) {
3563
+ if (this._sealedRoots !== null || this._closed)
3564
+ return () => { };
3565
+ const targets = new Set([`cid:root-${INPUTS_REFS_ROOT}:Map`, `cid:root-${INPUTS_READ_ROOT}:Map`]);
3566
+ return this._doc.subscribe((batch) => {
3567
+ if (batch.events.some((event) => targets.has(String(event.target))))
3568
+ cb();
3569
+ });
3570
+ }
3571
+ /** @internal How a frozen copy watches its document: starts watching,
3572
+ * calls `fire` when the document moves, and returns the stop. */
3573
+ _setChangeNotice(start) {
3574
+ this._changeNotice = start;
3575
+ }
3576
+ /** @internal Stops a frozen copy's notice once it has reported a move and
3577
+ * nobody is waiting on pending work. */
3578
+ _settleChangeNotice() {
3579
+ if (!this._changed)
3580
+ return;
3581
+ if (this._statusWatches > 0 && this._workStatus.work?.pending === true)
3582
+ return;
3583
+ this._stopChangeNotice?.();
3584
+ this._stopChangeNotice = null;
3585
+ }
3586
+ /** @internal Wire a frozen copy's reload: re-join, apply the result in
3587
+ * place, and re-point the change notice at what that join brought. */
3588
+ _setFrozenReloader(reload) {
3589
+ this._frozenReloader = reload;
3590
+ }
3591
+ // The notice is spent on its first frame, so the watch stops here: the topic
3592
+ // carries every op of the document, and a copy that already knows it moved
3593
+ // has no use for the rest until it reloads. A copy whose work is still
3594
+ // pending keeps it while an `onStatus` listener waits, because the import
3595
+ // landing moves the document before its terminal status arrives.
3596
+ _markChanged() {
3597
+ if (this._changed || this._closed)
3598
+ return;
3599
+ this._changed = true;
3600
+ this._settleChangeNotice();
3601
+ const listeners = [...this._changedListeners];
3602
+ for (const cb of listeners) {
3603
+ try {
3604
+ cb();
3605
+ }
3606
+ catch (err) {
3607
+ console.error('Brass: an onChanged listener threw', err);
3608
+ }
3609
+ }
3610
+ }
3611
+ /**
3612
+ * The work in flight on this document (a new version importing, a drive
3613
+ * sync), or `null` when there is none. Render `message` verbatim, draw a
3614
+ * bar from `progress` when present, and show a waiting state while
3615
+ * `pending`. Later phases arrive through `onStatus`.
3616
+ */
2453
3617
  get status() {
2454
3618
  return this._workStatus.status;
2455
3619
  }
2456
- // Subscribe to opaque work-status updates. The callback fires on
2457
- // every phase the server pushes; when `status.pending` flips to
2458
- // `false` the work is terminal (a success the app reacts to by
2459
- // re-opening the doc to load the now-available content, or a failure
2460
- // whose `message` the app shows). Fires once on attach with the
2461
- // current status when one is already known, so a late subscriber is
2462
- // not stranded. Returns an unsubscribe.
2463
- //
2464
- // The app must not parse `message` or infer phases from it: the
2465
- // server owns the wording and the lifecycle. This surface exists so
2466
- // the app can *display* progress, not understand it.
3620
+ // The platform's work status alone, without the SDK's own notice over it.
3621
+ // Every SDK-side decision about whether work is pending reads this, since
3622
+ // the notice describes this client's connection rather than the platform's
3623
+ // work.
3624
+ /** @internal */
3625
+ get _workStatusNow() {
3626
+ return this._workStatus.work;
3627
+ }
3628
+ // Tell the reader their edits are waiting to save, through `status`, while
3629
+ // they wait: on a connection that has not opened (`NEVER_OPENED_NOTICE_MS`),
3630
+ // or on one that leaves an edit unanswered (`UNANSWERED_OP_NOTICE_AFTER`).
3631
+ // The doc transport decides when; the notice clears itself once nothing is
3632
+ // waiting (`_fireSubscribers`). A stream raises it on the document that
3633
+ // opened it as well, since the app renders that document's status.
3634
+ /** @internal */
3635
+ _showConnectionNotice(kind = 'never_opened') {
3636
+ if (this._closed || this.savingCount === 0)
3637
+ return;
3638
+ this._workStatus.setNotice(SAVE_NOTICES[kind]);
3639
+ this._connectionNoticeObserver?.(kind);
3640
+ }
3641
+ _connectionNoticeObserver = null;
3642
+ /** @internal */
3643
+ _observeConnectionNotice(observer) {
3644
+ this._connectionNoticeObserver = observer;
3645
+ }
3646
+ /**
3647
+ * Calls `cb` with every work-status update, and once on attach when a
3648
+ * status is already known. `pending: false` marks the work finished, as a
3649
+ * success or as a failure whose `message` the app shows. Returns an
3650
+ * unsubscribe.
3651
+ *
3652
+ * Do not parse `message` or infer phases from it: its wording changes
3653
+ * without an SDK release.
3654
+ */
2467
3655
  onStatus(cb) {
2468
3656
  // See `onGone`: guard the cross-session listener leak.
2469
3657
  this._assertOpen();
2470
- return this._workStatus.on(cb);
3658
+ const stop = this._workStatus.on(cb);
3659
+ // A frozen copy hears the work that was pending when it loaded on its
3660
+ // change notice, which runs only while somebody listens. A live copy's
3661
+ // transport always delivers status.
3662
+ if (this._changeNotice === null || this._workStatus.work?.pending !== true)
3663
+ return stop;
3664
+ this._statusWatches += 1;
3665
+ if (!this._changed)
3666
+ this._watchForChange();
3667
+ let stopped = false;
3668
+ return () => {
3669
+ stop();
3670
+ if (stopped)
3671
+ return;
3672
+ stopped = true;
3673
+ this._statusWatches -= 1;
3674
+ this._unwatchIfIdle();
3675
+ this._settleChangeNotice();
3676
+ };
2471
3677
  }
2472
3678
  // Internal: store the latest status and fire listeners. Not
2473
3679
  // one-shot: every phase re-emits. A terminal status (`pending:
2474
3680
  // false`) is retained so a late `onStatus` subscriber still sees the
2475
3681
  // final state.
2476
3682
  /** @internal */
2477
- _emitStatus(status, opts) {
3683
+ _emitStatus(incoming, opts) {
2478
3684
  // A late reload/reconcile closure that resolved after `close()` must not
2479
3685
  // re-emit onto a torn-down doc (its undo stack is freed below in `close`).
2480
3686
  if (this._closed)
2481
3687
  return;
3688
+ // An import in flight holds the document against a settled status from
3689
+ // anywhere else (a late frame for the work that stored the file), because
3690
+ // an edit made before the import lands leaves the file unimportable. A
3691
+ // join reporting the import done clears `_awaitsImport`, which releases
3692
+ // it for a client waiting on another's import.
3693
+ const status = this._importHold !== null && this._awaitsImport && incoming.pending !== true
3694
+ ? this._importHold
3695
+ : incoming;
3696
+ const wasReadOnly = this.readOnly;
2482
3697
  // Terminal work that still reports itself as locking editing is inbound
2483
3698
  // work that ended without content: the platform clears the status when
2484
3699
  // hydration / extraction succeeds, so what it reports terminally is a
@@ -2495,6 +3710,11 @@ export class Document {
2495
3710
  // Refresh the undo-state notifier here so a subscribeUndoState-bound
2496
3711
  // button tracks the lock, not only CRDT edits.
2497
3712
  this._maybeFireUndoState();
3713
+ // An app re-reads `readOnly` in its `subscribe` handler, so a status that
3714
+ // locks or releases a document it has already bound has to reach that
3715
+ // handler, or its edit controls stay as they were.
3716
+ if (this.readOnly !== wasReadOnly)
3717
+ this._fireSubscribers({ kind: 'commands' });
2498
3718
  }
2499
3719
  // The current drive sync state of this document, or `null` when the
2500
3720
  // doc is not linked to a drive source. An app reads `state` / `canPush`
@@ -2586,8 +3806,242 @@ export class Document {
2586
3806
  this._resyncPushClientOpIds.clear();
2587
3807
  this._fireSubscribers({ kind: 'saving-count' });
2588
3808
  }
3809
+ this._provisionalInputs.clear();
2589
3810
  this._gone.mark(reason);
2590
3811
  }
3812
+ // --- References and read marks -------------------------------------------
3813
+ // How many handle `mutate` calls are running, and how many of those asked
3814
+ // to stay off the Undo stack. A commit inside any of them carries the
3815
+ // non-undoable origin, so a write the app asked to keep off the stack does
3816
+ // not reach it through a batch boundary or a nested handle.
3817
+ _mutateDepth = 0;
3818
+ _nonUndoableDepth = 0;
3819
+ // `dependsOn` / `markRead` writes waiting for the running mutate to finish.
3820
+ // A mutate can commit several times (`mutate-batching.ts`), and a write
3821
+ // left in the open transaction rides whichever commit comes next, so a
3822
+ // read mark could arrive before the results it acknowledges. Each runs as
3823
+ // a commit of its own after the mutate's last one.
3824
+ _inputsQueue = [];
3825
+ // The client op ids `_shipLocalOp` mints while a reference commit runs.
3826
+ _shipCapture = null;
3827
+ _provisionalInputs = new ProvisionalInputsTracker();
3828
+ /**
3829
+ * @internal The document's `updated_at` as this copy read it when it
3830
+ * opened. `markRead` records a document at this value. `null` for a
3831
+ * document not opened from the platform.
3832
+ */
3833
+ get updatedAt() {
3834
+ return joinStampOf(this) ?? null;
3835
+ }
3836
+ /** @internal Where a key's provisional entry is posted once the op
3837
+ * carrying its change is acknowledged. */
3838
+ _setProvisionalInputsPoster(poster) {
3839
+ this._provisionalInputs.setPoster(poster);
3840
+ // A post's answer names the key's revision after it, which reads the same
3841
+ // as a nudge: a status behind it fetches the change.
3842
+ this._provisionalInputs.onAnswer(poster === null ? null : (answer) => this._inputStatusNudged(answer.key, answer.rev));
3843
+ }
3844
+ /** @internal Run a handle's mutate, so its commits carry the origin it asked
3845
+ * for and the reference writes queued inside it commit after it. */
3846
+ _runInMutate(undoable, fn) {
3847
+ // A nested mutate that changes the origin first commits what the enclosing
3848
+ // one has written so far, and commits its own writes before handing back,
3849
+ // so each write reaches the Undo stack per the mutate that made it.
3850
+ const switchesOrigin = !undoable && this._nonUndoableDepth === 0 && this._mutateDepth > 0;
3851
+ if (switchesOrigin)
3852
+ this._commitPendingMutate();
3853
+ this._mutateDepth += 1;
3854
+ if (!undoable)
3855
+ this._nonUndoableDepth += 1;
3856
+ let threw = true;
3857
+ try {
3858
+ fn();
3859
+ threw = false;
3860
+ }
3861
+ finally {
3862
+ if (switchesOrigin)
3863
+ this._commitPendingMutate();
3864
+ this._mutateDepth -= 1;
3865
+ if (!undoable)
3866
+ this._nonUndoableDepth -= 1;
3867
+ if (this._mutateDepth === 0) {
3868
+ const queued = this._inputsQueue;
3869
+ this._inputsQueue = [];
3870
+ // A mutate that threw left its results partly written, so the marks
3871
+ // that acknowledge them are not written either.
3872
+ if (!threw)
3873
+ this._flushInputs(queued);
3874
+ }
3875
+ }
3876
+ }
3877
+ _commitPendingMutate() {
3878
+ if (this._doc.getPendingTxnLength() > 0)
3879
+ this._handleOwner.commitMutate();
3880
+ }
3881
+ get _mutateCommitOrigin() {
3882
+ return this._nonUndoableDepth > 0 ? NON_UNDOABLE_COMMIT_ORIGIN : MUTATE_COMMIT_ORIGIN;
3883
+ }
3884
+ /**
3885
+ * Records the documents `key` depends on, replacing what it held. `null`
3886
+ * removes the key and its read marks. Writes only what differs from this
3887
+ * copy, so a reference another reader adds or removes at the same time
3888
+ * keeps their change. Undoable, as part of the current step, unless called
3889
+ * inside `mutate(fn, { undoable: false })`.
3890
+ */
3891
+ dependsOn(key, refs) {
3892
+ this._handleOwner.assertWritable();
3893
+ if (!isInputKey(key))
3894
+ throw new TypeError(`dependsOn: "${String(key)}" is not a valid key`);
3895
+ if (refs !== null && !Array.isArray(refs)) {
3896
+ throw new TypeError('dependsOn: refs must be an array of references or null');
3897
+ }
3898
+ const parsed = [];
3899
+ for (const ref of refs ?? []) {
3900
+ const one = parseInputReference(ref);
3901
+ if (one === null)
3902
+ throw new TypeError('dependsOn: refs holds a value that is not a reference');
3903
+ if (one.extent === 'document' && this._docId !== null && one.doc_id === this._docId) {
3904
+ throw new Error('dependsOn: a document cannot reference itself');
3905
+ }
3906
+ parsed.push(one);
3907
+ }
3908
+ const refusal = this._inputsRuntime?.refusesReferences;
3909
+ if (refusal !== undefined && parsed.length > 0)
3910
+ throw new BrassError(refusal);
3911
+ const next = refs === null ? null : parsed;
3912
+ this._queueInputs(this._nonUndoableDepth === 0, () => {
3913
+ const refsMap = this._doc.getMap(INPUTS_REFS_ROOT);
3914
+ let changed = applyRootWrites(refsMap, inputRefsWrites(rootView(refsMap), key, next));
3915
+ if (next === null) {
3916
+ const readMap = this._doc.getMap(INPUTS_READ_ROOT);
3917
+ changed = applyRootWrites(readMap, inputReadClearWrites(rootView(readMap), key)) || changed;
3918
+ }
3919
+ return changed ? key : null;
3920
+ });
3921
+ }
3922
+ /**
3923
+ * @internal Records that this reader read files for the key `status` reports, and
3924
+ * acknowledged the removed files that status lists. With no `files`, every
3925
+ * file the status lists as added or modified is recorded at the version the
3926
+ * status reports. Otherwise each entry of `files` is recorded: an opened
3927
+ * copy at the `updatedAt` it was opened at (left out while its import is
3928
+ * pending, so it stays changed), and an entry of the status's own `files`
3929
+ * or `changed` at the version the status reports. Joins the current Undo
3930
+ * step unless `undoable` is false.
3931
+ */
3932
+ markRead(status, options) {
3933
+ this._handleOwner.assertWritable();
3934
+ const key = status?.key;
3935
+ if (!isInputKey(key))
3936
+ throw new TypeError('markRead: pass the status `subscribeInputStatus` reported');
3937
+ // A list of files passed where the options go would otherwise read as no
3938
+ // files, and record every changed one.
3939
+ if (Array.isArray(options))
3940
+ throw new TypeError('markRead: pass the files as `{ files }`');
3941
+ const files = options?.files;
3942
+ if (files !== undefined && !Array.isArray(files)) {
3943
+ throw new TypeError('markRead: `files` must be an array');
3944
+ }
3945
+ // The version the status reports for each file, which is what an entry
3946
+ // taken from the status is recorded at: an edit that lands after the
3947
+ // status stays changed.
3948
+ const reported = new Map();
3949
+ for (const file of status.inputs ?? [])
3950
+ reported.set(file.doc_id, file.updated_at);
3951
+ const items = files ?? status.changed.filter((c) => c.change !== 'removed');
3952
+ const reads = [];
3953
+ for (const item of items) {
3954
+ let docId;
3955
+ let version;
3956
+ if ('doc_id' in item) {
3957
+ // A removed file is acknowledged from `status.changed` below.
3958
+ if (item.change === 'removed')
3959
+ continue;
3960
+ docId = item.doc_id;
3961
+ version = reported.get(docId) ?? null;
3962
+ if (version === null) {
3963
+ throw new TypeError(`markRead: the status reports no version for ${docId}`);
3964
+ }
3965
+ }
3966
+ else {
3967
+ if (item.status?.pending === true)
3968
+ continue;
3969
+ docId = item.id;
3970
+ version = item.updatedAt;
3971
+ }
3972
+ const stamp = typeof version === 'string' ? inputStampFromUpdatedAt(version) : null;
3973
+ if (!isInputDocId(docId) || stamp === null) {
3974
+ throw new TypeError('markRead: each file must be a document opened from Brass or an entry of the status');
3975
+ }
3976
+ reads.push({ docId, stamp });
3977
+ }
3978
+ const removed = status.changed
3979
+ .filter((c) => c.change === 'removed')
3980
+ .filter((c) => isInputDocId(c.doc_id))
3981
+ .map((c) => ({ docId: c.doc_id, leftAt: c.updated_at ?? null }));
3982
+ this._queueInputs(options?.undoable !== false, () => {
3983
+ const readMap = this._doc.getMap(INPUTS_READ_ROOT);
3984
+ const view = rootView(readMap);
3985
+ let changed = false;
3986
+ const write = (docId, mark) => {
3987
+ const writes = inputReadMarkWrites(view, key, docId, mark);
3988
+ for (const k of writes.delete)
3989
+ delete view[k];
3990
+ Object.assign(view, writes.set);
3991
+ changed = applyRootWrites(readMap, writes) || changed;
3992
+ };
3993
+ for (const { docId, stamp } of reads) {
3994
+ write(docId, inputReadMarkFor(latestReadMark(view, key, docId), stamp));
3995
+ }
3996
+ for (const { docId, leftAt } of removed) {
3997
+ write(docId, inputRemovalMark(latestReadMark(view, key, docId), leftAt));
3998
+ }
3999
+ return changed ? key : null;
4000
+ });
4001
+ }
4002
+ _queueInputs(undoable, apply) {
4003
+ const entry = { undoable, apply };
4004
+ if (this._mutateDepth > 0) {
4005
+ this._inputsQueue.push(entry);
4006
+ return;
4007
+ }
4008
+ this._flushInputs([entry]);
4009
+ }
4010
+ // Commit queued reference writes in call order, one commit per run of
4011
+ // writes that share an origin, and wait to post each key they changed.
4012
+ _flushInputs(entries) {
4013
+ if (entries.length === 0 || this._readOnlyBecause !== null)
4014
+ return;
4015
+ this._ensureLocalUpdatesSubscribed();
4016
+ let i = 0;
4017
+ while (i < entries.length) {
4018
+ const undoable = entries[i].undoable;
4019
+ const keys = new Set();
4020
+ for (; i < entries.length && entries[i].undoable === undoable; i += 1) {
4021
+ const key = entries[i].apply();
4022
+ if (key !== null)
4023
+ keys.add(key);
4024
+ }
4025
+ if (keys.size === 0)
4026
+ continue;
4027
+ this._shipCapture = [];
4028
+ let shipped;
4029
+ try {
4030
+ this._doc.setNextCommitOrigin(undoable ? MUTATE_COMMIT_ORIGIN : NON_UNDOABLE_COMMIT_ORIGIN);
4031
+ this._doc.commit();
4032
+ }
4033
+ finally {
4034
+ shipped = this._shipCapture;
4035
+ this._shipCapture = null;
4036
+ }
4037
+ if (shipped.length === 0)
4038
+ continue;
4039
+ const state = decodeInputsRoots(this._doc.getMap(INPUTS_REFS_ROOT).toJSON(), this._doc.getMap(INPUTS_READ_ROOT).toJSON());
4040
+ for (const key of keys) {
4041
+ this._provisionalInputs.register(key, this._branchId, state.get(key) ?? { refs: [], read: {} }, shipped);
4042
+ }
4043
+ }
4044
+ }
2591
4045
  /**
2592
4046
  * The top-level collections of the stream this document is open at, keyed
2593
4047
  * as it keys them, each naming the kind of handle that reads it.
@@ -2607,11 +4061,13 @@ export class Document {
2607
4061
  */
2608
4062
  roots() {
2609
4063
  this._assertNotClosed();
4064
+ if (this._sealedRoots !== null)
4065
+ return this._sealedRoots.roots();
2610
4066
  const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
2611
4067
  const found = [];
2612
4068
  for (const [key, containerId] of Object.entries(source.getShallowValue())) {
2613
4069
  const kind = rootKindOf(containerId);
2614
- if (kind === null)
4070
+ if (kind === null || isReservedRootName(key))
2615
4071
  continue;
2616
4072
  // Reading the container's own count, rather than its value, is what
2617
4073
  // keeps this off the projection: an empty collection is skipped without
@@ -2619,6 +4075,8 @@ export class Document {
2619
4075
  const container = source.getContainerById(containerId);
2620
4076
  if (container === undefined || rootIsEmpty(container))
2621
4077
  continue;
4078
+ if (holdsOnlyMarkers(key, container))
4079
+ continue;
2622
4080
  found.push([key, kind]);
2623
4081
  }
2624
4082
  // Loro reports roots in registration order, which is an accident of who
@@ -2627,27 +4085,23 @@ export class Document {
2627
4085
  found.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
2628
4086
  return Object.fromEntries(found);
2629
4087
  }
2630
- // Plain-JS projection of the current state.
4088
+ // Plain-JS projection of the current state, leaving out a root that holds
4089
+ // nothing. A CRDT cannot store an empty collection, and the root a Document
4090
+ // registers on open is empty until written, so a projection that kept empty
4091
+ // roots would change shape when the storage swaps in place under a reader.
4092
+ // Every other root stays whatever its kind: the sealed-copy builder reads
4093
+ // this projection to refuse a kind the frozen form cannot carry.
2631
4094
  toJSON() {
2632
4095
  this._assertNotClosed();
2633
4096
  // When in checkout, read from the preview LoroDoc so the app
2634
4097
  // renders the historical state. Returning to head clears
2635
4098
  // `_checkout` and reads go back to the live `_doc`.
2636
- const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
2637
- const json = source.toJSON();
2638
- // A paged root holds its records inside page entries beside the store's
2639
- // own edit bookkeeping, so flatten it to the same record lookup
2640
- // `doc.map(root).read()` hands back: a serializer and a whole-document
2641
- // renderer read this surface, and a store that crossed the paging
2642
- // threshold has to reach them as records. Every root is asked, because a
2643
- // root no handle was ever taken on is paged by the same rule as one that
2644
- // was; a root the marker does not name comes back as loro produced it.
2645
- for (const [rootKey, value] of Object.entries(json)) {
2646
- if (!isRootLookup(value))
2647
- continue;
2648
- if (!readRootPaged(source, rootKey))
2649
- continue;
2650
- json[rootKey] = flattenPagedLookup(value, true);
4099
+ const json = this._sealedRoots !== null
4100
+ ? this._sealedRoots.toJSON()
4101
+ : engineJSON(this._checkout !== null ? this._checkout.previewDoc : this._doc);
4102
+ for (const [key, value] of Object.entries(json)) {
4103
+ if (projectsEmpty(value))
4104
+ delete json[key];
2651
4105
  }
2652
4106
  return json;
2653
4107
  }
@@ -2672,7 +4126,7 @@ export class Document {
2672
4126
  // handshake is finished; every other outcome leaves the doc diverged and
2673
4127
  // is the caller's cue to run the handshake again once the push settles.
2674
4128
  /** @internal */
2675
- maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
4129
+ maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs = []) {
2676
4130
  // `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
2677
4131
  // closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
2678
4132
  // closure can't touch freed WASM.
@@ -2684,7 +4138,7 @@ export class Document {
2684
4138
  if (this._outstandingByClientId.has(id))
2685
4139
  return 'in-flight';
2686
4140
  }
2687
- const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs);
4141
+ const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs);
2688
4142
  if (chunks === 'covered')
2689
4143
  return 'covered';
2690
4144
  if (chunks === 'undecodable')
@@ -2701,14 +4155,15 @@ export class Document {
2701
4155
  // this client holds CRDT knowledge the durable server state lacks,
2702
4156
  // and if so return the update blob that fills exactly those holes.
2703
4157
  //
2704
- // The durable frontier is the snapshot's version vector advanced by
2705
- // every tail op the server delivered (those ops are durable in the
2706
- // log even though no snapshot has absorbed them yet). Comparing the
2707
- // local doc against the SNAPSHOT vector alone would falsely flag the
2708
- // whole durable tail as missing and push it back on every join, which
2709
- // then grows the log and loops; folding the tail in is what keeps the
2710
- // handshake a no-op in the common "server already has everything"
2711
- // case. `decodeImportBlobMeta` reads each blob's end vector from its
4158
+ // What the server holds is the snapshot (every author's ops from zero to
4159
+ // its version vector) plus each tail op it served, range by range: a tail
4160
+ // op is durable in the log even though no snapshot has absorbed it. Holding
4161
+ // the tail as ranges rather than folding it into one counter per author is
4162
+ // what sees a hole: a log that lost an author's op and stored that author's
4163
+ // later ones can apply none of them, so the op that fills the hole is
4164
+ // exactly what has to go back. Leaving the tail out altogether would flag
4165
+ // the whole tail as missing and push it back on every join, which grows the
4166
+ // log and loops. `decodeImportBlobMeta` reads each blob's range from its
2712
4167
  // header without a full import, so this stays cheap.
2713
4168
  //
2714
4169
  // Returns the chunks to resync-push, `'covered'` when the durable state
@@ -2719,10 +4174,11 @@ export class Document {
2719
4174
  // nothing about whether the doc has diverged, so a caller healing a known
2720
4175
  // divergence must keep asking.
2721
4176
  //
2722
- // The common case is one chunk: an `update` export from the durable
2723
- // frontier carries only the missing ops plus their dependencies, never
2724
- // the whole history, and a realistic gap is far under the per-frame
2725
- // cap. When the missing slice exceeds the cap (a BIM-sized edit set:
4177
+ // The common case is one chunk: when each author is missing only its
4178
+ // newest ops, an `update` export carries exactly those, and a realistic
4179
+ // gap is far under the per-frame cap. A hole below ops the server holds
4180
+ // ships as its own `updates-in-range` span, one range per author per blob,
4181
+ // so a blob never re-sends what the server holds. When the missing slice exceeds the cap (a BIM-sized edit set:
2726
4182
  // a geometry import or bulk parametric change discarded then healed, or
2727
4183
  // a large offline divergence on reconnect), it is split into multiple
2728
4184
  // chunks via `updates-in-range` spans, each under the cap, so even a
@@ -2749,7 +4205,7 @@ export class Document {
2749
4205
  * @internal
2750
4206
  */
2751
4207
  _snapshotHasUnseenAuthor(persistedSnapshotVersionVector) {
2752
- if (this._closed)
4208
+ if (this._closed || this._sealedRoots !== null)
2753
4209
  return false;
2754
4210
  let durable;
2755
4211
  try {
@@ -2768,50 +4224,45 @@ export class Document {
2768
4224
  return false;
2769
4225
  }
2770
4226
  /** @internal */
2771
- computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
2772
- let durable;
4227
+ computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs, deliveringBlobs = []) {
4228
+ // Frozen content holds no local ops, so the durable frontier covers it.
4229
+ if (this._sealedRoots !== null)
4230
+ return 'covered';
4231
+ let held;
2773
4232
  try {
2774
- durable = VersionVector.decode(persistedSnapshotVersionVector);
2775
- for (const blob of durableTailBlobs) {
2776
- const meta = decodeImportBlobMeta(blob, false);
2777
- durable = mergeVersionVectors(durable, meta.partialEndVersionVector);
2778
- }
4233
+ // An op the transport has only just sent is on its way rather than
4234
+ // missing; counting it as held keeps the handshake from writing a
4235
+ // second copy of it beside the transport's own send.
4236
+ held = durableHoldings(persistedSnapshotVersionVector, [
4237
+ ...durableTailBlobs,
4238
+ ...deliveringBlobs,
4239
+ ]);
2779
4240
  }
2780
4241
  catch (err) {
2781
4242
  console.warn('[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob', err);
2782
4243
  return 'undecodable';
2783
4244
  }
2784
4245
  const local = this._doc.version();
2785
- const cmp = local.compare(durable);
2786
- // `compare` returns -1/0 when local is at or behind the durable
2787
- // frontier (nothing to push), 1 when local is strictly ahead, and
2788
- // undefined when the two are concurrent (local holds ops the server
2789
- // lacks AND vice versa). Push in the ahead and concurrent cases; the
2790
- // export from `durable` carries only the local-only ops either way.
2791
- if (cmp !== undefined && cmp <= 0)
4246
+ const missing = rangesMissingFrom(local, held);
4247
+ if (missing.length === 0)
2792
4248
  return 'covered';
2793
- // Common case: the whole missing slice fits in one frame. Keep this
2794
- // path byte-identical to a plain `update` export so the no-chunking
2795
- // case is unchanged.
2796
- const full = this._doc.export({ mode: 'update', from: durable });
2797
- if (full.byteLength <= RESYNC_PUSH_MAX_BYTES)
2798
- return [full];
2799
- // Large divergence: split the per-peer missing ranges into chunks.
2800
- return this.chunkMissingSlice(local, durable);
2801
- }
2802
- // The per-peer ranges local holds beyond `durable`, as half-open
2803
- // [from, to) counter ranges. `export({ mode: 'update', from })` covers
2804
- // exactly these ops; we re-express them as `updates-in-range` spans so
2805
- // they can be split.
2806
- missingRanges(local, durable) {
2807
- const durableMap = durable.toJSON();
2808
- const ranges = [];
2809
- for (const [peer, to] of local.toJSON()) {
2810
- const from = durableMap.get(peer) ?? 0;
2811
- if (to > from)
2812
- ranges.push({ peer, from, to });
2813
- }
2814
- return ranges;
4249
+ // Common case: each author is missing only its newest ops, which is an
4250
+ // `update` export from a version vector, and the slice fits in one frame.
4251
+ const tails = new Map(missing.map((r) => [r.peer, r]));
4252
+ const localMap = local.toJSON();
4253
+ if (tails.size === missing.length &&
4254
+ missing.every((r) => r.to === localMap.get(r.peer))) {
4255
+ const from = new Map(localMap);
4256
+ for (const r of missing)
4257
+ from.set(r.peer, r.from);
4258
+ const full = this._doc.export({
4259
+ mode: 'update',
4260
+ from: VersionVector.parseJSON(from),
4261
+ });
4262
+ if (full.byteLength <= RESYNC_PUSH_MAX_BYTES)
4263
+ return [full];
4264
+ }
4265
+ return oneRangePerAuthor(missing).flatMap((group) => this.chunkRanges(group, true));
2815
4266
  }
2816
4267
  // Recursively export the given ranges as one `updates-in-range` blob,
2817
4268
  // splitting by op count until each blob is under the per-chunk cap.
@@ -2850,9 +4301,6 @@ export class Document {
2850
4301
  ...this.chunkRanges(second, dropOversizedSingle),
2851
4302
  ];
2852
4303
  }
2853
- chunkMissingSlice(local, durable) {
2854
- return this.chunkRanges(this.missingRanges(local, durable), true);
2855
- }
2856
4304
  // A local commit re-exported as several op blobs, or null to ship it whole.
2857
4305
  //
2858
4306
  // `mutate` bounds what one commit carries by committing as it goes, but a
@@ -2934,17 +4382,19 @@ export class Document {
2934
4382
  return;
2935
4383
  this._suspended = true;
2936
4384
  this._setStreamsSuspended(true);
2937
- // A pending auto-checkpoint would otherwise fire against a parked
2938
- // Document and silently no-op via the scheduler's guard.
2939
- // Cancelling here keeps the timer count clean across long-lived
2940
- // caches and avoids the no-op fetch flush.
2941
- this._checkpoints.cancelAuto();
4385
+ // Calls still waiting for a marker get one now rather than after the
4386
+ // spacing, and a pending auto-checkpoint is cancelled rather than left to
4387
+ // fire against a parked Document.
4388
+ this._checkpoints.detach();
2942
4389
  this._subs.clear();
2943
4390
  this._undoStateSubs.clear();
2944
4391
  this._gone.clearListeners();
2945
4392
  this._workStatus.clearListeners();
2946
4393
  this._driveStatus.clearListeners();
2947
4394
  this._drivePublishTarget.clearListeners();
4395
+ this._inputFollowers.stopAll();
4396
+ this._inputStatusHub?.dispose();
4397
+ this._inputStatusHub = null;
2948
4398
  // Presence subscribes its OWN WS topic and keeps it alive across a park
2949
4399
  // (resume does not re-create the controller), so drop its app-side
2950
4400
  // change listeners here the same way as the sets above: a parked doc
@@ -2959,11 +4409,11 @@ export class Document {
2959
4409
  // so a subscription an app registered before `close()` would keep firing
2960
4410
  // the app's callback on a doc it has closed. Detaching first stops that;
2961
4411
  // fresh handles are minted on resume + next access.
2962
- for (const entry of this._rootHandles.values()) {
2963
- if (entry.kind === 'text')
2964
- entry.handle._teardown();
2965
- else
2966
- teardownRootHandle(entry.handle);
4412
+ // The handles over the ones torn down go with them, so a resumed open is
4413
+ // handed fresh handles like any other.
4414
+ if (this._sealedRoots === null) {
4415
+ this._teardownRootHandles();
4416
+ this._handles.clear();
2967
4417
  }
2968
4418
  this._rootHandles.clear();
2969
4419
  // Drop the ordered-map cache too. Each wrapper holds the specific root
@@ -3002,9 +4452,24 @@ export class Document {
3002
4452
  get _openGeneration() {
3003
4453
  return this._openEpoch;
3004
4454
  }
4455
+ /** @internal Whether the latest join reported the document as its stored
4456
+ * file with no import run yet. Set by the open layer on every join, so an
4457
+ * importer can hold the document before its claim goes out. */
4458
+ _awaitsImport = false;
4459
+ /** @internal The status an importer holds the document under, or null. */
4460
+ _importHold = null;
4461
+ /** @internal Why this client stopped waiting to import the document, or
4462
+ * null. */
4463
+ _importAbandoned = null;
3005
4464
  close() {
3006
4465
  if (this._closed)
3007
4466
  return;
4467
+ // Before anything is torn down: the final marker reads the seq and the
4468
+ // unacked-op count this close is about to drop.
4469
+ this._checkpoints.detach();
4470
+ this._stopChangeNotice?.();
4471
+ this._stopChangeNotice = null;
4472
+ this._changedListeners.clear();
3008
4473
  this._closeStreams();
3009
4474
  this._closed = true;
3010
4475
  this._suspended = false;
@@ -3020,6 +4485,10 @@ export class Document {
3020
4485
  this._undo.free();
3021
4486
  this._undo = null;
3022
4487
  }
4488
+ if (!this._undoGroup.isOwner(this)) {
4489
+ this._undoGroup.delete(this);
4490
+ this._undoGroup.changed();
4491
+ }
3023
4492
  this._thumbnails.destroy();
3024
4493
  this._checkpoints.cancelAuto();
3025
4494
  this._localOpHook = null;
@@ -3037,15 +4506,14 @@ export class Document {
3037
4506
  this._workStatus.clearListeners();
3038
4507
  this._driveStatus.clearListeners();
3039
4508
  this._drivePublishTarget.clearListeners();
4509
+ this._inputFollowers.stopAll();
4510
+ this._inputStatusHub?.dispose();
4511
+ this._inputStatusHub = null;
3040
4512
  // Detach handle subscriptions before freeing the doc (below): a handle's
3041
4513
  // per-container Loro subscription outlives the cache reference, and
3042
4514
  // `_doc.free()` must not run with subscriptions still attached.
3043
- for (const entry of this._rootHandles.values()) {
3044
- if (entry.kind === 'text')
3045
- entry.handle._teardown();
3046
- else
3047
- teardownRootHandle(entry.handle);
3048
- }
4515
+ if (this._sealedRoots === null)
4516
+ this._teardownRootHandles();
3049
4517
  this._rootHandles.clear();
3050
4518
  this._orderedMapHandles.clear();
3051
4519
  // Free a live historical-checkout preview: it is a distinct LoroDoc
@@ -3054,7 +4522,7 @@ export class Document {
3054
4522
  // mirrors `_checkoutVersion` / `returnToHead`: the frozen cold-link case
3055
4523
  // has `previewDoc === _doc`, where `_doc.free()` below already covers it
3056
4524
  // and an unguarded free would double-free.
3057
- if (this._checkout && this._checkout.previewDoc !== this._doc) {
4525
+ if (this._checkout && this._checkout.previewDoc !== this._engine) {
3058
4526
  try {
3059
4527
  this._checkout.previewDoc.free();
3060
4528
  }
@@ -3066,7 +4534,7 @@ export class Document {
3066
4534
  // Loro's WASM has a `free()` to drop the heap-allocated doc state.
3067
4535
  // Calling it after `close()` invalidates the doc, which is what we
3068
4536
  // want; future mutate/subscribe calls throw above.
3069
- this._doc.free();
4537
+ this._engine?.free();
3070
4538
  }
3071
4539
  }
3072
4540
  //# sourceMappingURL=documents.js.map