@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
package/src/documents.ts CHANGED
@@ -17,11 +17,8 @@ import {
17
17
  decodeImportBlobMeta,
18
18
  } from 'loro-crdt';
19
19
 
20
- import { MutateBatcher } from './mutate-batching.js';
21
- import {
22
- UndoController,
23
- type UndoControllerOptions,
24
- } from './undo.js';
20
+ import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
21
+ import type { DocSyncDiagnostics, UndoControllerOptions } from './types.js';
25
22
  import {
26
23
  createOrderedMapHandle,
27
24
  createRootListHandle,
@@ -29,22 +26,24 @@ import {
29
26
  rebindRootListAnchor,
30
27
  rebindRootMapAnchor,
31
28
  resyncRootHandle,
29
+ snapshotRootMapKeys,
32
30
  teardownRootHandle,
33
- wrapLoroMapProxy,
34
- type ChangeSource,
35
- type HandleOwner,
36
- type ListHandle,
37
- type MapHandle,
38
- type OrderedMapHandle,
31
+ type StateVersion,
39
32
  } from './handles.js';
33
+ import type {
34
+ ChangeSource,
35
+ HandleOwner,
36
+ ListHandle,
37
+ MapHandle,
38
+ OrderedMapHandle,
39
+ } from './handle-types.js';
40
40
  import {
41
- CURRENT_PAGING_SCHEME,
42
- isPagedByPageCount,
41
+ flattenPagedLookup,
42
+ isPagedMarker,
43
43
  META_KEY,
44
- PAGE_COUNT_KEY,
45
- PAGING_SCHEME_KEY,
46
- RECORDS_KEY,
44
+ pagedRootKey,
47
45
  } from './record-paging.js';
46
+ import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
48
47
  import type {
49
48
  BranchSummary,
50
49
  DocumentCheckpoint,
@@ -58,6 +57,7 @@ import {
58
57
  GoneSignal,
59
58
  WorkStatusSignal,
60
59
  DriveStatusSignal,
60
+ DrivePublishTargetSignal,
61
61
  type DocumentGoneListener,
62
62
  type DocumentGoneReason,
63
63
  type DocumentGoneUnsubscribe,
@@ -69,6 +69,8 @@ import {
69
69
  type DocumentDriveStatusUnsubscribe,
70
70
  type DocumentDriveTrackingState,
71
71
  type DriveMirrorDirection,
72
+ type DrivePublishTarget,
73
+ type DrivePublishState,
72
74
  } from './document-signals.js';
73
75
  import {
74
76
  ThumbnailCapture,
@@ -79,10 +81,12 @@ import {
79
81
  CheckpointScheduler,
80
82
  type CheckpointCreator,
81
83
  } from './document-checkpoints.js';
84
+ import { whenSaved } from './when-saved.js';
82
85
  import {
83
86
  BranchController,
84
87
  type BranchProvider,
85
88
  } from './document-branches.js';
89
+ import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
86
90
  import {
87
91
  CommandMenu,
88
92
  type Command,
@@ -120,6 +124,7 @@ export type {
120
124
  DocumentDriveStatusUnsubscribe,
121
125
  DocumentDriveTrackingState,
122
126
  DriveMirrorDirection,
127
+ DrivePublishTarget,
123
128
  };
124
129
 
125
130
  // The proxy callback receives an object with the root map (`entities`)
@@ -140,21 +145,23 @@ export interface DocumentMutator<E extends object = EntityProxy> {
140
145
  }
141
146
 
142
147
  // What the entity proxy presents by default: any nested plain object
143
- // shape backed by a LoroMap / LoroList container tree. `Record<string,
148
+ // shape backed by a LoroMap / LoroMovableList container tree. `Record<string,
144
149
  // unknown>` because Loro's value type is dynamic; typed apps pass their
145
150
  // shape to `DocumentMutator<E>` / `doc.mutate<E>` instead.
146
151
  export type EntityProxy = Record<string, unknown>;
147
152
 
148
- export type DocumentSubscriber = (event: unknown) => void;
153
+ export type DocumentSubscriber = (event: SyntheticSubscribeEvent) => void;
149
154
  export type DocumentUnsubscribe = () => void;
150
155
 
151
- // Discriminated event the SDK fires through `doc.subscribe` for
152
- // non-CRDT changes (checkout, return-to-head, saving count). Apps can
153
- // ignore the event arg and re-read in the handler, or narrow on
154
- // `event.kind`. CRDT-origin events keep their Loro shape (also
155
- // delivered through the same `subscribe`).
156
+ // The discriminated event `doc.subscribe` fires. Every event the document
157
+ // produces arrives in this shape, whether a content change or one of the
158
+ // SDK's own metadata ticks, so an app can ignore the argument and re-read in
159
+ // the handler, or narrow on `event.kind`.
156
160
  export type SyntheticSubscribeEvent =
157
- | { kind: 'checkout'; version: number }
161
+ // A content change, with what produced it. `source` is the same value the
162
+ // handle surface reports as `origin.source`.
163
+ | { kind: 'change'; source: ChangeSource }
164
+ | { kind: 'history'; version: number }
158
165
  | { kind: 'return-to-head' }
159
166
  // A bare re-read tick: the document's branch state settled or changed
160
167
  // (the list loaded on open, or a switch/create landed). Carries no
@@ -163,6 +170,12 @@ export type SyntheticSubscribeEvent =
163
170
  // branch model, management is hosted.
164
171
  | { kind: 'branches' }
165
172
  | { kind: 'saving-count' }
173
+ // The document's capacity verdict changed, so `doc.atCapacity` reads
174
+ // differently than it did. Fired on its own kind rather than folded into
175
+ // `saving-count` because an app acts on it differently: a saving count
176
+ // moves constantly and settles, while this one gates whether adding more
177
+ // is worth offering at all.
178
+ | { kind: 'capacity' }
166
179
  | { kind: 'commands' };
167
180
 
168
181
  // Snapshot of the Document's undo/redo state, pushed to
@@ -193,7 +206,6 @@ const REDO_COMMIT_ORIGIN = 'brass.redo';
193
206
  function changeSourceOf(event: unknown): ChangeSource {
194
207
  const by = (event as { by?: unknown }).by;
195
208
  if (by === 'import') return 'remote';
196
- if (by === 'checkout') return 'checkout';
197
209
  // by === 'local': disambiguate via the commit-origin tag.
198
210
  const origin = (event as { origin?: unknown }).origin;
199
211
  if (origin === UNDO_COMMIT_ORIGIN) return 'undo';
@@ -201,20 +213,53 @@ function changeSourceOf(event: unknown): ChangeSource {
201
213
  return 'local';
202
214
  }
203
215
 
204
- // Per-chunk cap on a resync-push blob (durability handshake). Each push
205
- // ships as one op frame, and the transport carries 128 KB; the blob is
206
- // base64-encoded (~1.33x) inside a JSON envelope, so the raw blob must
207
- // stay well under that. 64 KB base64s to ~85 KB. A larger missing slice
208
- // is split into multiple chunks (see `computeResyncPush`), each capped.
209
- const RESYNC_PUSH_MAX_BYTES = 64 * 1024;
210
-
211
- // Hard ceiling for a single irreducible op (one Loro op, unsplittable).
212
- // Sized so the base64 frame still clears the 128 KB limit (90 KB base64s
213
- // to ~120 KB plus envelope). An op this large cannot occur on a doc
214
- // whose normal edits send at all (same frame limit), so this only guards
215
- // a pathological op; it is skipped with a warning rather than retried
216
+ // Per-chunk cap on a blob this Document pushes as several ops: a durability
217
+ // resync's missing slice (`computeResyncPush`) and a local commit no write
218
+ // budget bounded (`splitLocalUpdate`).
219
+ //
220
+ // Each chunk goes out through the same local-op hook an ordinary commit
221
+ // uses, so the transport picks itself by size and a chunk this large takes
222
+ // the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
223
+ // makes a heal the most expensive write the SDK performs: the slice a bulk
224
+ // write leaves behind is the whole document, and at frame size that is
225
+ // thousands of ops, each one landing on the document's single storage
226
+ // partition at exactly the size that stays inline on its row. Measured, a
227
+ // 40 MB divergence healed in 16 KB chunks throttled the partition and left
228
+ // the join a tail it refused to serve. The same divergence at this size is
229
+ // tens of ops whose bytes go to the bucket instead.
230
+ const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
231
+
232
+ // Hard ceiling for a single irreducible op (one Loro op, unsplittable),
233
+ // beyond which the chunk is skipped with a warning rather than retried
216
234
  // forever, while the doc's other chunks still heal.
217
- const RESYNC_PUSH_HARD_MAX_BYTES = 90 * 1024;
235
+ //
236
+ // Skipping drops an edit the server never received, so this sits at exactly
237
+ // what the write path can carry rather than at what a frame holds. Base64
238
+ // turns B raw bytes into ceil(B/3)*4 characters, so the last raw size whose
239
+ // encoding still fits the durable cap is three quarters of it. Derived
240
+ // rather than written out, because the two have to move together and a
241
+ // literal here would let the ceiling drift above what the server accepts,
242
+ // which turns a heal into silent loss. A blob this large travels through
243
+ // the staged upload rather than a request body, a path the write already
244
+ // takes on its own size.
245
+ const RESYNC_PUSH_HARD_MAX_BYTES = Math.floor((MAX_OP_BLOB_CHARS * 3) / 4);
246
+
247
+ // What one run of the durability handshake settled. Only `covered` means the
248
+ // durable frontier holds everything local does; a caller driving the
249
+ // handshake to close a known divergence keeps asking on anything else.
250
+ //
251
+ // covered the server holds it all, so the handshake is finished
252
+ // pushed chunks are on the wire and their acks decide the outcome
253
+ // in-flight an earlier push has not settled, so this run stood down
254
+ // undecodable the frontier did not parse, so nothing was compared
255
+ // closed the doc was freed before the handshake ran
256
+ /** @internal */
257
+ export type ResyncPushOutcome =
258
+ | 'covered'
259
+ | 'pushed'
260
+ | 'in-flight'
261
+ | 'undecodable'
262
+ | 'closed';
218
263
 
219
264
  // Element-wise max of two Loro version vectors. `VersionVector` exposes
220
265
  // no public join, so round-trip through its `Map<PeerID, number>` JSON
@@ -233,64 +278,193 @@ function mergeVersionVectors(
233
278
  return VersionVector.parseJSON(merged);
234
279
  }
235
280
 
236
- // Split half-open per-peer counter ranges into two halves by total op
237
- // count, splitting at most one range across the boundary. `total` must
238
- // be >= 2 so both halves are non-empty (the caller handles total === 1
239
- // as the irreducible single-op case). Order is preserved.
240
- function splitRangesByLength(
241
- ranges: { peer: PeerID; from: number; to: number }[],
242
- total: number,
243
- ): [
244
- { peer: PeerID; from: number; to: number }[],
245
- { peer: PeerID; from: number; to: number }[],
246
- ] {
247
- const half = Math.floor(total / 2);
248
- const first: { peer: PeerID; from: number; to: number }[] = [];
249
- const second: { peer: PeerID; from: number; to: number }[] = [];
281
+ type CounterRange = { peer: PeerID; from: number; to: number };
282
+
283
+ function rangeOpCount(ranges: readonly CounterRange[]): number {
284
+ return ranges.reduce((n, r) => n + (r.to - r.from), 0);
285
+ }
286
+
287
+ // Split half-open per-peer counter ranges in two after `at` ops, splitting at
288
+ // most one range across the boundary. Order is preserved.
289
+ function splitRangesAt(
290
+ ranges: readonly CounterRange[],
291
+ at: number,
292
+ ): [CounterRange[], CounterRange[]] {
293
+ const first: CounterRange[] = [];
294
+ const second: CounterRange[] = [];
250
295
  let acc = 0;
251
296
  for (const r of ranges) {
252
297
  const len = r.to - r.from;
253
- if (acc >= half) {
298
+ if (acc >= at) {
254
299
  second.push(r);
255
- } else if (acc + len <= half) {
300
+ } else if (acc + len <= at) {
256
301
  first.push(r);
257
302
  acc += len;
258
303
  } else {
259
- const splitAt = r.from + (half - acc);
304
+ const splitAt = r.from + (at - acc);
260
305
  first.push({ peer: r.peer, from: r.from, to: splitAt });
261
306
  second.push({ peer: r.peer, from: splitAt, to: r.to });
262
- acc = half;
307
+ acc = at;
263
308
  }
264
309
  }
265
310
  return [first, second];
266
311
  }
267
312
 
268
- // The record store's page count off a doc's `meta` map, or 0 when
269
- // unpaged. `handles.ts` reads it to resolve which page a key hashes to
270
- // AND to decide whether the store is paged at all (on every root-map
271
- // read / edit). Read by PATH, not `getMap(META_KEY)`: `getMap` is
272
- // get-or-create and would materialize an empty `meta` root on every
273
- // unpaged document, polluting its wire shape; `getByPath` is a pure read
274
- // returning undefined when `meta` does not exist.
275
- function readStorePageCount(source: LoroDoc): number {
276
- const pc = source.getByPath(`${META_KEY}/${PAGE_COUNT_KEY}`);
277
- // Same "is paged" predicate as the formats/core readers, so the SDK
278
- // handle path and the store readers cannot disagree on a malformed
279
- // count (e.g. a non-finite value must read as unpaged everywhere).
280
- return isPagedByPageCount(pc) ? (pc as number) : 0;
313
+ // Split into two halves by op count. `total` must be >= 2 so both halves are
314
+ // non-empty (the caller handles total === 1 as the irreducible single-op case).
315
+ function splitRangesByLength(
316
+ ranges: readonly CounterRange[],
317
+ total: number,
318
+ ): [CounterRange[], CounterRange[]] {
319
+ return splitRangesAt(ranges, Math.floor(total / 2));
320
+ }
321
+
322
+ // Split into `parts` pieces of roughly equal op count, in order.
323
+ //
324
+ // A caller that already knows the whole range's byte size can name the number
325
+ // of pieces it wants instead of discovering it by halving, which costs one
326
+ // export per level and re-exports the same ops at every level: an 8 MB commit
327
+ // takes 31 exports to reach sixteen chunks by halving and 16 by naming them.
328
+ // The pieces are still measured afterwards, so an uneven commit that leaves a
329
+ // piece over the cap is split further from there.
330
+ function splitRangesEvenly(
331
+ ranges: readonly CounterRange[],
332
+ parts: number,
333
+ ): CounterRange[][] {
334
+ let rest = [...ranges];
335
+ let remaining = rangeOpCount(rest);
336
+ if (parts <= 1 || remaining <= parts) return [rest];
337
+ const out: CounterRange[][] = [];
338
+ for (let left = parts; left > 1; left -= 1) {
339
+ const at = Math.floor(remaining / left);
340
+ if (at < 1) break;
341
+ const [first, second] = splitRangesAt(rest, at);
342
+ out.push(first);
343
+ rest = second;
344
+ remaining -= at;
345
+ }
346
+ out.push(rest);
347
+ return out.filter((piece) => rangeOpCount(piece) > 0);
348
+ }
349
+
350
+ // The `meta` root a document's paged marker lives in, or `null` while the
351
+ // document has none.
352
+ //
353
+ // The marker is read on every root-map read, edit and keyed lookup, and a
354
+ // reader that answers from a copy taken earlier is what puts paged-shaped keys
355
+ // into a store whose marker says unpaged, or reads a root a peer paged as its
356
+ // raw page entries. Answering off the live container instead makes both
357
+ // impossible: a peer's write lands in that same container, so the next read
358
+ // sees it, with no invalidation to get right.
359
+ //
360
+ // What is remembered per document is the resolved CONTAINER, and only once the
361
+ // root exists. A root handle reads through to the document's current state, so
362
+ // holding one keeps the value live: a peer's import and a checkout both land
363
+ // on the same container the cell already points at.
364
+ //
365
+ // Holding it is also what keeps this off the leak. Every `getMap` mints a new
366
+ // wasm-bindgen handle that dropping does not reclaim (the same fact `pageValue`
367
+ // and `entryExists` free around), and this resolves on every root-map read,
368
+ // edit and keyed lookup, so minting per call retains ~145 bytes each for the
369
+ // length of the session. One handle per document is a bounded cost instead.
370
+ //
371
+ // Resolution goes through `getByPath` rather than `getMap`, which is
372
+ // get-or-create and would materialize an empty `meta` on a document that has
373
+ // none (in the wire shape of every unpaged document, and as a local write on a
374
+ // read-only open). Its cost tracks the document's container count, and it is
375
+ // paid once per document.
376
+ const metaRoots = new WeakMap<LoroDoc, { map: LoroMap | null }>();
377
+
378
+ function metaRoot(source: LoroDoc): LoroMap | null {
379
+ let cell = metaRoots.get(source);
380
+ if (cell === undefined) {
381
+ cell = { map: null };
382
+ metaRoots.set(source, cell);
383
+ }
384
+ if (cell.map === null) {
385
+ const found = source.getByPath(META_KEY);
386
+ if (!(found instanceof LoroMap)) return null;
387
+ cell.map = found;
388
+ }
389
+ return cell.map;
390
+ }
391
+
392
+ /** The `meta` root, creating it if `source` has none, and remembering it so
393
+ * later reads resolve without minting a second handle. */
394
+ function metaRootOrCreate(source: LoroDoc): LoroMap {
395
+ const existing = metaRoot(source);
396
+ if (existing !== null) return existing;
397
+ const created = source.getMap(META_KEY);
398
+ metaRoots.set(source, { map: created });
399
+ return created;
281
400
  }
282
401
 
283
- // The paging scheme a paged store was written under, off the same `meta` map
284
- // as the page count (read by path, not `getMap`, for the same reason: a pure
285
- // read that never materializes an empty `meta` root). A paged store predating
286
- // the stamp reports the current scheme, so it stays editable; a store written
287
- // under a newer scheme reports that number, and the edit path refuses it
288
- // rather than hashing a key into the wrong page.
289
- function readStorePagingScheme(source: LoroDoc): number {
290
- const s = source.getByPath(`${META_KEY}/${PAGING_SCHEME_KEY}`);
291
- return typeof s === 'number' && s > 0 ? s : CURRENT_PAGING_SCHEME;
402
+ /** Whether one root map is paged. Same predicate the record-store readers
403
+ * use, so the SDK handle path and those readers cannot disagree. */
404
+ function readRootPaged(source: LoroDoc, rootKey: string): boolean {
405
+ return isPagedMarker(metaRoot(source)?.get(pagedRootKey(rootKey)));
292
406
  }
293
407
 
408
+ /** Whether a root's JSON is the keyed lookup a record store flattens. A root
409
+ * that projects to a list or a scalar cannot hold pages whatever `meta`
410
+ * says, so it is handed back untouched. */
411
+ function isRootLookup(value: unknown): value is Record<string, unknown> {
412
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
413
+ }
414
+
415
+ // Mark `rootKey` paged, under a `meta` key of its own. Written ONCE and never
416
+ // revised: pages are addressed by session and index, so nothing downstream
417
+ // needs to know how many there are or how much the store grows afterwards.
418
+ //
419
+ // A key per root is what lets two peers page two different roots at the same
420
+ // time. One entry naming every paged root would have to be read, extended and
421
+ // written back whole, and the losing side of that race takes its root's marker
422
+ // with it (see `PAGED_ROOT_PREFIX`); two keys merge.
423
+ //
424
+ // The marker commits on its own, under an origin the undo stack excludes, and
425
+ // `commitPending` lands whatever the running mutation has written so far
426
+ // first. Two properties ride on that. Peers and the server never see a page
427
+ // without the marker that says how to read it, because the marker's op
428
+ // precedes every op carrying a page. And nothing an app authored is taken out
429
+ // of the undo stack with it: a commit carrying both would exclude the records
430
+ // as well as the marker.
431
+ function pageRoot(
432
+ source: LoroDoc,
433
+ rootKey: string,
434
+ commitPending: () => void,
435
+ ): void {
436
+ if (readRootPaged(source, rootKey)) return;
437
+ commitPending();
438
+ metaRootOrCreate(source).set(pagedRootKey(rootKey), true);
439
+ source.setNextCommitOrigin(PAGING_COMMIT_ORIGIN);
440
+ source.commit();
441
+ }
442
+
443
+ // Why a live Document refuses local writes. Each names a refusal the server
444
+ // would make, so `mutate` can say which one applies rather than answering
445
+ // every refusal with one message.
446
+ /** @internal */
447
+ export type ReadOnlyReason =
448
+ // Opened through a view-only share link.
449
+ | 'link'
450
+ // The reader's grant on this document does not carry write access.
451
+ | 'grant'
452
+ // The app acting on the reader's behalf may not edit this document.
453
+ | 'app'
454
+ // A change this copy holds was refused, so it shows something no other
455
+ // reader has. Editing on top of it builds more of the same, and `reload()`
456
+ // is what returns the copy to what the platform holds.
457
+ | 'unsaved';
458
+
459
+ const READ_ONLY_MESSAGES: Record<ReadOnlyReason, string> = {
460
+ link: 'Document is read-only: it was opened via a view-only share link.',
461
+ grant: 'Document is read-only: you have view-only access to it.',
462
+ app: 'Document is read-only: this app is not permitted to edit it.',
463
+ unsaved:
464
+ 'Document is read-only: a change could not be saved, so this copy holds ' +
465
+ 'work the platform does not. Brass is replacing it with the platform copy.',
466
+ };
467
+
294
468
  export interface DocumentOptions {
295
469
  // Root key the Document wraps. Defaults to `"entities"` to match the
296
470
  // wire protocol's top-level shape.
@@ -307,6 +481,10 @@ export interface DocumentOptions {
307
481
  // True when opened at a historical version. A read-only Document
308
482
  // throws from `mutate` and carries no live WS transport.
309
483
  readOnly?: boolean;
484
+ // Why `readOnly` was set, which picks the message `mutate` throws.
485
+ // Defaults to the view-only link.
486
+ /** @internal */
487
+ readOnlyReason?: ReadOnlyReason;
310
488
  // The historical version materialized at, when opened via `{ version
311
489
  // }`; `null` for a live (head) open. The server's *effective* version,
312
490
  // which may round down when compaction has TTL'd the exact op.
@@ -361,13 +539,15 @@ export class Document {
361
539
  // leaves this `false`.
362
540
  private readonly _isFrozen: boolean;
363
541
  // Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
364
- // is a version-pinned open with no live channel). The anonymous
365
- // link-guest open sets this: the Document keeps a live WS subscription
366
- // and applies incoming remote ops, but local `mutate` is rejected, so a
367
- // guest sees edits stream in while being unable to write.
368
- private readonly _forcedReadOnly: boolean;
369
- private readonly _root: LoroMap;
370
- private readonly _entities: EntityProxy;
542
+ // is a version-pinned open with no live channel): the Document keeps a
543
+ // live WS subscription and applies incoming remote ops, but local
544
+ // `mutate` is rejected, so the reader sees edits stream in while being
545
+ // unable to write. Set at open from the join's `can_edit` (a viewer's
546
+ // grant, a view-only link), and latched on afterwards by
547
+ // `_markReadOnly` when the server refuses a write on the same grounds.
548
+ private _forcedReadOnly: boolean;
549
+ // Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
550
+ private _readOnlyReason: ReadOnlyReason;
371
551
  // The UndoManager is constructed eagerly so the first call to
372
552
  // `doc.mutate(...)` lands in the undo stack. Lazy construction
373
553
  // (deferred to first `doc.undo()`) would miss every commit made
@@ -381,7 +561,9 @@ export class Document {
381
561
  // app reconciles by value and reads `origin.source` only when it has a
382
562
  // non-render reason to know the cause. A throwing subscriber is swallowed
383
563
  // so it cannot strand the others or the WS router.
384
- private readonly _subs = createEmitter<unknown>({ onError: () => {} });
564
+ private readonly _subs = createEmitter<SyntheticSubscribeEvent>({
565
+ onError: () => {},
566
+ });
385
567
  // Subscribers registered via `subscribeUndoState`. Fired after
386
568
  // every underlying Loro event when `canUndo()` or `canRedo()`
387
569
  // changed value vs the last fire, so UI bound to undo/redo
@@ -435,10 +617,25 @@ export class Document {
435
617
  this._fireSubscribers({ kind: 'branches' });
436
618
  },
437
619
  });
620
+ // The version of the newest picture the server holds of this document, or
621
+ // `null` for none. Seeded from the join and moved by this instance's own
622
+ // uploads; a picture older than the document's head is what arms the seed
623
+ // trigger, so a placeholder taken before the content arrived is replaced
624
+ // once it does.
625
+ private _thumbnailVersion: number | null = null;
438
626
  private readonly _thumbnails = new ThumbnailCapture({
627
+ // `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
628
+ // thumbnail from a reader it will not take writes from, so a capture
629
+ // there costs a request and a warn to be told so. The load lock is the
630
+ // other half of `readOnly` and must NOT gate this, because a document
631
+ // still hydrating is exactly the one the seed watch is armed for, and
632
+ // the arm happens while that lock is still on.
439
633
  isCaptureAllowed: (): boolean =>
440
- this._isLive && this._checkout === null,
634
+ this._isLive && this._checkout === null && !this._forcedReadOnly,
441
635
  isAttached: (): boolean => this._isLive,
636
+ onCaptured: (): void => {
637
+ this._thumbnailVersion = this.currentSeq ?? 0;
638
+ },
442
639
  });
443
640
  private readonly _checkpoints = new CheckpointScheduler({
444
641
  docId: (): string | null => this._docId,
@@ -449,6 +646,7 @@ export class Document {
449
646
  sealUndoStep: (): void => {
450
647
  if (this._undo) this._undo.sealStep();
451
648
  },
649
+ whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
452
650
  });
453
651
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
454
652
  // then throws with a clear message rather than silently dropping.
@@ -532,27 +730,48 @@ export class Document {
532
730
  // Live = neither closed nor parked. This is the public-surface gate;
533
731
  // internal ingress (remote ops, status emits) deliberately checks
534
732
  // `_closed` alone, because a parked doc keeps applying remote state.
535
- private get _isLive(): boolean {
733
+ // Readable from the SDK's own layers (the client's open-document set
734
+ // gates on it) rather than only from inside this class.
735
+ /** @internal */
736
+ get _isLive(): boolean {
536
737
  return !this._closed && !this._suspended;
537
738
  }
538
739
 
539
740
  private _assertOpen(): void {
540
741
  if (!this._isLive) throw new Error('Document is closed');
541
742
  }
743
+
744
+ // `close()` releases the CRDT state, so a read after it reaches released
745
+ // memory and fails with whatever the engine says about that. Reads are gated
746
+ // on this rather than on `_assertOpen` because a parked Document still holds
747
+ // its state and answers from it.
748
+ private _assertNotClosed(): void {
749
+ if (this._closed) throw new Error('Document is closed');
750
+ }
542
751
  // Server-pushed lifecycle signals: `_gone` is fire-once-then-cache
543
752
  // for `doc_gone` eviction; `_workStatus` is latest-retained,
544
753
  // re-emit-per-phase for `doc_status`.
545
754
  private readonly _gone = new GoneSignal();
546
755
  private readonly _workStatus = new WorkStatusSignal();
756
+ // Inbound work ended without delivering content, so this doc holds
757
+ // nothing and stays locked. See `_emitStatus` for how it is derived.
758
+ private _loadFailed = false;
547
759
  // Drive sync state, latest-retained. `null` until the open factory
548
760
  // seeds it (drive-linked docs only) and on docs with no drive link.
549
761
  private readonly _driveStatus = new DriveStatusSignal();
762
+ // Where this document could be published when it mirrors nothing yet.
763
+ // Seeded from the open, emptied once it becomes a mirror.
764
+ private readonly _drivePublishTarget = new DrivePublishTargetSignal();
550
765
 
551
766
  // Document-scoped presence. `null` until the WS transport is wired
552
767
  // on, and on the offline / test path where there is no transport.
553
768
  presence: import('./presence.js').PresenceClient | null = null;
554
769
 
555
- constructor(loroDoc: LoroDoc, options?: DocumentOptions) {
770
+ // A Document owns the LoroDoc it wraps, so it is built through the factories
771
+ // below (or `documentFromEngine`, for in-repo tooling that brings its own).
772
+ // Owning construction is what lets this class declare an engine-neutral
773
+ // surface: app code holds a Document, never the doc underneath it.
774
+ private constructor(loroDoc: LoroDoc, options?: DocumentOptions) {
556
775
  this._doc = loroDoc;
557
776
  this._rootKey = options?.rootKey ?? 'entities';
558
777
  this._docId = options?.docId ?? null;
@@ -574,12 +793,15 @@ export class Document {
574
793
  this._isFrozen = false;
575
794
  this._checkout = null;
576
795
  }
577
- // `readOnly` on a live (non-version) open is a forced read-only: the
578
- // guest link view. A version open is already frozen, so it does not
579
- // also need the forced flag.
796
+ // `readOnly` on a live (non-version) open is a forced read-only: a
797
+ // reader the server will not accept writes from, whether because their
798
+ // grant is view-only or because the link they came through is. A
799
+ // version open is already frozen, so it does not also need the flag.
580
800
  this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
581
- this._root = loroDoc.getMap(this._rootKey);
582
- this._entities = wrapLoroMapProxy(this._root) as EntityProxy;
801
+ this._readOnlyReason = options?.readOnlyReason ?? 'link';
802
+ // Materialize the root container, so a document nothing has written to
803
+ // still carries its root in the shape a reader opens it with.
804
+ loroDoc.getMap(this._rootKey);
583
805
  this._undo = new UndoController(this._doc, options?.undo);
584
806
  }
585
807
 
@@ -604,6 +826,15 @@ export class Document {
604
826
  | { kind: 'list'; key: string; handle: ListHandle<unknown> }
605
827
  >();
606
828
 
829
+ // Names the pages this Document's writes may occupy. A page is one value,
830
+ // so two writers appending to the same page container would resolve
831
+ // last-writer-wins and lose one writer's records; the token is what keeps
832
+ // their key spaces apart. Loro's peer id is already unique per open, which
833
+ // is exactly the scope wanted.
834
+ private get _pagingSessionToken(): string {
835
+ return this._doc.peerIdStr;
836
+ }
837
+
607
838
  // Get a handle for a top-level LoroMap at `key`. The container is
608
839
  // materialized in the underlying LoroDoc immediately (Loro's
609
840
  // `getMap` is "get or create"); mutations through the handle do
@@ -623,41 +854,32 @@ export class Document {
623
854
  // `instances` map present its page containers transparently as a
624
855
  // flat record map. It resolves the ACTIVE source at call time (not
625
856
  // the one captured at handle creation): a checkout rebinds the
626
- // handle's anchor to the preview doc, and the page count must come
627
- // from that same doc or keyed reads hash into the wrong page (and a
628
- // closure pinned to a preview doc would read a freed LoroDoc after
629
- // returnToHead).
857
+ // handle's anchor to the preview doc, and the page state must come
858
+ // from that same doc or a read takes the wrong path for the state it is
859
+ // looking at (and a closure pinned to a preview doc would read a freed
860
+ // LoroDoc after returnToHead).
630
861
  //
631
- // Only the record store's paged root (`instances`) may report a page
632
- // count. `readStorePageCount` reads the document-global
633
- // `meta.page_count`, so handing it to every root map would make the
634
- // sibling record-store roots (`meta`, `header`) and any app-owned
635
- // root map read as paged once `instances` pages, routing their
636
- // `get`/`has`/`mutate` through the page-hash path against a map that
637
- // holds no page containers (`get` returns undefined for present keys;
638
- // `mutate` writes a stray page container into a plain map). A non-
639
- // record-store key gets a null provider and stays unpaged.
640
- const handle = createRootMapHandle<unknown>(
641
- loroMap,
642
- this._handleOwner,
643
- key === RECORDS_KEY
644
- ? (): number =>
645
- readStorePageCount(
646
- this._checkout !== null ? this._checkout.previewDoc : this._doc,
647
- )
648
- : undefined,
649
- key === RECORDS_KEY
650
- ? (): number =>
651
- readStorePagingScheme(
652
- this._checkout !== null ? this._checkout.previewDoc : this._doc,
653
- )
654
- : undefined,
655
- );
862
+ // EVERY root map gets the providers, because a `meta` key of its own is
863
+ // what names a paged root: a root with no such key reads as unpaged, which
864
+ // is the answer `meta` and `header` want anyway. Naming one root here
865
+ // instead would make paging a property of a reserved key rather than of a
866
+ // collection, so a map an app owns could never be paged however large it
867
+ // grew.
868
+ const activeSource = (): LoroDoc =>
869
+ this._checkout !== null ? this._checkout.previewDoc : this._doc;
870
+ const handle = createRootMapHandle<unknown>(loroMap, this._handleOwner, {
871
+ isPaged: (): boolean => readRootPaged(activeSource(), key),
872
+ page: (): void => {
873
+ pageRoot(this._doc, key, this._handleOwner.commitMutate);
874
+ },
875
+ sessionToken: (): string => this._pagingSessionToken,
876
+ stateVersion: (): StateVersion => activeSource().frontiers(),
877
+ });
656
878
  this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
657
879
  return handle as unknown as MapHandle<T>;
658
880
  }
659
881
 
660
- // Get a handle for a top-level LoroList at `key`.
882
+ // Get a handle for a top-level LoroMovableList at `key`.
661
883
  list<T = unknown>(key: string): ListHandle<T> {
662
884
  this._assertOpen();
663
885
  const cacheKey = `list:${key}`;
@@ -665,7 +887,7 @@ export class Document {
665
887
  if (existing) return existing.handle as unknown as ListHandle<T>;
666
888
  const source =
667
889
  this._checkout !== null ? this._checkout.previewDoc : this._doc;
668
- const loroList = source.getList(key);
890
+ const loroList = source.getMovableList(key);
669
891
  const handle = createRootListHandle<unknown>(loroList, this._handleOwner);
670
892
  this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
671
893
  return handle as unknown as ListHandle<T>;
@@ -710,6 +932,9 @@ export class Document {
710
932
  private get _handleOwner(): HandleOwner {
711
933
  if (this._handleOwnerCache) return this._handleOwnerCache;
712
934
  this._handleOwnerCache = {
935
+ assertReadable: (): void => {
936
+ this._assertNotClosed();
937
+ },
713
938
  assertWritable: (): void => {
714
939
  this._assertOpen();
715
940
  if (this._checkout !== null) {
@@ -718,9 +943,19 @@ export class Document {
718
943
  'Call `doc.returnToHead()` to edit.',
719
944
  );
720
945
  }
946
+ if (this._versionBrowsing) {
947
+ throw new Error(
948
+ 'Document is read-only: the reader is browsing its versions. ' +
949
+ 'Close the version bar to edit.',
950
+ );
951
+ }
721
952
  if (this._forcedReadOnly) {
953
+ throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
954
+ }
955
+ if (this._loadFailed) {
722
956
  throw new Error(
723
- 'Document is read-only: it was opened via a view-only share link.',
957
+ 'Document is read-only: its content could not be prepared ' +
958
+ '(see `doc.status`). Reopen it to try again.',
724
959
  );
725
960
  }
726
961
  if (this._isLoadLocked()) {
@@ -760,11 +995,14 @@ export class Document {
760
995
  return this._branchId;
761
996
  }
762
997
 
763
- // True when the Document is currently showing a historical version
764
- // (a checkout): `mutate` throws and edit affordances should be
765
- // disabled. Reactive: flips on `_checkoutVersion(v)` /
766
- // `returnToHead()`. `subscribe` fires when this changes, so apps
767
- // bind it directly inside the subscribe handler.
998
+ // True when this reader cannot write to the document right now:
999
+ // `mutate` throws and edit affordances should be disabled. It covers a
1000
+ // view-only grant, a view-only share link, a historical version showing
1001
+ // (a checkout), and a document whose content is still loading or could
1002
+ // not be loaded. Reactive in every one of those: `subscribe` fires when
1003
+ // it changes, so an app binds it directly inside the subscribe handler
1004
+ // and follows a grant the server withdraws mid-session as well as a
1005
+ // checkout the reader entered.
768
1006
  get readOnly(): boolean {
769
1007
  // A doc that is still loading its first content (a drive-linked
770
1008
  // stub hydrating on first open) is read-only until the bytes
@@ -774,23 +1012,53 @@ export class Document {
774
1012
  // `reloadAfterHydration`); until then mutation is rejected. This is
775
1013
  // also what stops an app's "seed an empty doc" path from clobbering
776
1014
  // a doc that only looks empty because it has not loaded yet.
1015
+ // Browsing versions locks the document at the live head as well as at an
1016
+ // earlier one. Stepping moves between the points history KEEPS, and an
1017
+ // edit at the head makes no such point, so an edit made under the open
1018
+ // bar puts the reader's own newest work somewhere the bar cannot travel
1019
+ // to: Back then steps past it to the newest marker, and the way forward
1020
+ // is a different number of presses from the way back. Reading history
1021
+ // and writing it are separate modes, and the bar carries the Close that
1022
+ // leaves this one.
777
1023
  return (
778
- this._forcedReadOnly || this._checkout !== null || this._isLoadLocked()
1024
+ this._forcedReadOnly ||
1025
+ this._checkout !== null ||
1026
+ this._versionBrowsing ||
1027
+ this._isLoadLocked()
779
1028
  );
780
1029
  }
781
1030
 
782
1031
  // True when content-replacing work (hydration / extraction) is pending
783
1032
  // and editing must be locked so incoming bytes do not clobber local
784
- // ops. A pending PUSH is outbound (it writes the doc out to the drive,
785
- // nothing replaces local content), so the server marks it
1033
+ // ops, and true again once that work ends without content
1034
+ // (`_loadFailed`): a doc nothing could prepare is empty, and an edit
1035
+ // there writes ops into a document the app is about to be told could not
1036
+ // be opened. A pending PUSH is outbound (it writes the doc out to the
1037
+ // drive, nothing replaces local content), so the server marks it
786
1038
  // `blocking: false` and the doc stays editable while the importer
787
1039
  // runs. An older server omits `blocking`; absence is treated as
788
1040
  // blocking so hydration stays locked.
789
1041
  private _isLoadLocked(): boolean {
1042
+ if (this._loadFailed) return true;
790
1043
  const s = this._workStatus.status;
791
1044
  return s?.pending === true && s.blocking !== false;
792
1045
  }
793
1046
 
1047
+ /**
1048
+ * Whether inbound work on this document ended without content. The SDK's
1049
+ * session reads it to report the open as failed rather than binding an
1050
+ * empty document as ready.
1051
+ * @internal
1052
+ */
1053
+ get _contentLoadFailed(): boolean {
1054
+ return this._loadFailed;
1055
+ }
1056
+
1057
+ /** @internal The `doc_gone` reason this Document has seen, or `null`. */
1058
+ get _goneReason(): DocumentGoneReason | null {
1059
+ return this._gone.reason;
1060
+ }
1061
+
794
1062
  /**
795
1063
  * The historical version this Document shows, or `null` for a live
796
1064
  * (head) open. Reactive alongside `readOnly`. The number is the
@@ -857,6 +1125,16 @@ export class Document {
857
1125
  return new Document(new LoroDoc(), options);
858
1126
  }
859
1127
 
1128
+ /**
1129
+ * Wrap a LoroDoc the caller already holds. `documentFromEngine` is the typed
1130
+ * entry point; this seam stays `unknown` so `documents.ts` declares an
1131
+ * engine-neutral surface.
1132
+ * @internal
1133
+ */
1134
+ static _fromEngine(engine: unknown, options?: DocumentOptions): Document {
1135
+ return new Document(engine as LoroDoc, options);
1136
+ }
1137
+
860
1138
  // Apply a remote op blob produced by another peer (delivered via the
861
1139
  // WS `doc_op` server frame). Idempotent: Loro's import
862
1140
  // path dedupes by lamport, so re-applying a self-originated op is
@@ -883,14 +1161,19 @@ export class Document {
883
1161
 
884
1162
  // Apply a snapshot blob over the top of an existing Document.
885
1163
  // Internal: the SDK calls this when the server delivers a fresh
886
- // snapshot mid-session (e.g. after a compaction cycle). Persistence
887
- // is server-authoritative, so there is no app-facing snapshot import.
1164
+ // snapshot mid-session. Persistence is server-authoritative: the server
1165
+ // decides when a fresh snapshot supersedes the local history.
888
1166
  /** @internal */
889
1167
  _applySnapshot(snapshot: Uint8Array): void {
890
1168
  // See `applyRemoteOp`: refuse the import on a closed (freed) doc so a late
891
1169
  // reload/resync closure can't touch freed WASM. `_closed` only; a parked
892
1170
  // doc still imports.
893
1171
  if (this._closed) return;
1172
+ // Arm the seed BEFORE the import, because this is the moment the content
1173
+ // arrives and the Loro subscriber cannot be relied on to report it: it is
1174
+ // attached lazily, so a document whose content lands before the session has
1175
+ // bound its canvas has nobody subscribed and the import fires nothing.
1176
+ this._armSeedIfPictureIsStale();
894
1177
  this._doc.import(snapshot);
895
1178
  // The undo stack pre-snapshot referenced ops the new snapshot
896
1179
  // supersedes. Drop both stacks so undo doesn't try to revert
@@ -994,9 +1277,9 @@ export class Document {
994
1277
  // intentional escape hatch: `Brass.openDocumentById` wires the hook;
995
1278
  // `Document.create()` and direct construction stay
996
1279
  // hook-free so the class is usable without taking the WS dependency.
997
- // `E` defaults to `EntityProxy` (the untyped Record<string, unknown>)
998
- // for backwards-compat with existing callers. Apps with a typed
999
- // entity shape pass it as a type argument:
1280
+ // `E` defaults to `EntityProxy` (the untyped Record<string, unknown>) so an
1281
+ // app that has not described its entity shape still gets a working mutate.
1282
+ // Apps with a typed entity shape pass it as a type argument:
1000
1283
  //
1001
1284
  // doc.mutate<MyEntity>((d) => {
1002
1285
  // d.entities[id] = myTypedEntity;
@@ -1006,50 +1289,18 @@ export class Document {
1006
1289
  // regardless of `E`; the generic exists purely to widen the type at
1007
1290
  // the assignment site so closed interfaces don't need an
1008
1291
  // `as Record<string, unknown>` cast.
1292
+ //
1293
+ // `d.entities` is the root map's handle under a plain-object surface, so
1294
+ // the two reach one collection: a record a paged store holds reads and
1295
+ // edits here exactly as it does through `doc.map(rootKey)`, and a write
1296
+ // large enough to cross the paging threshold takes the paged shape from
1297
+ // either.
1009
1298
  mutate<E extends object = EntityProxy>(
1010
1299
  fn: (d: DocumentMutator<E>) => void,
1011
1300
  ): void {
1012
- this._assertOpen();
1013
- if (this._checkout !== null) {
1014
- throw new Error(
1015
- 'Document is read-only: it is showing a historical version. ' +
1016
- 'Call `doc.returnToHead()` to edit.',
1017
- );
1018
- }
1019
- // A view-only share-link (guest) open is a live head held read-only:
1020
- // remote ops still stream in, but a local edit must be refused. The
1021
- // `_checkout` guard above only covers the version-pinned case, so this
1022
- // is a separate branch.
1023
- if (this._forcedReadOnly) {
1024
- throw new Error(
1025
- 'Document is read-only: it was opened via a view-only share link.',
1026
- );
1027
- }
1028
- if (this._isLoadLocked()) {
1029
- throw new Error(
1030
- 'Document is still loading its content; wait for the load to ' +
1031
- 'finish before editing (see `doc.onStatus`).',
1032
- );
1033
- }
1034
- this._ensureLocalUpdatesSubscribed();
1035
- // A fresh proxy for the call, because the batcher rides it: the
1036
- // long-lived `this._entities` must stay batcher-free for the reads
1037
- // that go through it outside a mutation.
1038
- const batcher = new MutateBatcher(() => {
1039
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
1040
- this._doc.commit();
1301
+ this.map<E>(this._rootKey).mutate((entities) => {
1302
+ fn({ entities });
1041
1303
  });
1042
- fn({
1043
- entities: wrapLoroMapProxy(this._root, batcher) as unknown as Record<
1044
- string,
1045
- E
1046
- >,
1047
- });
1048
- // Stamp the commit so `subscribe`'s default path can identify
1049
- // and suppress the local-mutate echo. `setNextCommitOrigin`
1050
- // applies to the very next commit, and `commit()` consumes it.
1051
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
1052
- this._doc.commit();
1053
1304
  }
1054
1305
 
1055
1306
  // Install / replace the local-op send hook; `null` detaches it.
@@ -1112,6 +1363,13 @@ export class Document {
1112
1363
  return this._checkout !== null;
1113
1364
  }
1114
1365
 
1366
+ /** @internal Whether this Document is pinned to the version it was opened
1367
+ * at, with no live channel behind it. Stepping and returning to the head
1368
+ * both throw on that shape, so the SDK's version bar offers neither. */
1369
+ get _isVersionPinned(): boolean {
1370
+ return this._isFrozen;
1371
+ }
1372
+
1115
1373
  // Switch the Document to a historical version, in place. @internal
1116
1374
  // (the hosted `history` command drives this). After the promise
1117
1375
  // resolves, `doc.readOnly` is `true`,
@@ -1162,6 +1420,11 @@ export class Document {
1162
1420
  for (const blob of tailOpBlobs) previewDoc.import(blob);
1163
1421
  const previewRoot = previewDoc.getMap(this._rootKey);
1164
1422
  const old = this._checkout;
1423
+ // Snapshot the handles' keys before the active-source pointer moves (see
1424
+ // `_snapshotRootHandleKeys`). Nothing between here and `_rebindRootHandles`
1425
+ // may return early, or the snapshot would outlive its swap and be diffed
1426
+ // against an unrelated later one.
1427
+ this._snapshotRootHandleKeys();
1165
1428
  this._checkout = {
1166
1429
  previewDoc,
1167
1430
  previewRoot,
@@ -1187,30 +1450,38 @@ export class Document {
1187
1450
  // ignore; already freed
1188
1451
  }
1189
1452
  }
1190
- this._fireSubscribers({ kind: 'checkout', version: effectiveVersion });
1453
+ this._fireSubscribers({ kind: 'history', version: effectiveVersion });
1191
1454
  this._maybeFireUndoState();
1192
1455
  // Preview every opened stream at the same point in time, so a
1193
1456
  // partitioned document's history shows a consistent historical state
1194
1457
  // across streams rather than a historical default over live streams.
1195
- await this._checkoutOpenStreams();
1458
+ await this._checkoutOpenStreams(generation);
1196
1459
  }
1197
1460
 
1198
1461
  // Re-materialize each opened non-default stream at its seq recorded by the
1199
1462
  // active preview's checkpoint. A stream with no recorded seq at this
1200
1463
  // version (opened after the checkpoint, or before any checkpoint recorded
1201
- // one) returns to its own head, so it shows its earliest-known rather than
1202
- // a mismatched live state. Best-effort per stream: a stream whose inner
1464
+ // one) has no historical state it can show, so it is held read-only for the
1465
+ // duration rather than left live and writable under a historical default. Best-effort per stream: a stream whose inner
1203
1466
  // has no checkout fetcher (a guest line, which has no token to fetch
1204
1467
  // at-version) stays live. Failures are swallowed so one stream cannot
1205
1468
  // break the doc-level preview.
1206
- private async _checkoutOpenStreams(): Promise<void> {
1469
+ private async _checkoutOpenStreams(generation: number): Promise<void> {
1207
1470
  const seqs = this._checkoutStreamSeqs ?? {};
1208
1471
  for (const [name, openPromise] of this._streamOpens) {
1209
1472
  try {
1210
1473
  const stream = await openPromise;
1474
+ // Every stream costs an await, so the reader can have stepped
1475
+ // somewhere else, or back to the live head, since the previous one.
1476
+ // The document's own swap is guarded the same way, and this loop
1477
+ // running on past it is what leaves a partitioned document showing
1478
+ // two moments at once: the document at the version the reader asked
1479
+ // for, and whichever streams a superseded run still reached at its
1480
+ // own.
1481
+ if (generation !== this._checkoutGeneration) return;
1211
1482
  const lineId = this._streamManifest?.get(name)?.line_id;
1212
1483
  const seq = lineId !== undefined ? seqs[lineId] : undefined;
1213
- await stream._previewAt(seq);
1484
+ await stream._previewAt(seq, true);
1214
1485
  } catch {
1215
1486
  // A stream that failed to open or preview is left as-is.
1216
1487
  }
@@ -1240,9 +1511,12 @@ export class Document {
1240
1511
  // Bump first, unconditionally: a checkout fetch may be in flight with
1241
1512
  // `_checkout` still null, and this is the signal that supersedes it when
1242
1513
  // it resolves.
1243
- this._checkoutGeneration += 1;
1514
+ const generation = (this._checkoutGeneration += 1);
1244
1515
  if (this._checkout === null) return;
1245
1516
  const old = this._checkout;
1517
+ // Symmetric with `_checkoutVersion`: snapshot before the active-source
1518
+ // pointer moves, and return early nowhere between here and the rebind.
1519
+ this._snapshotRootHandleKeys();
1246
1520
  this._checkout = null;
1247
1521
  // Re-point the app's handles back at the live doc and redraw them,
1248
1522
  // symmetrically with the checkout path, so the model returns to the live
@@ -1266,13 +1540,33 @@ export class Document {
1266
1540
  this._checkoutStreamSeqs = null;
1267
1541
  for (const openPromise of this._streamOpens.values()) {
1268
1542
  void openPromise
1269
- .then((stream) => stream._previewAt(undefined))
1543
+ .then((stream) => {
1544
+ // Each stream is reached a turn or more later, so a checkout the
1545
+ // reader started in the meantime owns the document by now and this
1546
+ // return would drag its streams back to the live head under it.
1547
+ if (generation !== this._checkoutGeneration) return;
1548
+ return stream._previewAt(undefined);
1549
+ })
1270
1550
  .catch(() => {
1271
1551
  // A stream that failed to open has nothing to return.
1272
1552
  });
1273
1553
  }
1274
1554
  }
1275
1555
 
1556
+ // Capture every root map handle's key set for the resync's removed-key diff,
1557
+ // while the handles still describe the outgoing source. This is a phase of
1558
+ // its own, ahead of `_rebindRootHandles`, because a handle resolves its
1559
+ // container from its anchor but its paging state from this Document's active
1560
+ // source: both must still name the outgoing source when the keys are read,
1561
+ // and the caller moves the active-source pointer between the two calls.
1562
+ // Callers must not return early in that window; a snapshot left unconsumed
1563
+ // would be diffed against a later, unrelated swap.
1564
+ private _snapshotRootHandleKeys(): void {
1565
+ for (const entry of this._rootHandles.values()) {
1566
+ if (entry.kind === 'map') snapshotRootMapKeys(entry.handle);
1567
+ }
1568
+ }
1569
+
1276
1570
  // Re-point every cached root handle at the corresponding container in
1277
1571
  // `source` (the live doc or a checkout preview) and resync its subscribers.
1278
1572
  // The app holds these same handle objects, so this is what makes a version
@@ -1289,7 +1583,7 @@ export class Document {
1289
1583
  if (entry.kind === 'map') {
1290
1584
  rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
1291
1585
  } else {
1292
- rebindRootListAnchor(entry.handle, source.getList(entry.key));
1586
+ rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
1293
1587
  }
1294
1588
  }
1295
1589
  for (const entry of this._rootHandles.values()) {
@@ -1386,6 +1680,23 @@ export class Document {
1386
1680
  this._thumbnails.setUploader(uploader);
1387
1681
  }
1388
1682
 
1683
+ // Tell the document which version the server's picture of it depicts, from
1684
+ // the join, and start a seed watch when that picture is behind the content.
1685
+ /** @internal */
1686
+ _setThumbnailVersion(version: number | null): void {
1687
+ this._thumbnailVersion = version;
1688
+ this._armSeedIfPictureIsStale();
1689
+ }
1690
+
1691
+ // A picture at or above the head depicts what the document currently holds.
1692
+ private _armSeedIfPictureIsStale(): void {
1693
+ const head = this.currentSeq ?? 0;
1694
+ if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
1695
+ return;
1696
+ }
1697
+ this._thumbnails.armSeed();
1698
+ }
1699
+
1389
1700
  // Install the checkpoint-create hook; `null` detaches.
1390
1701
  /** @internal */
1391
1702
  setCheckpointCreator(creator: CheckpointCreator | null): void {
@@ -1419,11 +1730,8 @@ export class Document {
1419
1730
  this._sourceBlobFetcher = fetcher;
1420
1731
  }
1421
1732
 
1422
- // Publish this document's current state back to its drive source.
1423
- // Lets an app code one line on save / on a commit button to make its
1424
- // edits visible to people working outside Brass on the same source:
1425
- //
1426
- // await doc.pushToDrive({ message: 'Wall move' });
1733
+ // Publish this document's current state back to its drive source,
1734
+ // driving the hosted toolbar's publish control.
1427
1735
  //
1428
1736
  // The platform publishes the doc's current state, overwriting the
1429
1737
  // source (unknown drive fields preserved), and marks its own write so
@@ -1431,6 +1739,7 @@ export class Document {
1431
1739
  // concurrent drive-side edits. Throws on auth / link / write
1432
1740
  // failures; resolves with the new external ref and the published
1433
1741
  // entity count.
1742
+ /** @internal */
1434
1743
  async pushToDrive(
1435
1744
  input?: PushToDriveInput,
1436
1745
  ): Promise<PushToDriveResult> {
@@ -1447,10 +1756,9 @@ export class Document {
1447
1756
  }
1448
1757
 
1449
1758
  // Resolve a `behind` / `diverged` drive-linked doc by opening the
1450
- // hosted resolution surface, so an app can offer pull (take the source)
1451
- // / keep both alongside the publish-over that `pushToDrive` does:
1452
- //
1453
- // if (doc.driveStatus?.state === 'diverged') await doc.resolveDrive();
1759
+ // hosted resolution surface, which offers pull (take the source) / keep
1760
+ // both alongside the publish-over that `pushToDrive` does. Driven by the
1761
+ // hosted toolbar.
1454
1762
  //
1455
1763
  // Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
1456
1764
  // takes over the drive link (take-source trashes this doc, keep-both
@@ -1460,6 +1768,7 @@ export class Document {
1460
1768
  // returned `newDocId` lets a multi-doc app do its own switch. Resolves
1461
1769
  // with the outcome, or `null` when the user dismissed the dialog.
1462
1770
  // Throws when there is no doc identity or no resolver is attached.
1771
+ /** @internal */
1463
1772
  async resolveDrive(): Promise<DriveResolveResult | null> {
1464
1773
  this._assertOpen();
1465
1774
  if (this._docId === null) {
@@ -1473,11 +1782,11 @@ export class Document {
1473
1782
  return await this._driveResolver();
1474
1783
  }
1475
1784
 
1476
- // Fetch the original file behind an opaque-blob document: a short-lived
1477
- // presigned GET plus the file's name / content type / size. An app that
1478
- // knows the format fetches the bytes from `url`, parses them, and writes
1479
- // the structured content into this document through the normal handle
1480
- // surface (`doc.map('instances').mutate(...)`):
1785
+ // Fetch the file a document was uploaded as: a short-lived presigned GET
1786
+ // plus the file's name / content type / size. An app that knows the format
1787
+ // fetches the bytes from `url`, parses them, and writes the structured
1788
+ // content into this document through the normal handle surface
1789
+ // (`doc.map('instances').mutate(...)`):
1481
1790
  //
1482
1791
  // const src = await doc.sourceBlob();
1483
1792
  // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
@@ -1489,9 +1798,8 @@ export class Document {
1489
1798
  // each with its own URL, so a multi-stream format (a glTF with its
1490
1799
  // `.bin` buffer) fetches only the streams it needs.
1491
1800
  //
1492
- // Rejects with a `BrassError` when the document is not a stored opaque
1493
- // blob (it has no original file to hand back) or the caller cannot
1494
- // reach it (both 404).
1801
+ // Rejects with a `BrassError` when the document holds no such file, or
1802
+ // the caller cannot reach it (both 404).
1495
1803
  async sourceBlob(): Promise<DocumentSourceBlob> {
1496
1804
  this._assertOpen();
1497
1805
  if (this._docId === null) {
@@ -1517,6 +1825,9 @@ export class Document {
1517
1825
  private _streamRuntime: StreamRuntime | null = null;
1518
1826
  private _streamManifest: Map<string, DocumentStreamInfo> | null = null;
1519
1827
  private readonly _streamOpens = new Map<string, Promise<DocumentStream>>();
1828
+ // The subset of `_streamOpens` that has resolved, so a park reaches them
1829
+ // without waiting on a microtask. Kept in step with the map above.
1830
+ private readonly _openStreams = new Map<string, DocumentStream>();
1520
1831
  private _ownStream: DocumentStream | null = null;
1521
1832
  // Which stream this Document's own handles read. An open names a stream
1522
1833
  // and the usual one has a default, so `default` is a name here rather
@@ -1602,16 +1913,33 @@ export class Document {
1602
1913
  // the same point in time rather than showing it live under a
1603
1914
  // historical default.
1604
1915
  if (this._checkout !== null) {
1605
- await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id]);
1916
+ await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
1606
1917
  }
1607
1918
  return stream;
1608
1919
  })();
1609
1920
  this._streamOpens.set(name, open);
1610
- // A failed open (network, deleted stream) must not poison the cache:
1611
- // drop it so the next call retries.
1612
- open.catch(() => {
1613
- if (this._streamOpens.get(name) === open) this._streamOpens.delete(name);
1614
- });
1921
+ // Hold the RESOLVED stream too. A park has to reach the streams
1922
+ // synchronously (an app's `close()` returns before any microtask runs, and
1923
+ // a remote op landing in between would still call the app back), and a
1924
+ // promise cannot be read that way. An open still in flight when the park
1925
+ // happens resolves into a parked document, so it is parked on arrival.
1926
+ void open.then(
1927
+ (stream) => {
1928
+ if (this._streamOpens.get(name) !== open) return;
1929
+ this._openStreams.set(name, stream);
1930
+ // `savingCount` counts this stream from here on, so an indicator bound
1931
+ // to the document has to hear the stream's writes land.
1932
+ stream._inner._observeSavingCount(() => {
1933
+ this._fireSubscribers({ kind: 'saving-count' });
1934
+ });
1935
+ if (this._suspended) stream._suspend();
1936
+ },
1937
+ () => {
1938
+ // A failed open (network, deleted stream) must not poison the cache:
1939
+ // drop it so the next call retries.
1940
+ if (this._streamOpens.get(name) === open) this._streamOpens.delete(name);
1941
+ },
1942
+ );
1615
1943
  return open;
1616
1944
  }
1617
1945
 
@@ -1683,15 +2011,45 @@ export class Document {
1683
2011
  // Close every opened stream's own transport + state. Streams opened
1684
2012
  // while this Document was live do not outlive it.
1685
2013
  private _closeStreams(): void {
2014
+ // A stream that has already opened closes IN THIS CALL, for the reason the
2015
+ // park reaches them through this map: `close()` returns to the app before
2016
+ // any microtask runs, so a stream closed on a `.then` spends the rest of
2017
+ // that turn taking writes and calling the app back while `doc.mutate` on
2018
+ // the same document already refuses. A write in that window reaches a
2019
+ // transport about to be torn down and is never persisted.
2020
+ for (const stream of this._openStreams.values()) {
2021
+ stream._inner._observeSavingCount(null);
2022
+ stream._close();
2023
+ }
2024
+ // An open still in flight has no Document to close yet, so it closes on
2025
+ // arrival. A stream reached both ways is closed twice, which is a no-op.
1686
2026
  for (const open of this._streamOpens.values()) {
1687
2027
  void open.then((s) => s._close()).catch(() => {
1688
2028
  // A failed open has nothing to close.
1689
2029
  });
1690
2030
  }
1691
2031
  this._streamOpens.clear();
2032
+ this._openStreams.clear();
1692
2033
  this._streamManifest = null;
1693
2034
  }
1694
2035
 
2036
+ // Park / unpark the streams opened off this document, alongside the
2037
+ // document's own park.
2038
+ //
2039
+ // A non-default stream is its own Document, which nothing else suspends: it
2040
+ // keeps its transport, so an app holding `await doc.stream(name)` across
2041
+ // `doc.close()` would go on receiving remote-op callbacks and could still
2042
+ // write through the stream, while `doc.mutate` correctly throws. That is the
2043
+ // cross-session callback a park exists to stop, plus a write path around the
2044
+ // closed check. The default stream's inner IS this document, which owns its
2045
+ // own lifecycle, so `_ownsInner` gates this exactly as it gates the close.
2046
+ private _setStreamsSuspended(suspended: boolean): void {
2047
+ for (const stream of this._openStreams.values()) {
2048
+ if (suspended) stream._suspend();
2049
+ else stream._resume();
2050
+ }
2051
+ }
2052
+
1695
2053
  // Seal a deliberate checkpoint on this document's version-
1696
2054
  // history timeline. Compaction snapshots continue to appear as
1697
2055
  // no-action fallback markers, so an app that never calls this still
@@ -1767,6 +2125,14 @@ export class Document {
1767
2125
  else console.error('[Brass]', err);
1768
2126
  }
1769
2127
 
2128
+ /** @internal Report a failure from the SDK's own chrome (the version
2129
+ * bar's intents) through the same sink the built-in commands use, so a
2130
+ * refused checkout reaches the app's `onError` rather than being dropped
2131
+ * on the way back to a surface that only shows the reader a state. */
2132
+ _reportChromeError(err: Error): void {
2133
+ this._reportError(err);
2134
+ }
2135
+
1770
2136
  // Fire all `subscribe` listeners with a synthetic event. Used for
1771
2137
  // metadata-only changes (checkout, return-to-head, branch switch,
1772
2138
  // branches list update, saving count change, commands change)
@@ -1775,21 +2141,127 @@ export class Document {
1775
2141
  // call `toJSON()` in the handler; the discriminant is there for
1776
2142
  // the rare consumer that wants to branch on the reason.
1777
2143
  private _fireSubscribers(event: SyntheticSubscribeEvent): void {
2144
+ // Every change to the outstanding-op map reports itself here, which
2145
+ // makes this the one place the unload guard has to hear about un-acked
2146
+ // work (`close()` releases the doc outright).
2147
+ if (event.kind === 'saving-count') {
2148
+ // Own ops rather than `savingCount`: a stream's inner Document registers
2149
+ // itself, so the registry's total already carries one entry per stream.
2150
+ trackUnsavedWork(this, this._outstandingByClientId.size);
2151
+ this._savingCountObserver?.();
2152
+ }
1778
2153
  this._ensureLoroSubscribed();
1779
2154
  this._subs.emit(event);
1780
2155
  }
1781
2156
 
1782
- // Saving indicator: number of locally-issued ops not yet acked by
1783
- // the server. Reactive: `subscribe` fires when this count changes
1784
- // (an op is sent or an `op_ack` lands). Apps bind directly inside
1785
- // the subscribe handler:
2157
+ // Told when this Document's outstanding-op count moves, so a parent
2158
+ // Document's `savingCount` covers the streams it has open and its
2159
+ // subscribers fire when a stream's writes land. One observer, because a
2160
+ // stream's inner Document is opened by, and belongs to, exactly one parent.
2161
+ private _savingCountObserver: (() => void) | null = null;
2162
+
2163
+ /** @internal */
2164
+ _observeSavingCount(observer: (() => void) | null): void {
2165
+ this._savingCountObserver = observer;
2166
+ }
2167
+
2168
+ // Saving indicator: number of locally-issued ops not yet acked by the
2169
+ // server, across this document's own state and every stream it has open. A
2170
+ // partitioned document keeps its bulk on its streams, so a count that named
2171
+ // the default stream alone reports "All changes saved" over a stream still
2172
+ // writing. Reactive: `subscribe` fires when this count changes (an op is
2173
+ // sent or an `op_ack` lands, on any of them). Apps bind directly inside the
2174
+ // subscribe handler:
1786
2175
  //
1787
2176
  // doc.subscribe(() => {
1788
2177
  // savingEl.textContent =
1789
2178
  // doc.savingCount === 0 ? 'All changes saved'
1790
2179
  // : `Saving ${doc.savingCount} change${doc.savingCount === 1 ? '' : 's'}...`;
1791
2180
  // });
2181
+ //
2182
+ // Only this count aggregates. `readOnly` and `atCapacity` gate `doc.mutate`,
2183
+ // which writes this document's own state, so folding another stream's
2184
+ // refusal into them would close an editor over a stream taking writes;
2185
+ // `stream.readOnly` / `stream.atCapacity` answer for one stream.
2186
+ // Supplied by the doc transport, which holds the sync state this reports.
2187
+ // Absent until a transport is wired (a read-only or detached Document),
2188
+ // where the honest answer is that nothing is outstanding.
2189
+ private _diagnosticsProvider: (() => DocSyncDiagnostics) | null = null;
2190
+
2191
+ /** @internal */
2192
+ _setDiagnosticsProvider(provider: () => DocSyncDiagnostics): void {
2193
+ this._diagnosticsProvider = provider;
2194
+ }
2195
+
2196
+ /**
2197
+ * How this document's sync is going, as the client sees it: how much is
2198
+ * outstanding, how long it has been, over which transport, across how many
2199
+ * reconnects, and under which correlator, which is what separates a
2200
+ * connection that is answering nothing from one that is merely behind.
2201
+ *
2202
+ * @internal Telemetry for Brass's own surfaces. An app reads `savingCount`
2203
+ * to know a save is outstanding, and the SDK resolves a stuck one itself.
2204
+ */
2205
+ diagnostics(): DocSyncDiagnostics {
2206
+ const own: DocSyncDiagnostics = this._diagnosticsProvider?.() ?? {
2207
+ unackedOps: 0,
2208
+ oldestUnackedMs: 0,
2209
+ oldestClientOpId: null,
2210
+ reconnects: 0,
2211
+ lastAckAgeMs: null,
2212
+ inFlightBytes: 0,
2213
+ httpOpsInFlight: 0,
2214
+ healRounds: 0,
2215
+ hidden: false,
2216
+ };
2217
+ // Cover the streams this document opened, the way `savingCount` does: a
2218
+ // reader watching a save waits on the document, and which line an op was
2219
+ // written to is not something they can act on. The work counters add
2220
+ // up and the waiting ones take the longest wait, so the answer is about
2221
+ // the oldest edit anywhere in the document.
2222
+ //
2223
+ // The connection counters are the parent's alone rather than a fold,
2224
+ // because every line rides one socket, so reconnects and page
2225
+ // visibility are one fact about the client and not one per line.
2226
+ for (const stream of this._openStreams.values()) {
2227
+ const line = stream._inner.diagnostics();
2228
+ own.unackedOps += line.unackedOps;
2229
+ own.inFlightBytes += line.inFlightBytes;
2230
+ own.httpOpsInFlight += line.httpOpsInFlight;
2231
+ own.healRounds = Math.max(own.healRounds, line.healRounds);
2232
+ // Take the stream's op when it waited longer, and also when this
2233
+ // document has none of its own: a strict comparison against a zero
2234
+ // wait leaves the fold reporting outstanding work while naming no op
2235
+ // to go looking for, which is the field the rest hangs off.
2236
+ if (
2237
+ line.oldestClientOpId !== null &&
2238
+ (own.oldestClientOpId === null ||
2239
+ line.oldestUnackedMs > own.oldestUnackedMs)
2240
+ ) {
2241
+ own.oldestUnackedMs = line.oldestUnackedMs;
2242
+ own.oldestClientOpId = line.oldestClientOpId;
2243
+ }
2244
+ }
2245
+ return own;
2246
+ }
2247
+
1792
2248
  get savingCount(): number {
2249
+ let total = this._ownSavingCount;
2250
+ // `_openStreams` holds the streams that opened their own line. The stream
2251
+ // this Document IS wraps `this`, and is not in it, so nothing is counted
2252
+ // twice.
2253
+ for (const stream of this._openStreams.values()) {
2254
+ total += stream._inner.savingCount;
2255
+ }
2256
+ return total;
2257
+ }
2258
+
2259
+ // The ops issued against THIS line, which is what a stream reports for
2260
+ // itself: `doc.stream('default')` wraps this Document, so a default stream
2261
+ // reading the aggregate would report a sibling stream's writes as its own
2262
+ // and stand at "saving" while its own line has nothing outstanding.
2263
+ /** @internal */
2264
+ get _ownSavingCount(): number {
1793
2265
  return this._outstandingByClientId.size;
1794
2266
  }
1795
2267
 
@@ -1806,9 +2278,54 @@ export class Document {
1806
2278
  // once every chunk of the push has drained.
1807
2279
  this._resyncPushClientOpIds.delete(clientOpId);
1808
2280
  if (wasTracked) this._fireSubscribers({ kind: 'saving-count' });
2281
+ // An ack is proof the branch is taking writes again, which is the only
2282
+ // thing that can clear the capacity latch. A full branch refuses every
2283
+ // op, so no ack can arrive while the refusal stands; that is what makes
2284
+ // this evidence rather than a guess, and what keeps it from clearing a
2285
+ // state the next write would re-enter.
2286
+ if (this._atCapacity) {
2287
+ this._atCapacity = false;
2288
+ this._fireSubscribers({ kind: 'capacity' });
2289
+ }
1809
2290
  return wasTracked;
1810
2291
  }
1811
2292
 
2293
+ // Internal: turn this Document read-only because the server refused a
2294
+ // write on the reader's authority rather than on the op. It lands on
2295
+ // `readOnly` because that is the gate an app already renders, so a grant
2296
+ // withdrawn mid-session closes the editor with no new signal to learn.
2297
+ // One-way: the socket resolves write authority when it subscribes, so a
2298
+ // grant restored afterwards is refused by the server too until the
2299
+ // connection recycles, and a reopen is what picks the wider grant up.
2300
+ /** @internal */
2301
+ _markReadOnly(reason: ReadOnlyReason): void {
2302
+ if (this._forcedReadOnly) return;
2303
+ this._forcedReadOnly = true;
2304
+ this._readOnlyReason = reason;
2305
+ this._fireSubscribers({ kind: 'commands' });
2306
+ this._maybeFireUndoState();
2307
+ }
2308
+
2309
+ /**
2310
+ * Return this copy to what the platform holds, discarding anything it
2311
+ * refused. A refused change stays drawn on the copy that made it and
2312
+ * nowhere else, and no local operation removes it (a CRDT merges rather
2313
+ * than replaces, and an undo inverts an operation instead of discarding
2314
+ * it), so the only state holding exactly what the platform has is one
2315
+ * built from it.
2316
+ *
2317
+ * The session closes this document and opens the same id again, so a
2318
+ * `session.subscribe` handler rebinds the way it does on any document
2319
+ * switch.
2320
+ *
2321
+ * @internal The SDK drives this itself on the one refusal that reaches it,
2322
+ * telling the reader first, because an app that renders nothing for it is
2323
+ * exactly the case that must not leave someone on a diverged copy.
2324
+ */
2325
+ reload(): void {
2326
+ this._markGone('moved');
2327
+ }
2328
+
1812
2329
  // Internal: forget an outstanding op the server TERMINALLY rejected (an
1813
2330
  // `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
1814
2331
  // server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
@@ -1817,16 +2334,77 @@ export class Document {
1817
2334
  // app can react (the optimistic local commit stays applied but never
1818
2335
  // persists, so the app's cue is to reload). Returns true if it was tracked.
1819
2336
  /** @internal */
1820
- _failOutstandingOp(clientOpId: string, message: string): boolean {
2337
+ _failOutstandingOp(
2338
+ clientOpId: string,
2339
+ message: string,
2340
+ // Whether the app hears about this one. A refusal that describes the
2341
+ // DOCUMENT answers every op in flight at once, so reporting per op tells
2342
+ // the reader the same thing as many times as they happened to have
2343
+ // unacked writes, which on a bulk write is dozens. The caller reports it
2344
+ // once instead; the op still leaves the tracker either way, because
2345
+ // `savingCount` has to fall whether or not anyone is told.
2346
+ opts: { report?: boolean } = {},
2347
+ ): boolean {
1821
2348
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
1822
2349
  this._resyncPushClientOpIds.delete(clientOpId);
1823
2350
  if (wasTracked) {
1824
2351
  this._fireSubscribers({ kind: 'saving-count' });
1825
- this._reportError(new Error(message));
2352
+ if (opts.report !== false) this._reportError(new Error(message));
1826
2353
  }
1827
2354
  return wasTracked;
1828
2355
  }
1829
2356
 
2357
+ // Internal: whether this op is one the durability handshake pushed, rather
2358
+ // than one the app wrote. The transport reads it to tell a refusal the heal
2359
+ // itself provoked from one that arrived on its own, which is what keeps a
2360
+ // refused heal chunk from re-arming the heal that sent it.
2361
+ /** @internal */
2362
+ _isResyncPushOp(clientOpId: string): boolean {
2363
+ return this._resyncPushClientOpIds.has(clientOpId);
2364
+ }
2365
+
2366
+ private _atCapacity = false;
2367
+
2368
+ /**
2369
+ * Whether the document has stopped accepting new changes because it has
2370
+ * reached the largest size the platform can keep.
2371
+ *
2372
+ * Read from the server's refusal of a write rather than predicted locally,
2373
+ * so it turns true at the first edit that does not land, and back to false
2374
+ * on the first edit that does. Both directions are evidence: a full branch
2375
+ * refuses every op, so an ack cannot arrive while the refusal stands.
2376
+ *
2377
+ * Edits keep applying to the local copy while it is true, which is what
2378
+ * lets the app show the reader what they did alongside the fact that it is
2379
+ * not saved; the copy is discarded on reload.
2380
+ *
2381
+ * An app reads this to stop offering work that would only be refused (a
2382
+ * bulk import, a generate pass) and to say so once, instead of surfacing one
2383
+ * error per rejected op.
2384
+ */
2385
+ get atCapacity(): boolean {
2386
+ return this._atCapacity;
2387
+ }
2388
+
2389
+ // Internal: record the capacity verdict from a server refusal. Idempotent.
2390
+ // Cleared again by `resolveOutstandingOp`, so a branch whose compaction
2391
+ // seals it back under the ceiling reaches the open reader through the next
2392
+ // write that lands rather than only through a reload.
2393
+ /**
2394
+ * Returns whether this call is the one that flipped it, which is what lets
2395
+ * the transport report the refusal exactly once. Every op in flight draws
2396
+ * the same refusal, and a bulk write has as many of those as it has unacked
2397
+ * writes, so the flip is the only moment worth telling the app about.
2398
+ *
2399
+ * @internal
2400
+ */
2401
+ _markAtCapacity(): boolean {
2402
+ if (this._atCapacity) return false;
2403
+ this._atCapacity = true;
2404
+ this._fireSubscribers({ kind: 'capacity' });
2405
+ return true;
2406
+ }
2407
+
1830
2408
  private _ensureLocalUpdatesSubscribed(): void {
1831
2409
  if (this._unsubLocal) return;
1832
2410
  if (!this._localOpHook) return;
@@ -1843,16 +2421,45 @@ export class Document {
1843
2421
  // actually flushes (the WS client may queue the frame).
1844
2422
  const copy = new Uint8Array(bytes.byteLength);
1845
2423
  copy.set(bytes);
1846
- const clientOpId = hook(copy);
1847
- this._outstandingByClientId.set(clientOpId, copy);
1848
- // The local mutate has already fired `subscribe` (the Loro
1849
- // event), but `savingCount` just incremented; fire one more
1850
- // synthetic event so a subscribe handler bound on `savingCount`
1851
- // sees the change.
1852
- this._fireSubscribers({ kind: 'saving-count' });
2424
+ const chunks = this.splitLocalUpdate(copy);
2425
+ if (chunks === null) {
2426
+ this._shipLocalOp(copy);
2427
+ return;
2428
+ }
2429
+ // In order: each chunk continues the one before it, so a peer applying
2430
+ // them out of sequence holds the tail unapplied until the gap closes.
2431
+ for (const chunk of chunks) this._shipLocalOp(chunk);
1853
2432
  });
1854
2433
  }
1855
2434
 
2435
+ // Hand one op blob to the transport and count it as unsaved, or keep it
2436
+ // local when the server has evicted this document.
2437
+ //
2438
+ // A gone document still takes edits by design: it stays open so the app can
2439
+ // render its own "no longer available" state, and the person may keep
2440
+ // interacting until they navigate. Those edits have nowhere to land. The
2441
+ // transport dropped its retry machinery on `doc_gone`, and the frame would
2442
+ // ship onto a subscription the server evicted and come back as a `forbidden`
2443
+ // that describes nothing the app did. Counting them is the part that shows:
2444
+ // each one adds to `savingCount` for a save that cannot happen, so the doc
2445
+ // reports "no longer available" and "Saving 3 changes..." together and
2446
+ // re-arms the leave-site prompt `_markGone` just released.
2447
+ //
2448
+ // Returns the minted `client_op_id`, or null when the op stayed local.
2449
+ private _shipLocalOp(copy: Uint8Array): string | null {
2450
+ const hook = this._localOpHook;
2451
+ if (!hook) return null;
2452
+ if (this._gone.reason !== null) return null;
2453
+ const clientOpId = hook(copy);
2454
+ this._outstandingByClientId.set(clientOpId, copy);
2455
+ // The local mutate has already fired `subscribe` (the Loro
2456
+ // event), but `savingCount` just incremented; fire one more
2457
+ // synthetic event so a subscribe handler bound on `savingCount`
2458
+ // sees the change.
2459
+ this._fireSubscribers({ kind: 'saving-count' });
2460
+ return clientOpId;
2461
+ }
2462
+
1856
2463
  // Lazy-attach the underlying LoroDoc subscriber. **Do not** wire this
1857
2464
  // in the constructor or before the initial snapshot import: with a
1858
2465
  // subscriber attached, snapshot-import of a 50k doc takes ~8.5 s
@@ -1873,14 +2480,24 @@ export class Document {
1873
2480
  // One channel: every event reaches every subscriber, whatever
1874
2481
  // produced it. The app reconciles by value and reads the cause off
1875
2482
  // the event only when it has a non-render reason to.
1876
- this._subs.emit(event);
2483
+ //
2484
+ // What reaches a subscriber is this SDK's own `{ kind, source }`, not
2485
+ // the engine's event object. An app that receives the engine's shape
2486
+ // can read its internals (container ids, peer ids, the raw diff) and
2487
+ // build on them, which pins the storage engine through a surface that
2488
+ // never promised it. The cause survives as `source`, the same
2489
+ // engine-neutral vocabulary the handle surface reports.
2490
+ this._subs.emit({
2491
+ kind: 'change',
2492
+ source: changeSourceOf(event),
2493
+ } satisfies SyntheticSubscribeEvent);
1877
2494
  const isLocalCommit = (event as { by?: unknown }).by === 'local';
1878
2495
  this._maybeFireUndoState();
1879
2496
  // A local commit (mutate / undo / redo) schedules a debounced
1880
2497
  // thumbnail capture, when the app has registered a canvas via
1881
- // `useCanvas`. Remote ops never trigger a capture: a viewer or
1882
- // a passive collaborator must not re-upload a screenshot of
1883
- // someone else's edit.
2498
+ // `useCanvas`. The debounce is for the peer that drew the change, so
2499
+ // it is local-only: a passive collaborator re-uploading a screenshot
2500
+ // of someone else's edit is what a per-op capture would be.
1884
2501
  if (isLocalCommit) {
1885
2502
  this._thumbnails.schedule();
1886
2503
  // Same reasoning for the implicit auto-checkpoint: only the
@@ -1888,6 +2505,17 @@ export class Document {
1888
2505
  // N-way session would write N redundant CHECKPOINT# rows per
1889
2506
  // idle period.
1890
2507
  this._checkpoints.scheduleAuto();
2508
+ } else {
2509
+ // Content arriving with no local commit behind it is what an import
2510
+ // looks like from here, and it moves the head past whatever picture
2511
+ // the document has. This DOES reach a reader who drew none of it,
2512
+ // which the debounce above deliberately does not: an import runs on
2513
+ // whichever client claimed it, so gating the seed on local authorship
2514
+ // would leave the documents that most need a picture without one.
2515
+ // Every such reader captures the same document at the same version,
2516
+ // and the targeted snapshot each upload enqueues is idempotent on
2517
+ // that version, so the concurrent seeds resolve themselves.
2518
+ this._armSeedIfPictureIsStale();
1891
2519
  }
1892
2520
  });
1893
2521
  }
@@ -1897,12 +2525,12 @@ export class Document {
1897
2525
  // construction stays cheap.
1898
2526
  //
1899
2527
  // `cb` fires for EVERY change, whatever produced it: a local `mutate()`,
1900
- // a remote `import`, `undo`, `redo`, `checkout`, and the SDK's synthetic
1901
- // metadata events (saving-count, branches, commands). One channel; the
1902
- // app re-reads / reconciles in the handler and branches on the event only
1903
- // when it needs the cause. CRDT-origin events keep their Loro shape and
1904
- // carry `by` / `origin` so the cause is recoverable; the engine-neutral
1905
- // `origin.source` lives on the handle surface.
2528
+ // a remote op, `undo`, `redo`, a history move, and the SDK's own metadata
2529
+ // events (saving-count, branches, commands). One channel; the app re-reads
2530
+ // / reconciles in the handler and branches on `event.kind` only when it
2531
+ // needs the cause. A content change arrives as
2532
+ // `{ kind: 'change', source }`, carrying the same engine-neutral `source`
2533
+ // the handle surface reports.
1906
2534
  subscribe(cb: DocumentSubscriber): DocumentUnsubscribe {
1907
2535
  this._assertOpen();
1908
2536
  this._ensureLoroSubscribed();
@@ -1988,10 +2616,10 @@ export class Document {
1988
2616
  // The Document is NOT auto-closed; callers may want to render a
1989
2617
  // "the document was deleted" overlay before closing. Disable any
1990
2618
  // pending mutations in the listener and call `close()` when ready.
1991
- // Mutations after `_markGone` still mutate the local CRDT and call
1992
- // the `LocalOpHook`, but the server will reject those op frames
1993
- // (the SUB# row was evicted at fanout time); the SDK consumer
1994
- // sees the rejection via the underlying transport's error channel.
2619
+ // Mutations after `_markGone` still mutate the local CRDT, and stay
2620
+ // local: `_shipLocalOp` keeps them off the transport and out of
2621
+ // `savingCount`, so this one notice is what the app hears about the
2622
+ // eviction rather than a refusal per later edit.
1995
2623
  onGone(cb: DocumentGoneListener): DocumentGoneUnsubscribe {
1996
2624
  // Guard like `subscribe` / `subscribeUndoState`: `_suspend` clears these
1997
2625
  // signals' listeners so a stale handle from a parked session does not
@@ -2035,10 +2663,27 @@ export class Document {
2035
2663
  // false`) is retained so a late `onStatus` subscriber still sees the
2036
2664
  // final state.
2037
2665
  /** @internal */
2038
- _emitStatus(status: DocumentWorkStatus): void {
2666
+ _emitStatus(
2667
+ status: DocumentWorkStatus,
2668
+ opts?: {
2669
+ // The caller imported the document's content immediately before this
2670
+ // emit, so the work delivered what it promised whatever the terminal
2671
+ // frame says.
2672
+ contentLoaded?: boolean;
2673
+ },
2674
+ ): void {
2039
2675
  // A late reload/reconcile closure that resolved after `close()` must not
2040
2676
  // re-emit onto a torn-down doc (its undo stack is freed below in `close`).
2041
2677
  if (this._closed) return;
2678
+ // Terminal work that still reports itself as locking editing is inbound
2679
+ // work that ended without content: the platform clears the status when
2680
+ // hydration / extraction succeeds, so what it reports terminally is a
2681
+ // failure. Anything else means the doc is either loading or holds what
2682
+ // it has.
2683
+ this._loadFailed =
2684
+ opts?.contentLoaded !== true &&
2685
+ status.pending === false &&
2686
+ status.blocking === true;
2042
2687
  this._workStatus.emit(status);
2043
2688
  // A blocking work-status is one of the inputs to `canUndo` / `canRedo`
2044
2689
  // (via `_isLoadLocked`), so a status that engages or releases the load
@@ -2049,10 +2694,10 @@ export class Document {
2049
2694
  }
2050
2695
 
2051
2696
  // The current drive sync state of this document, or `null` when the
2052
- // doc is not linked to a drive source. The read-side companion to
2053
- // `doc.pushToDrive(...)`: an app reads `canPush` / `state` to decide
2054
- // whether to surface a "Publish" affordance, and renders the
2055
- // server-authored `label` / `detail` as the wording. Seeded on open
2697
+ // doc is not linked to a drive source. An app reads `state` / `canPush`
2698
+ // to tell the reader they have unpublished edits, and renders the
2699
+ // server-authored `label` / `detail` as the wording; publishing itself
2700
+ // is the hosted toolbar's control. Seeded on open
2056
2701
  // (for a drive-linked doc) and kept live as edits, refreshes, and
2057
2702
  // pushes move the state.
2058
2703
  get driveStatus(): DocumentDriveStatus | null {
@@ -2082,6 +2727,55 @@ export class Document {
2082
2727
  _emitDriveStatus(status: DocumentDriveStatus): void {
2083
2728
  if (this._closed) return;
2084
2729
  this._driveStatus.emit(status);
2730
+ // A document that has become a mirror publishes through its link from
2731
+ // now on, so the create-an-artifact offer goes with the arrival of the
2732
+ // status that replaces it.
2733
+ this._drivePublishTarget.set(null);
2734
+ }
2735
+
2736
+ // Whether this document's export runs in THIS app, which is what decides
2737
+ // whether a push carries bytes. Not on `driveStatus`: it is a fact about
2738
+ // the caller rather than about the document, and it is all an app is told
2739
+ // about the exporter, never which app it is when it is not this one.
2740
+ private _exportsHere = false;
2741
+
2742
+ /** @internal */
2743
+ get _exportRunsHere(): boolean {
2744
+ return this._exportsHere;
2745
+ }
2746
+
2747
+ /** @internal */
2748
+ _setExportRunsHere(runsHere: boolean): void {
2749
+ this._exportsHere = runsHere;
2750
+ }
2751
+
2752
+ // Internal: where this document can be published while it mirrors nothing.
2753
+ /** @internal */
2754
+ get _publishTarget(): DrivePublishTarget | null {
2755
+ return this._drivePublishTarget.target;
2756
+ }
2757
+
2758
+ /** @internal */
2759
+ _setPublishTarget(target: DrivePublishTarget | null): void {
2760
+ if (this._closed) return;
2761
+ this._drivePublishTarget.set(target);
2762
+ }
2763
+
2764
+ // Internal: how the publish of a document that mirrors nothing is going.
2765
+ /** @internal */
2766
+ get _publishState(): DrivePublishState {
2767
+ return this._drivePublishTarget.state;
2768
+ }
2769
+
2770
+ /** @internal */
2771
+ _emitPublishState(state: DrivePublishState): void {
2772
+ if (this._closed) return;
2773
+ this._drivePublishTarget.setState(state);
2774
+ }
2775
+
2776
+ /** @internal */
2777
+ _onPublishState(cb: () => void): () => void {
2778
+ return this._drivePublishTarget.onState(cb);
2085
2779
  }
2086
2780
 
2087
2781
  // Internal: mark the doc evicted. Idempotent (multiple frames arrive
@@ -2104,12 +2798,26 @@ export class Document {
2104
2798
 
2105
2799
  // Plain-JS projection of the current state.
2106
2800
  toJSON(): Record<string, unknown> {
2801
+ this._assertNotClosed();
2107
2802
  // When in checkout, read from the preview LoroDoc so the app
2108
2803
  // renders the historical state. Returning to head clears
2109
2804
  // `_checkout` and reads go back to the live `_doc`.
2110
2805
  const source =
2111
2806
  this._checkout !== null ? this._checkout.previewDoc : this._doc;
2112
- return source.toJSON() as Record<string, unknown>;
2807
+ const json = source.toJSON() as Record<string, unknown>;
2808
+ // A paged root holds its records inside page entries beside the store's
2809
+ // own edit bookkeeping, so flatten it to the same record lookup
2810
+ // `doc.map(root).read()` hands back: a serializer and a whole-document
2811
+ // renderer read this surface, and a store that crossed the paging
2812
+ // threshold has to reach them as records. Every root is asked, because a
2813
+ // root no handle was ever taken on is paged by the same rule as one that
2814
+ // was; a root the marker does not name comes back as loro produced it.
2815
+ for (const [rootKey, value] of Object.entries(json)) {
2816
+ if (!isRootLookup(value)) continue;
2817
+ if (!readRootPaged(source, rootKey)) continue;
2818
+ json[rootKey] = flattenPagedLookup(value, true);
2819
+ }
2820
+ return json;
2113
2821
  }
2114
2822
 
2115
2823
  /**
@@ -2128,29 +2836,36 @@ export class Document {
2128
2836
  // in either caller. No-op when the durable frontier already covers the
2129
2837
  // local doc, while a prior resync push is still unacked, or when the
2130
2838
  // missing slice is too large to ship as one op (see the cap below).
2839
+ //
2840
+ // The return value says whether the caller may stop asking. `covered`
2841
+ // means the durable frontier holds everything local does, so the
2842
+ // handshake is finished; every other outcome leaves the doc diverged and
2843
+ // is the caller's cue to run the handshake again once the push settles.
2131
2844
  /** @internal */
2132
2845
  maybeResyncPush(
2133
2846
  persistedSnapshotVersionVector: Uint8Array,
2134
2847
  durableTailBlobs: Uint8Array[],
2135
- ): void {
2848
+ ): ResyncPushOutcome {
2136
2849
  // `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
2137
2850
  // closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
2138
2851
  // closure can't touch freed WASM.
2139
- if (this._closed) return;
2852
+ if (this._closed) return 'closed';
2140
2853
  // Loop guard: skip while any chunk of a prior push is still unacked,
2141
2854
  // so a flurry of reconnects cannot re-push the same missing slice.
2142
2855
  for (const id of this._resyncPushClientOpIds) {
2143
- if (this._outstandingByClientId.has(id)) return;
2856
+ if (this._outstandingByClientId.has(id)) return 'in-flight';
2144
2857
  }
2145
2858
  const chunks = this.computeResyncPush(
2146
2859
  persistedSnapshotVersionVector,
2147
2860
  durableTailBlobs,
2148
2861
  );
2149
- if (chunks === null) return;
2862
+ if (chunks === 'covered') return 'covered';
2863
+ if (chunks === 'undecodable') return 'undecodable';
2150
2864
  for (const chunk of chunks) {
2151
2865
  const id = this.injectLocalUpdate(chunk);
2152
2866
  if (id !== null) this._resyncPushClientOpIds.add(id);
2153
2867
  }
2868
+ return 'pushed';
2154
2869
  }
2155
2870
 
2156
2871
  // Given the server's persisted snapshot version vector and the op
@@ -2168,10 +2883,13 @@ export class Document {
2168
2883
  // case. `decodeImportBlobMeta` reads each blob's end vector from its
2169
2884
  // header without a full import, so this stays cheap.
2170
2885
  //
2171
- // Returns the chunks to resync-push, or null when the durable state
2172
- // already covers everything local holds (or a malformed vector / tail
2173
- // blob, which degrades the handshake to a no-op and warns rather than
2174
- // throwing out of the frame handler that drives the live transport).
2886
+ // Returns the chunks to resync-push, `'covered'` when the durable state
2887
+ // already holds everything local does, or `'undecodable'` for a malformed
2888
+ // vector / tail blob, which degrades the handshake to a no-op and warns
2889
+ // rather than throwing out of the frame handler that drives the live
2890
+ // transport. `'undecodable'` is distinct from `'covered'` because it says
2891
+ // nothing about whether the doc has diverged, so a caller healing a known
2892
+ // divergence must keep asking.
2175
2893
  //
2176
2894
  // The common case is one chunk: an `update` export from the durable
2177
2895
  // frontier carries only the missing ops plus their dependencies, never
@@ -2183,11 +2901,47 @@ export class Document {
2183
2901
  // large divergence heals instead of being skipped. Import is idempotent
2184
2902
  // and order-independent (Loro buffers a chunk whose dependency is in a
2185
2903
  // sibling chunk until it arrives), so the chunks need no ordering.
2904
+ /**
2905
+ * Whether the durable snapshot holds changes by an author this doc has never
2906
+ * heard from.
2907
+ *
2908
+ * Every author a client can hear from reaches it as ops, so a peer present
2909
+ * in the durable frontier and absent from the local doc after the tail has
2910
+ * applied is one whose changes exist only inside the snapshot. That is the
2911
+ * platform having rewritten how the document stores what it stores
2912
+ * (compaction's patch fold is the one thing that does this), which a client
2913
+ * cannot merge with and cannot undo across: its undo stack inverts the
2914
+ * operations it made, and the values those operations wrote now live
2915
+ * somewhere else.
2916
+ *
2917
+ * The answer drives a re-import, which is the existing recovery for exactly
2918
+ * this shape (`_applySnapshot` drops both stacks, so undo reports itself
2919
+ * unavailable instead of silently reverting nothing).
2920
+ *
2921
+ * @internal
2922
+ */
2923
+ _snapshotHasUnseenAuthor(persistedSnapshotVersionVector: Uint8Array): boolean {
2924
+ if (this._closed) return false;
2925
+ let durable: VersionVector;
2926
+ try {
2927
+ durable = VersionVector.decode(persistedSnapshotVersionVector);
2928
+ } catch {
2929
+ // Undecodable says nothing, and the durability handshake already warns
2930
+ // about it on the same frame.
2931
+ return false;
2932
+ }
2933
+ const local = this._doc.version().toJSON();
2934
+ for (const [peer, counter] of durable.toJSON()) {
2935
+ if (counter > 0 && !local.has(peer)) return true;
2936
+ }
2937
+ return false;
2938
+ }
2939
+
2186
2940
  /** @internal */
2187
2941
  computeResyncPush(
2188
2942
  persistedSnapshotVersionVector: Uint8Array,
2189
2943
  durableTailBlobs: Uint8Array[],
2190
- ): Uint8Array[] | null {
2944
+ ): Uint8Array[] | 'covered' | 'undecodable' {
2191
2945
  let durable: VersionVector;
2192
2946
  try {
2193
2947
  durable = VersionVector.decode(persistedSnapshotVersionVector);
@@ -2200,7 +2954,7 @@ export class Document {
2200
2954
  '[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob',
2201
2955
  err,
2202
2956
  );
2203
- return null;
2957
+ return 'undecodable';
2204
2958
  }
2205
2959
  const local = this._doc.version();
2206
2960
  const cmp = local.compare(durable);
@@ -2209,7 +2963,7 @@ export class Document {
2209
2963
  // undefined when the two are concurrent (local holds ops the server
2210
2964
  // lacks AND vice versa). Push in the ahead and concurrent cases; the
2211
2965
  // export from `durable` carries only the local-only ops either way.
2212
- if (cmp !== undefined && cmp <= 0) return null;
2966
+ if (cmp !== undefined && cmp <= 0) return 'covered';
2213
2967
  // Common case: the whole missing slice fits in one frame. Keep this
2214
2968
  // path byte-identical to a plain `update` export so the no-chunking
2215
2969
  // case is unchanged.
@@ -2237,39 +2991,98 @@ export class Document {
2237
2991
  }
2238
2992
 
2239
2993
  // Recursively export the given ranges as one `updates-in-range` blob,
2240
- // splitting by op count until each blob is under the per-frame cap. An
2241
- // irreducible single op (one that alone exceeds the cap) is emitted up
2242
- // to the hard ceiling, or skipped with a warning beyond it so the loop
2243
- // never wedges on an un-sendable frame.
2994
+ // splitting by op count until each blob is under the per-chunk cap.
2995
+ //
2996
+ // `dropOversizedSingle` decides what happens to an irreducible op (one that
2997
+ // alone exceeds the cap and the hard ceiling). A resync drops it with a
2998
+ // warning so the loop never wedges on an un-sendable frame while the rest
2999
+ // of the slice heals. A local commit keeps it: it is an edit the app just
3000
+ // made and nothing else will carry it, so it goes to the write path, which
3001
+ // reports the refusal to the app rather than losing it silently.
3002
+ private chunkRanges(
3003
+ ranges: { peer: PeerID; from: number; to: number }[],
3004
+ dropOversizedSingle: boolean,
3005
+ ): Uint8Array[] {
3006
+ const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
3007
+ if (total === 0) return [];
3008
+ const blob = this._doc.export({
3009
+ mode: 'updates-in-range',
3010
+ spans: ranges.map((r) => ({
3011
+ id: { peer: r.peer, counter: r.from },
3012
+ len: r.to - r.from,
3013
+ })),
3014
+ });
3015
+ if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES) return [blob];
3016
+ if (total === 1) {
3017
+ // A single op that cannot be split further.
3018
+ if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES) return [blob];
3019
+ if (!dropOversizedSingle) return [blob];
3020
+ console.warn(
3021
+ `[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`,
3022
+ );
3023
+ return [];
3024
+ }
3025
+ const [first, second] = splitRangesByLength(ranges, total);
3026
+ return [
3027
+ ...this.chunkRanges(first, dropOversizedSingle),
3028
+ ...this.chunkRanges(second, dropOversizedSingle),
3029
+ ];
3030
+ }
3031
+
2244
3032
  private chunkMissingSlice(
2245
3033
  local: VersionVector,
2246
3034
  durable: VersionVector,
2247
3035
  ): Uint8Array[] {
2248
- const pack = (
2249
- ranges: { peer: PeerID; from: number; to: number }[],
2250
- ): Uint8Array[] => {
2251
- const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
2252
- if (total === 0) return [];
2253
- const blob = this._doc.export({
2254
- mode: 'updates-in-range',
2255
- spans: ranges.map((r) => ({
2256
- id: { peer: r.peer, counter: r.from },
2257
- len: r.to - r.from,
2258
- })),
2259
- });
2260
- if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES) return [blob];
2261
- if (total === 1) {
2262
- // A single op that cannot be split further.
2263
- if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES) return [blob];
2264
- console.warn(
2265
- `[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`,
2266
- );
2267
- return [];
3036
+ return this.chunkRanges(this.missingRanges(local, durable), true);
3037
+ }
3038
+
3039
+ // A local commit re-exported as several op blobs, or null to ship it whole.
3040
+ //
3041
+ // `mutate` bounds what one commit carries by committing as it goes, but a
3042
+ // commit Loro makes for itself takes no such budget: `redo` replays a whole
3043
+ // undo step, so a bulk write that correctly split into many ops comes back
3044
+ // as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
3045
+ // is refused outright and the redone edit never persists, and below it, it
3046
+ // still lands as a single large row on the document's one storage partition.
3047
+ //
3048
+ // The ops already exist, so the only lever left is which spans they ship in.
3049
+ // The blob names the counter range it covers, and re-exporting a sub-range
3050
+ // is what the durability resync already does, so the chunks are ordinary ops
3051
+ // a peer applies in sequence.
3052
+ //
3053
+ // Splitting is abandoned unless EVERY chunk comes in under the cap. A commit
3054
+ // holding one value nothing can split (a mesh, a large embedded body) would
3055
+ // otherwise ship as a small chunk that lands and an irreducible one that the
3056
+ // write path refuses, which puts a record on the server missing the field
3057
+ // that made it large and leaves the two copies permanently apart. Whole, its
3058
+ // refusal covers the whole edit and the app is told once.
3059
+ private splitLocalUpdate(blob: Uint8Array): Uint8Array[] | null {
3060
+ if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES) return null;
3061
+ let ranges: { peer: PeerID; from: number; to: number }[];
3062
+ try {
3063
+ const meta = decodeImportBlobMeta(blob, false);
3064
+ const start = meta.partialStartVersionVector.toJSON();
3065
+ ranges = [];
3066
+ for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
3067
+ const from = start.get(peer) ?? 0;
3068
+ if (to > from) ranges.push({ peer, from, to });
2268
3069
  }
2269
- const [first, second] = splitRangesByLength(ranges, total);
2270
- return [...pack(first), ...pack(second)];
2271
- };
2272
- return pack(this.missingRanges(local, durable));
3070
+ } catch {
3071
+ // An undecodable blob is one this cannot describe in spans. Shipping it
3072
+ // whole is what happens without this path at all.
3073
+ return null;
3074
+ }
3075
+ const total = rangeOpCount(ranges);
3076
+ if (total < 2) return null;
3077
+ // The blob's own size names how many pieces to ask for, so the split does
3078
+ // not rediscover that by halving.
3079
+ const parts = Math.min(total, Math.ceil(blob.byteLength / RESYNC_PUSH_MAX_BYTES));
3080
+ const chunks = splitRangesEvenly(ranges, parts).flatMap((piece) =>
3081
+ this.chunkRanges(piece, false),
3082
+ );
3083
+ if (chunks.length < 2) return null;
3084
+ if (chunks.some((c) => c.byteLength > RESYNC_PUSH_MAX_BYTES)) return null;
3085
+ return chunks;
2273
3086
  }
2274
3087
 
2275
3088
  // Feed an op blob into the local-op send path as if it were a fresh
@@ -2283,14 +3096,9 @@ export class Document {
2283
3096
  /** @internal */
2284
3097
  injectLocalUpdate(blob: Uint8Array): string | null {
2285
3098
  this._ensureLocalUpdatesSubscribed();
2286
- const hook = this._localOpHook;
2287
- if (!hook) return null;
2288
3099
  const copy = new Uint8Array(blob.byteLength);
2289
3100
  copy.set(blob);
2290
- const clientOpId = hook(copy);
2291
- this._outstandingByClientId.set(clientOpId, copy);
2292
- this._fireSubscribers({ kind: 'saving-count' });
2293
- return clientOpId;
3101
+ return this._shipLocalOp(copy);
2294
3102
  }
2295
3103
 
2296
3104
  // Internal: park the Document in the SDK cache. Clears app-side
@@ -2305,6 +3113,7 @@ export class Document {
2305
3113
  _suspend(): void {
2306
3114
  if (this._closed || this._suspended) return;
2307
3115
  this._suspended = true;
3116
+ this._setStreamsSuspended(true);
2308
3117
  // A pending auto-checkpoint would otherwise fire against a parked
2309
3118
  // Document and silently no-op via the scheduler's guard.
2310
3119
  // Cancelling here keeps the timer count clean across long-lived
@@ -2315,6 +3124,7 @@ export class Document {
2315
3124
  this._gone.clearListeners();
2316
3125
  this._workStatus.clearListeners();
2317
3126
  this._driveStatus.clearListeners();
3127
+ this._drivePublishTarget.clearListeners();
2318
3128
  // Presence subscribes its OWN WS topic and keeps it alive across a park
2319
3129
  // (resume does not re-create the controller), so drop its app-side
2320
3130
  // change listeners here the same way as the sets above: a parked doc
@@ -2347,6 +3157,7 @@ export class Document {
2347
3157
  /** @internal */
2348
3158
  _resume(): void {
2349
3159
  this._suspended = false;
3160
+ this._setStreamsSuspended(false);
2350
3161
  // Pair of the `_suspend` presence gate: reopen the presence controller's
2351
3162
  // outbound path and re-advertise this client's cursor on the resumed open.
2352
3163
  // `_suspend` only closed the gate; without this the parked doc's presence
@@ -2381,12 +3192,13 @@ export class Document {
2381
3192
  this._undo.free();
2382
3193
  this._undo = null;
2383
3194
  }
2384
- this._thumbnails.cancel();
3195
+ this._thumbnails.destroy();
2385
3196
  this._checkpoints.cancelAuto();
2386
3197
  this._localOpHook = null;
2387
3198
  this._currentSeqProvider = null;
2388
3199
  this._outstandingByClientId.clear();
2389
3200
  this._resyncPushClientOpIds.clear();
3201
+ releaseUnsavedWork(this);
2390
3202
  this._subs.clear();
2391
3203
  this._undoStateSubs.clear();
2392
3204
  // Drop the signal listeners too, mirroring `_suspend()`. Their frames
@@ -2396,6 +3208,7 @@ export class Document {
2396
3208
  this._gone.clearListeners();
2397
3209
  this._workStatus.clearListeners();
2398
3210
  this._driveStatus.clearListeners();
3211
+ this._drivePublishTarget.clearListeners();
2399
3212
  // Detach handle subscriptions before freeing the doc (below): a handle's
2400
3213
  // per-container Loro subscription outlives the cache reference, and
2401
3214
  // `_doc.free()` must not run with subscriptions still attached.