@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/dist/documents.js CHANGED
@@ -9,14 +9,16 @@
9
9
  // `Object.keys`; doing `loroMap.keys().includes(key)` there makes
10
10
  // `Object.keys()` O(N^2). It reads a single `loroMap.get(key)`.
11
11
  import { LoroDoc, LoroMap, VersionVector, decodeImportBlobMeta, } from 'loro-crdt';
12
- import { MutateBatcher } from './mutate-batching.js';
13
- import { UndoController, } from './undo.js';
14
- import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, teardownRootHandle, wrapLoroMapProxy, } from './handles.js';
15
- import { CURRENT_PAGING_SCHEME, isPagedByPageCount, META_KEY, PAGE_COUNT_KEY, PAGING_SCHEME_KEY, RECORDS_KEY, } from './record-paging.js';
16
- import { GoneSignal, WorkStatusSignal, DriveStatusSignal, } from './document-signals.js';
12
+ import { PAGING_COMMIT_ORIGIN, UndoController } from './undo.js';
13
+ import { createOrderedMapHandle, createRootListHandle, createRootMapHandle, rebindRootListAnchor, rebindRootMapAnchor, resyncRootHandle, snapshotRootMapKeys, teardownRootHandle, } from './handles.js';
14
+ import { flattenPagedLookup, isPagedMarker, META_KEY, pagedRootKey, } from './record-paging.js';
15
+ import { MAX_OP_BLOB_CHARS } from './ws-frame-limit.js';
16
+ import { GoneSignal, WorkStatusSignal, DriveStatusSignal, DrivePublishTargetSignal, } from './document-signals.js';
17
17
  import { ThumbnailCapture, } from './document-thumbnail-capture.js';
18
18
  import { CheckpointScheduler, } from './document-checkpoints.js';
19
+ import { whenSaved } from './when-saved.js';
19
20
  import { BranchController, } from './document-branches.js';
21
+ import { releaseUnsavedWork, trackUnsavedWork } from './unload-guard.js';
20
22
  import { CommandMenu, } from './document-commands.js';
21
23
  import { DocumentStream } from './document-streams.js';
22
24
  import { createEmitter } from './emitter.js';
@@ -42,8 +44,6 @@ function changeSourceOf(event) {
42
44
  const by = event.by;
43
45
  if (by === 'import')
44
46
  return 'remote';
45
- if (by === 'checkout')
46
- return 'checkout';
47
47
  // by === 'local': disambiguate via the commit-origin tag.
48
48
  const origin = event.origin;
49
49
  if (origin === UNDO_COMMIT_ORIGIN)
@@ -52,19 +52,35 @@ function changeSourceOf(event) {
52
52
  return 'redo';
53
53
  return 'local';
54
54
  }
55
- // Per-chunk cap on a resync-push blob (durability handshake). Each push
56
- // ships as one op frame, and the transport carries 128 KB; the blob is
57
- // base64-encoded (~1.33x) inside a JSON envelope, so the raw blob must
58
- // stay well under that. 64 KB base64s to ~85 KB. A larger missing slice
59
- // is split into multiple chunks (see `computeResyncPush`), each capped.
60
- const RESYNC_PUSH_MAX_BYTES = 64 * 1024;
61
- // Hard ceiling for a single irreducible op (one Loro op, unsplittable).
62
- // Sized so the base64 frame still clears the 128 KB limit (90 KB base64s
63
- // to ~120 KB plus envelope). An op this large cannot occur on a doc
64
- // whose normal edits send at all (same frame limit), so this only guards
65
- // a pathological op; it is skipped with a warning rather than retried
55
+ // Per-chunk cap on a blob this Document pushes as several ops: a durability
56
+ // resync's missing slice (`computeResyncPush`) and a local commit no write
57
+ // budget bounded (`splitLocalUpdate`).
58
+ //
59
+ // Each chunk goes out through the same local-op hook an ordinary commit
60
+ // uses, so the transport picks itself by size and a chunk this large takes
61
+ // the HTTP op write. Sizing the chunk to a WebSocket frame instead is what
62
+ // makes a heal the most expensive write the SDK performs: the slice a bulk
63
+ // write leaves behind is the whole document, and at frame size that is
64
+ // thousands of ops, each one landing on the document's single storage
65
+ // partition at exactly the size that stays inline on its row. Measured, a
66
+ // 40 MB divergence healed in 16 KB chunks throttled the partition and left
67
+ // the join a tail it refused to serve. The same divergence at this size is
68
+ // tens of ops whose bytes go to the bucket instead.
69
+ const RESYNC_PUSH_MAX_BYTES = 512 * 1024;
70
+ // Hard ceiling for a single irreducible op (one Loro op, unsplittable),
71
+ // beyond which the chunk is skipped with a warning rather than retried
66
72
  // forever, while the doc's other chunks still heal.
67
- const RESYNC_PUSH_HARD_MAX_BYTES = 90 * 1024;
73
+ //
74
+ // Skipping drops an edit the server never received, so this sits at exactly
75
+ // what the write path can carry rather than at what a frame holds. Base64
76
+ // turns B raw bytes into ceil(B/3)*4 characters, so the last raw size whose
77
+ // encoding still fits the durable cap is three quarters of it. Derived
78
+ // rather than written out, because the two have to move together and a
79
+ // literal here would let the ceiling drift above what the server accepts,
80
+ // which turns a heal into silent loss. A blob this large travels through
81
+ // the staged upload rather than a request body, a path the write already
82
+ // takes on its own size.
83
+ const RESYNC_PUSH_HARD_MAX_BYTES = Math.floor((MAX_OP_BLOB_CHARS * 3) / 4);
68
84
  // Element-wise max of two Loro version vectors. `VersionVector` exposes
69
85
  // no public join, so round-trip through its `Map<PeerID, number>` JSON
70
86
  // form. Counters are exclusive ends, so the larger is more advanced.
@@ -79,57 +95,157 @@ function mergeVersionVectors(a, b) {
79
95
  }
80
96
  return VersionVector.parseJSON(merged);
81
97
  }
82
- // Split half-open per-peer counter ranges into two halves by total op
83
- // count, splitting at most one range across the boundary. `total` must
84
- // be >= 2 so both halves are non-empty (the caller handles total === 1
85
- // as the irreducible single-op case). Order is preserved.
86
- function splitRangesByLength(ranges, total) {
87
- const half = Math.floor(total / 2);
98
+ function rangeOpCount(ranges) {
99
+ return ranges.reduce((n, r) => n + (r.to - r.from), 0);
100
+ }
101
+ // Split half-open per-peer counter ranges in two after `at` ops, splitting at
102
+ // most one range across the boundary. Order is preserved.
103
+ function splitRangesAt(ranges, at) {
88
104
  const first = [];
89
105
  const second = [];
90
106
  let acc = 0;
91
107
  for (const r of ranges) {
92
108
  const len = r.to - r.from;
93
- if (acc >= half) {
109
+ if (acc >= at) {
94
110
  second.push(r);
95
111
  }
96
- else if (acc + len <= half) {
112
+ else if (acc + len <= at) {
97
113
  first.push(r);
98
114
  acc += len;
99
115
  }
100
116
  else {
101
- const splitAt = r.from + (half - acc);
117
+ const splitAt = r.from + (at - acc);
102
118
  first.push({ peer: r.peer, from: r.from, to: splitAt });
103
119
  second.push({ peer: r.peer, from: splitAt, to: r.to });
104
- acc = half;
120
+ acc = at;
105
121
  }
106
122
  }
107
123
  return [first, second];
108
124
  }
109
- // The record store's page count off a doc's `meta` map, or 0 when
110
- // unpaged. `handles.ts` reads it to resolve which page a key hashes to
111
- // AND to decide whether the store is paged at all (on every root-map
112
- // read / edit). Read by PATH, not `getMap(META_KEY)`: `getMap` is
113
- // get-or-create and would materialize an empty `meta` root on every
114
- // unpaged document, polluting its wire shape; `getByPath` is a pure read
115
- // returning undefined when `meta` does not exist.
116
- function readStorePageCount(source) {
117
- const pc = source.getByPath(`${META_KEY}/${PAGE_COUNT_KEY}`);
118
- // Same "is paged" predicate as the formats/core readers, so the SDK
119
- // handle path and the store readers cannot disagree on a malformed
120
- // count (e.g. a non-finite value must read as unpaged everywhere).
121
- return isPagedByPageCount(pc) ? pc : 0;
125
+ // Split into two halves by op count. `total` must be >= 2 so both halves are
126
+ // non-empty (the caller handles total === 1 as the irreducible single-op case).
127
+ function splitRangesByLength(ranges, total) {
128
+ return splitRangesAt(ranges, Math.floor(total / 2));
129
+ }
130
+ // Split into `parts` pieces of roughly equal op count, in order.
131
+ //
132
+ // A caller that already knows the whole range's byte size can name the number
133
+ // of pieces it wants instead of discovering it by halving, which costs one
134
+ // export per level and re-exports the same ops at every level: an 8 MB commit
135
+ // takes 31 exports to reach sixteen chunks by halving and 16 by naming them.
136
+ // The pieces are still measured afterwards, so an uneven commit that leaves a
137
+ // piece over the cap is split further from there.
138
+ function splitRangesEvenly(ranges, parts) {
139
+ let rest = [...ranges];
140
+ let remaining = rangeOpCount(rest);
141
+ if (parts <= 1 || remaining <= parts)
142
+ return [rest];
143
+ const out = [];
144
+ for (let left = parts; left > 1; left -= 1) {
145
+ const at = Math.floor(remaining / left);
146
+ if (at < 1)
147
+ break;
148
+ const [first, second] = splitRangesAt(rest, at);
149
+ out.push(first);
150
+ rest = second;
151
+ remaining -= at;
152
+ }
153
+ out.push(rest);
154
+ return out.filter((piece) => rangeOpCount(piece) > 0);
122
155
  }
123
- // The paging scheme a paged store was written under, off the same `meta` map
124
- // as the page count (read by path, not `getMap`, for the same reason: a pure
125
- // read that never materializes an empty `meta` root). A paged store predating
126
- // the stamp reports the current scheme, so it stays editable; a store written
127
- // under a newer scheme reports that number, and the edit path refuses it
128
- // rather than hashing a key into the wrong page.
129
- function readStorePagingScheme(source) {
130
- const s = source.getByPath(`${META_KEY}/${PAGING_SCHEME_KEY}`);
131
- return typeof s === 'number' && s > 0 ? s : CURRENT_PAGING_SCHEME;
156
+ // The `meta` root a document's paged marker lives in, or `null` while the
157
+ // document has none.
158
+ //
159
+ // The marker is read on every root-map read, edit and keyed lookup, and a
160
+ // reader that answers from a copy taken earlier is what puts paged-shaped keys
161
+ // into a store whose marker says unpaged, or reads a root a peer paged as its
162
+ // raw page entries. Answering off the live container instead makes both
163
+ // impossible: a peer's write lands in that same container, so the next read
164
+ // sees it, with no invalidation to get right.
165
+ //
166
+ // What is remembered per document is the resolved CONTAINER, and only once the
167
+ // root exists. A root handle reads through to the document's current state, so
168
+ // holding one keeps the value live: a peer's import and a checkout both land
169
+ // on the same container the cell already points at.
170
+ //
171
+ // Holding it is also what keeps this off the leak. Every `getMap` mints a new
172
+ // wasm-bindgen handle that dropping does not reclaim (the same fact `pageValue`
173
+ // and `entryExists` free around), and this resolves on every root-map read,
174
+ // edit and keyed lookup, so minting per call retains ~145 bytes each for the
175
+ // length of the session. One handle per document is a bounded cost instead.
176
+ //
177
+ // Resolution goes through `getByPath` rather than `getMap`, which is
178
+ // get-or-create and would materialize an empty `meta` on a document that has
179
+ // none (in the wire shape of every unpaged document, and as a local write on a
180
+ // read-only open). Its cost tracks the document's container count, and it is
181
+ // paid once per document.
182
+ const metaRoots = new WeakMap();
183
+ function metaRoot(source) {
184
+ let cell = metaRoots.get(source);
185
+ if (cell === undefined) {
186
+ cell = { map: null };
187
+ metaRoots.set(source, cell);
188
+ }
189
+ if (cell.map === null) {
190
+ const found = source.getByPath(META_KEY);
191
+ if (!(found instanceof LoroMap))
192
+ return null;
193
+ cell.map = found;
194
+ }
195
+ return cell.map;
132
196
  }
197
+ /** The `meta` root, creating it if `source` has none, and remembering it so
198
+ * later reads resolve without minting a second handle. */
199
+ function metaRootOrCreate(source) {
200
+ const existing = metaRoot(source);
201
+ if (existing !== null)
202
+ return existing;
203
+ const created = source.getMap(META_KEY);
204
+ metaRoots.set(source, { map: created });
205
+ return created;
206
+ }
207
+ /** Whether one root map is paged. Same predicate the record-store readers
208
+ * use, so the SDK handle path and those readers cannot disagree. */
209
+ function readRootPaged(source, rootKey) {
210
+ return isPagedMarker(metaRoot(source)?.get(pagedRootKey(rootKey)));
211
+ }
212
+ /** Whether a root's JSON is the keyed lookup a record store flattens. A root
213
+ * that projects to a list or a scalar cannot hold pages whatever `meta`
214
+ * says, so it is handed back untouched. */
215
+ function isRootLookup(value) {
216
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
217
+ }
218
+ // Mark `rootKey` paged, under a `meta` key of its own. Written ONCE and never
219
+ // revised: pages are addressed by session and index, so nothing downstream
220
+ // needs to know how many there are or how much the store grows afterwards.
221
+ //
222
+ // A key per root is what lets two peers page two different roots at the same
223
+ // time. One entry naming every paged root would have to be read, extended and
224
+ // written back whole, and the losing side of that race takes its root's marker
225
+ // with it (see `PAGED_ROOT_PREFIX`); two keys merge.
226
+ //
227
+ // The marker commits on its own, under an origin the undo stack excludes, and
228
+ // `commitPending` lands whatever the running mutation has written so far
229
+ // first. Two properties ride on that. Peers and the server never see a page
230
+ // without the marker that says how to read it, because the marker's op
231
+ // precedes every op carrying a page. And nothing an app authored is taken out
232
+ // of the undo stack with it: a commit carrying both would exclude the records
233
+ // as well as the marker.
234
+ function pageRoot(source, rootKey, commitPending) {
235
+ if (readRootPaged(source, rootKey))
236
+ return;
237
+ commitPending();
238
+ metaRootOrCreate(source).set(pagedRootKey(rootKey), true);
239
+ source.setNextCommitOrigin(PAGING_COMMIT_ORIGIN);
240
+ source.commit();
241
+ }
242
+ const READ_ONLY_MESSAGES = {
243
+ link: 'Document is read-only: it was opened via a view-only share link.',
244
+ grant: 'Document is read-only: you have view-only access to it.',
245
+ app: 'Document is read-only: this app is not permitted to edit it.',
246
+ unsaved: 'Document is read-only: a change could not be saved, so this copy holds ' +
247
+ 'work the platform does not. Brass is replacing it with the platform copy.',
248
+ };
133
249
  // Public Document class. Wraps a LoroDoc; exposes mutate / subscribe /
134
250
  // toJSON / close. Construction is cheap: the Loro subscriber attaches
135
251
  // lazily (see `_ensureLoroSubscribed`).
@@ -164,13 +280,15 @@ export class Document {
164
280
  // leaves this `false`.
165
281
  _isFrozen;
166
282
  // Forced read-only on a LIVE head open (distinct from `_isFrozen`, which
167
- // is a version-pinned open with no live channel). The anonymous
168
- // link-guest open sets this: the Document keeps a live WS subscription
169
- // and applies incoming remote ops, but local `mutate` is rejected, so a
170
- // guest sees edits stream in while being unable to write.
283
+ // is a version-pinned open with no live channel): the Document keeps a
284
+ // live WS subscription and applies incoming remote ops, but local
285
+ // `mutate` is rejected, so the reader sees edits stream in while being
286
+ // unable to write. Set at open from the join's `can_edit` (a viewer's
287
+ // grant, a view-only link), and latched on afterwards by
288
+ // `_markReadOnly` when the server refuses a write on the same grounds.
171
289
  _forcedReadOnly;
172
- _root;
173
- _entities;
290
+ // Which refusal `_forcedReadOnly` stands for, so `mutate` names it.
291
+ _readOnlyReason;
174
292
  // The UndoManager is constructed eagerly so the first call to
175
293
  // `doc.mutate(...)` lands in the undo stack. Lazy construction
176
294
  // (deferred to first `doc.undo()`) would miss every commit made
@@ -184,7 +302,9 @@ export class Document {
184
302
  // app reconciles by value and reads `origin.source` only when it has a
185
303
  // non-render reason to know the cause. A throwing subscriber is swallowed
186
304
  // so it cannot strand the others or the WS router.
187
- _subs = createEmitter({ onError: () => { } });
305
+ _subs = createEmitter({
306
+ onError: () => { },
307
+ });
188
308
  // Subscribers registered via `subscribeUndoState`. Fired after
189
309
  // every underlying Loro event when `canUndo()` or `canRedo()`
190
310
  // changed value vs the last fire, so UI bound to undo/redo
@@ -222,9 +342,24 @@ export class Document {
222
342
  this._fireSubscribers({ kind: 'branches' });
223
343
  },
224
344
  });
345
+ // The version of the newest picture the server holds of this document, or
346
+ // `null` for none. Seeded from the join and moved by this instance's own
347
+ // uploads; a picture older than the document's head is what arms the seed
348
+ // trigger, so a placeholder taken before the content arrived is replaced
349
+ // once it does.
350
+ _thumbnailVersion = null;
225
351
  _thumbnails = new ThumbnailCapture({
226
- isCaptureAllowed: () => this._isLive && this._checkout === null,
352
+ // `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
353
+ // thumbnail from a reader it will not take writes from, so a capture
354
+ // there costs a request and a warn to be told so. The load lock is the
355
+ // other half of `readOnly` and must NOT gate this, because a document
356
+ // still hydrating is exactly the one the seed watch is armed for, and
357
+ // the arm happens while that lock is still on.
358
+ isCaptureAllowed: () => this._isLive && this._checkout === null && !this._forcedReadOnly,
227
359
  isAttached: () => this._isLive,
360
+ onCaptured: () => {
361
+ this._thumbnailVersion = this.currentSeq ?? 0;
362
+ },
228
363
  });
229
364
  _checkpoints = new CheckpointScheduler({
230
365
  docId: () => this._docId,
@@ -235,6 +370,7 @@ export class Document {
235
370
  if (this._undo)
236
371
  this._undo.sealStep();
237
372
  },
373
+ whenLocalOpsSaved: () => whenSaved(this),
238
374
  });
239
375
  // Drive push hook. `null` on the offline / test path; `pushToDrive`
240
376
  // then throws with a clear message rather than silently dropping.
@@ -314,6 +450,9 @@ export class Document {
314
450
  // Live = neither closed nor parked. This is the public-surface gate;
315
451
  // internal ingress (remote ops, status emits) deliberately checks
316
452
  // `_closed` alone, because a parked doc keeps applying remote state.
453
+ // Readable from the SDK's own layers (the client's open-document set
454
+ // gates on it) rather than only from inside this class.
455
+ /** @internal */
317
456
  get _isLive() {
318
457
  return !this._closed && !this._suspended;
319
458
  }
@@ -321,17 +460,35 @@ export class Document {
321
460
  if (!this._isLive)
322
461
  throw new Error('Document is closed');
323
462
  }
463
+ // `close()` releases the CRDT state, so a read after it reaches released
464
+ // memory and fails with whatever the engine says about that. Reads are gated
465
+ // on this rather than on `_assertOpen` because a parked Document still holds
466
+ // its state and answers from it.
467
+ _assertNotClosed() {
468
+ if (this._closed)
469
+ throw new Error('Document is closed');
470
+ }
324
471
  // Server-pushed lifecycle signals: `_gone` is fire-once-then-cache
325
472
  // for `doc_gone` eviction; `_workStatus` is latest-retained,
326
473
  // re-emit-per-phase for `doc_status`.
327
474
  _gone = new GoneSignal();
328
475
  _workStatus = new WorkStatusSignal();
476
+ // Inbound work ended without delivering content, so this doc holds
477
+ // nothing and stays locked. See `_emitStatus` for how it is derived.
478
+ _loadFailed = false;
329
479
  // Drive sync state, latest-retained. `null` until the open factory
330
480
  // seeds it (drive-linked docs only) and on docs with no drive link.
331
481
  _driveStatus = new DriveStatusSignal();
482
+ // Where this document could be published when it mirrors nothing yet.
483
+ // Seeded from the open, emptied once it becomes a mirror.
484
+ _drivePublishTarget = new DrivePublishTargetSignal();
332
485
  // Document-scoped presence. `null` until the WS transport is wired
333
486
  // on, and on the offline / test path where there is no transport.
334
487
  presence = null;
488
+ // A Document owns the LoroDoc it wraps, so it is built through the factories
489
+ // below (or `documentFromEngine`, for in-repo tooling that brings its own).
490
+ // Owning construction is what lets this class declare an engine-neutral
491
+ // surface: app code holds a Document, never the doc underneath it.
335
492
  constructor(loroDoc, options) {
336
493
  this._doc = loroDoc;
337
494
  this._rootKey = options?.rootKey ?? 'entities';
@@ -355,12 +512,15 @@ export class Document {
355
512
  this._isFrozen = false;
356
513
  this._checkout = null;
357
514
  }
358
- // `readOnly` on a live (non-version) open is a forced read-only: the
359
- // guest link view. A version open is already frozen, so it does not
360
- // also need the forced flag.
515
+ // `readOnly` on a live (non-version) open is a forced read-only: a
516
+ // reader the server will not accept writes from, whether because their
517
+ // grant is view-only or because the link they came through is. A
518
+ // version open is already frozen, so it does not also need the flag.
361
519
  this._forcedReadOnly = options?.readOnly === true && initialVersion === null;
362
- this._root = loroDoc.getMap(this._rootKey);
363
- this._entities = wrapLoroMapProxy(this._root);
520
+ this._readOnlyReason = options?.readOnlyReason ?? 'link';
521
+ // Materialize the root container, so a document nothing has written to
522
+ // still carries its root in the shape a reader opens it with.
523
+ loroDoc.getMap(this._rootKey);
364
524
  this._undo = new UndoController(this._doc, options?.undo);
365
525
  }
366
526
  // ---- Typed container handles (the primary read/write/subscribe surface) --
@@ -378,6 +538,14 @@ export class Document {
378
538
  // rebinds and each suspend / close tears down every root handle in a single
379
539
  // traversal.
380
540
  _rootHandles = new Map();
541
+ // Names the pages this Document's writes may occupy. A page is one value,
542
+ // so two writers appending to the same page container would resolve
543
+ // last-writer-wins and lose one writer's records; the token is what keeps
544
+ // their key spaces apart. Loro's peer id is already unique per open, which
545
+ // is exactly the scope wanted.
546
+ get _pagingSessionToken() {
547
+ return this._doc.peerIdStr;
548
+ }
381
549
  // Get a handle for a top-level LoroMap at `key`. The container is
382
550
  // materialized in the underlying LoroDoc immediately (Loro's
383
551
  // `getMap` is "get or create"); mutations through the handle do
@@ -397,29 +565,30 @@ export class Document {
397
565
  // `instances` map present its page containers transparently as a
398
566
  // flat record map. It resolves the ACTIVE source at call time (not
399
567
  // the one captured at handle creation): a checkout rebinds the
400
- // handle's anchor to the preview doc, and the page count must come
401
- // from that same doc or keyed reads hash into the wrong page (and a
402
- // closure pinned to a preview doc would read a freed LoroDoc after
403
- // returnToHead).
568
+ // handle's anchor to the preview doc, and the page state must come
569
+ // from that same doc or a read takes the wrong path for the state it is
570
+ // looking at (and a closure pinned to a preview doc would read a freed
571
+ // LoroDoc after returnToHead).
404
572
  //
405
- // Only the record store's paged root (`instances`) may report a page
406
- // count. `readStorePageCount` reads the document-global
407
- // `meta.page_count`, so handing it to every root map would make the
408
- // sibling record-store roots (`meta`, `header`) and any app-owned
409
- // root map read as paged once `instances` pages, routing their
410
- // `get`/`has`/`mutate` through the page-hash path against a map that
411
- // holds no page containers (`get` returns undefined for present keys;
412
- // `mutate` writes a stray page container into a plain map). A non-
413
- // record-store key gets a null provider and stays unpaged.
414
- const handle = createRootMapHandle(loroMap, this._handleOwner, key === RECORDS_KEY
415
- ? () => readStorePageCount(this._checkout !== null ? this._checkout.previewDoc : this._doc)
416
- : undefined, key === RECORDS_KEY
417
- ? () => readStorePagingScheme(this._checkout !== null ? this._checkout.previewDoc : this._doc)
418
- : undefined);
573
+ // EVERY root map gets the providers, because a `meta` key of its own is
574
+ // what names a paged root: a root with no such key reads as unpaged, which
575
+ // is the answer `meta` and `header` want anyway. Naming one root here
576
+ // instead would make paging a property of a reserved key rather than of a
577
+ // collection, so a map an app owns could never be paged however large it
578
+ // grew.
579
+ const activeSource = () => this._checkout !== null ? this._checkout.previewDoc : this._doc;
580
+ const handle = createRootMapHandle(loroMap, this._handleOwner, {
581
+ isPaged: () => readRootPaged(activeSource(), key),
582
+ page: () => {
583
+ pageRoot(this._doc, key, this._handleOwner.commitMutate);
584
+ },
585
+ sessionToken: () => this._pagingSessionToken,
586
+ stateVersion: () => activeSource().frontiers(),
587
+ });
419
588
  this._rootHandles.set(cacheKey, { kind: 'map', key, handle });
420
589
  return handle;
421
590
  }
422
- // Get a handle for a top-level LoroList at `key`.
591
+ // Get a handle for a top-level LoroMovableList at `key`.
423
592
  list(key) {
424
593
  this._assertOpen();
425
594
  const cacheKey = `list:${key}`;
@@ -427,7 +596,7 @@ export class Document {
427
596
  if (existing)
428
597
  return existing.handle;
429
598
  const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
430
- const loroList = source.getList(key);
599
+ const loroList = source.getMovableList(key);
431
600
  const handle = createRootListHandle(loroList, this._handleOwner);
432
601
  this._rootHandles.set(cacheKey, { kind: 'list', key, handle });
433
602
  return handle;
@@ -461,14 +630,25 @@ export class Document {
461
630
  if (this._handleOwnerCache)
462
631
  return this._handleOwnerCache;
463
632
  this._handleOwnerCache = {
633
+ assertReadable: () => {
634
+ this._assertNotClosed();
635
+ },
464
636
  assertWritable: () => {
465
637
  this._assertOpen();
466
638
  if (this._checkout !== null) {
467
639
  throw new Error('Document is read-only: it is showing a historical version. ' +
468
640
  'Call `doc.returnToHead()` to edit.');
469
641
  }
642
+ if (this._versionBrowsing) {
643
+ throw new Error('Document is read-only: the reader is browsing its versions. ' +
644
+ 'Close the version bar to edit.');
645
+ }
470
646
  if (this._forcedReadOnly) {
471
- throw new Error('Document is read-only: it was opened via a view-only share link.');
647
+ throw new Error(READ_ONLY_MESSAGES[this._readOnlyReason]);
648
+ }
649
+ if (this._loadFailed) {
650
+ throw new Error('Document is read-only: its content could not be prepared ' +
651
+ '(see `doc.status`). Reopen it to try again.');
472
652
  }
473
653
  if (this._isLoadLocked()) {
474
654
  throw new Error('Document is still loading its content; wait for the load to ' +
@@ -502,11 +682,14 @@ export class Document {
502
682
  get branchId() {
503
683
  return this._branchId;
504
684
  }
505
- // True when the Document is currently showing a historical version
506
- // (a checkout): `mutate` throws and edit affordances should be
507
- // disabled. Reactive: flips on `_checkoutVersion(v)` /
508
- // `returnToHead()`. `subscribe` fires when this changes, so apps
509
- // bind it directly inside the subscribe handler.
685
+ // True when this reader cannot write to the document right now:
686
+ // `mutate` throws and edit affordances should be disabled. It covers a
687
+ // view-only grant, a view-only share link, a historical version showing
688
+ // (a checkout), and a document whose content is still loading or could
689
+ // not be loaded. Reactive in every one of those: `subscribe` fires when
690
+ // it changes, so an app binds it directly inside the subscribe handler
691
+ // and follows a grant the server withdraws mid-session as well as a
692
+ // checkout the reader entered.
510
693
  get readOnly() {
511
694
  // A doc that is still loading its first content (a drive-linked
512
695
  // stub hydrating on first open) is read-only until the bytes
@@ -516,19 +699,48 @@ export class Document {
516
699
  // `reloadAfterHydration`); until then mutation is rejected. This is
517
700
  // also what stops an app's "seed an empty doc" path from clobbering
518
701
  // a doc that only looks empty because it has not loaded yet.
519
- return (this._forcedReadOnly || this._checkout !== null || this._isLoadLocked());
702
+ // Browsing versions locks the document at the live head as well as at an
703
+ // earlier one. Stepping moves between the points history KEEPS, and an
704
+ // edit at the head makes no such point, so an edit made under the open
705
+ // bar puts the reader's own newest work somewhere the bar cannot travel
706
+ // to: Back then steps past it to the newest marker, and the way forward
707
+ // is a different number of presses from the way back. Reading history
708
+ // and writing it are separate modes, and the bar carries the Close that
709
+ // leaves this one.
710
+ return (this._forcedReadOnly ||
711
+ this._checkout !== null ||
712
+ this._versionBrowsing ||
713
+ this._isLoadLocked());
520
714
  }
521
715
  // True when content-replacing work (hydration / extraction) is pending
522
716
  // and editing must be locked so incoming bytes do not clobber local
523
- // ops. A pending PUSH is outbound (it writes the doc out to the drive,
524
- // nothing replaces local content), so the server marks it
717
+ // ops, and true again once that work ends without content
718
+ // (`_loadFailed`): a doc nothing could prepare is empty, and an edit
719
+ // there writes ops into a document the app is about to be told could not
720
+ // be opened. A pending PUSH is outbound (it writes the doc out to the
721
+ // drive, nothing replaces local content), so the server marks it
525
722
  // `blocking: false` and the doc stays editable while the importer
526
723
  // runs. An older server omits `blocking`; absence is treated as
527
724
  // blocking so hydration stays locked.
528
725
  _isLoadLocked() {
726
+ if (this._loadFailed)
727
+ return true;
529
728
  const s = this._workStatus.status;
530
729
  return s?.pending === true && s.blocking !== false;
531
730
  }
731
+ /**
732
+ * Whether inbound work on this document ended without content. The SDK's
733
+ * session reads it to report the open as failed rather than binding an
734
+ * empty document as ready.
735
+ * @internal
736
+ */
737
+ get _contentLoadFailed() {
738
+ return this._loadFailed;
739
+ }
740
+ /** @internal The `doc_gone` reason this Document has seen, or `null`. */
741
+ get _goneReason() {
742
+ return this._gone.reason;
743
+ }
532
744
  /**
533
745
  * The historical version this Document shows, or `null` for a live
534
746
  * (head) open. Reactive alongside `readOnly`. The number is the
@@ -589,6 +801,15 @@ export class Document {
589
801
  static create(options) {
590
802
  return new Document(new LoroDoc(), options);
591
803
  }
804
+ /**
805
+ * Wrap a LoroDoc the caller already holds. `documentFromEngine` is the typed
806
+ * entry point; this seam stays `unknown` so `documents.ts` declares an
807
+ * engine-neutral surface.
808
+ * @internal
809
+ */
810
+ static _fromEngine(engine, options) {
811
+ return new Document(engine, options);
812
+ }
592
813
  // Apply a remote op blob produced by another peer (delivered via the
593
814
  // WS `doc_op` server frame). Idempotent: Loro's import
594
815
  // path dedupes by lamport, so re-applying a self-originated op is
@@ -615,8 +836,8 @@ export class Document {
615
836
  }
616
837
  // Apply a snapshot blob over the top of an existing Document.
617
838
  // Internal: the SDK calls this when the server delivers a fresh
618
- // snapshot mid-session (e.g. after a compaction cycle). Persistence
619
- // is server-authoritative, so there is no app-facing snapshot import.
839
+ // snapshot mid-session. Persistence is server-authoritative: the server
840
+ // decides when a fresh snapshot supersedes the local history.
620
841
  /** @internal */
621
842
  _applySnapshot(snapshot) {
622
843
  // See `applyRemoteOp`: refuse the import on a closed (freed) doc so a late
@@ -624,6 +845,11 @@ export class Document {
624
845
  // doc still imports.
625
846
  if (this._closed)
626
847
  return;
848
+ // Arm the seed BEFORE the import, because this is the moment the content
849
+ // arrives and the Loro subscriber cannot be relied on to report it: it is
850
+ // attached lazily, so a document whose content lands before the session has
851
+ // bound its canvas has nobody subscribed and the import fires nothing.
852
+ this._armSeedIfPictureIsStale();
627
853
  this._doc.import(snapshot);
628
854
  // The undo stack pre-snapshot referenced ops the new snapshot
629
855
  // supersedes. Drop both stacks so undo doesn't try to revert
@@ -733,9 +959,9 @@ export class Document {
733
959
  // intentional escape hatch: `Brass.openDocumentById` wires the hook;
734
960
  // `Document.create()` and direct construction stay
735
961
  // hook-free so the class is usable without taking the WS dependency.
736
- // `E` defaults to `EntityProxy` (the untyped Record<string, unknown>)
737
- // for backwards-compat with existing callers. Apps with a typed
738
- // entity shape pass it as a type argument:
962
+ // `E` defaults to `EntityProxy` (the untyped Record<string, unknown>) so an
963
+ // app that has not described its entity shape still gets a working mutate.
964
+ // Apps with a typed entity shape pass it as a type argument:
739
965
  //
740
966
  // doc.mutate<MyEntity>((d) => {
741
967
  // d.entities[id] = myTypedEntity;
@@ -745,39 +971,16 @@ export class Document {
745
971
  // regardless of `E`; the generic exists purely to widen the type at
746
972
  // the assignment site so closed interfaces don't need an
747
973
  // `as Record<string, unknown>` cast.
974
+ //
975
+ // `d.entities` is the root map's handle under a plain-object surface, so
976
+ // the two reach one collection: a record a paged store holds reads and
977
+ // edits here exactly as it does through `doc.map(rootKey)`, and a write
978
+ // large enough to cross the paging threshold takes the paged shape from
979
+ // either.
748
980
  mutate(fn) {
749
- this._assertOpen();
750
- if (this._checkout !== null) {
751
- throw new Error('Document is read-only: it is showing a historical version. ' +
752
- 'Call `doc.returnToHead()` to edit.');
753
- }
754
- // A view-only share-link (guest) open is a live head held read-only:
755
- // remote ops still stream in, but a local edit must be refused. The
756
- // `_checkout` guard above only covers the version-pinned case, so this
757
- // is a separate branch.
758
- if (this._forcedReadOnly) {
759
- throw new Error('Document is read-only: it was opened via a view-only share link.');
760
- }
761
- if (this._isLoadLocked()) {
762
- throw new Error('Document is still loading its content; wait for the load to ' +
763
- 'finish before editing (see `doc.onStatus`).');
764
- }
765
- this._ensureLocalUpdatesSubscribed();
766
- // A fresh proxy for the call, because the batcher rides it: the
767
- // long-lived `this._entities` must stay batcher-free for the reads
768
- // that go through it outside a mutation.
769
- const batcher = new MutateBatcher(() => {
770
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
771
- this._doc.commit();
981
+ this.map(this._rootKey).mutate((entities) => {
982
+ fn({ entities });
772
983
  });
773
- fn({
774
- entities: wrapLoroMapProxy(this._root, batcher),
775
- });
776
- // Stamp the commit so `subscribe`'s default path can identify
777
- // and suppress the local-mutate echo. `setNextCommitOrigin`
778
- // applies to the very next commit, and `commit()` consumes it.
779
- this._doc.setNextCommitOrigin(MUTATE_COMMIT_ORIGIN);
780
- this._doc.commit();
781
984
  }
782
985
  // Install / replace the local-op send hook; `null` detaches it.
783
986
  //
@@ -821,6 +1024,12 @@ export class Document {
821
1024
  _isCheckedOut() {
822
1025
  return this._checkout !== null;
823
1026
  }
1027
+ /** @internal Whether this Document is pinned to the version it was opened
1028
+ * at, with no live channel behind it. Stepping and returning to the head
1029
+ * both throw on that shape, so the SDK's version bar offers neither. */
1030
+ get _isVersionPinned() {
1031
+ return this._isFrozen;
1032
+ }
824
1033
  // Switch the Document to a historical version, in place. @internal
825
1034
  // (the hosted `history` command drives this). After the promise
826
1035
  // resolves, `doc.readOnly` is `true`,
@@ -870,6 +1079,11 @@ export class Document {
870
1079
  previewDoc.import(blob);
871
1080
  const previewRoot = previewDoc.getMap(this._rootKey);
872
1081
  const old = this._checkout;
1082
+ // Snapshot the handles' keys before the active-source pointer moves (see
1083
+ // `_snapshotRootHandleKeys`). Nothing between here and `_rebindRootHandles`
1084
+ // may return early, or the snapshot would outlive its swap and be diffed
1085
+ // against an unrelated later one.
1086
+ this._snapshotRootHandleKeys();
873
1087
  this._checkout = {
874
1088
  previewDoc,
875
1089
  previewRoot,
@@ -896,29 +1110,38 @@ export class Document {
896
1110
  // ignore; already freed
897
1111
  }
898
1112
  }
899
- this._fireSubscribers({ kind: 'checkout', version: effectiveVersion });
1113
+ this._fireSubscribers({ kind: 'history', version: effectiveVersion });
900
1114
  this._maybeFireUndoState();
901
1115
  // Preview every opened stream at the same point in time, so a
902
1116
  // partitioned document's history shows a consistent historical state
903
1117
  // across streams rather than a historical default over live streams.
904
- await this._checkoutOpenStreams();
1118
+ await this._checkoutOpenStreams(generation);
905
1119
  }
906
1120
  // Re-materialize each opened non-default stream at its seq recorded by the
907
1121
  // active preview's checkpoint. A stream with no recorded seq at this
908
1122
  // version (opened after the checkpoint, or before any checkpoint recorded
909
- // one) returns to its own head, so it shows its earliest-known rather than
910
- // a mismatched live state. Best-effort per stream: a stream whose inner
1123
+ // one) has no historical state it can show, so it is held read-only for the
1124
+ // duration rather than left live and writable under a historical default. Best-effort per stream: a stream whose inner
911
1125
  // has no checkout fetcher (a guest line, which has no token to fetch
912
1126
  // at-version) stays live. Failures are swallowed so one stream cannot
913
1127
  // break the doc-level preview.
914
- async _checkoutOpenStreams() {
1128
+ async _checkoutOpenStreams(generation) {
915
1129
  const seqs = this._checkoutStreamSeqs ?? {};
916
1130
  for (const [name, openPromise] of this._streamOpens) {
917
1131
  try {
918
1132
  const stream = await openPromise;
1133
+ // Every stream costs an await, so the reader can have stepped
1134
+ // somewhere else, or back to the live head, since the previous one.
1135
+ // The document's own swap is guarded the same way, and this loop
1136
+ // running on past it is what leaves a partitioned document showing
1137
+ // two moments at once: the document at the version the reader asked
1138
+ // for, and whichever streams a superseded run still reached at its
1139
+ // own.
1140
+ if (generation !== this._checkoutGeneration)
1141
+ return;
919
1142
  const lineId = this._streamManifest?.get(name)?.line_id;
920
1143
  const seq = lineId !== undefined ? seqs[lineId] : undefined;
921
- await stream._previewAt(seq);
1144
+ await stream._previewAt(seq, true);
922
1145
  }
923
1146
  catch {
924
1147
  // A stream that failed to open or preview is left as-is.
@@ -946,10 +1169,13 @@ export class Document {
946
1169
  // Bump first, unconditionally: a checkout fetch may be in flight with
947
1170
  // `_checkout` still null, and this is the signal that supersedes it when
948
1171
  // it resolves.
949
- this._checkoutGeneration += 1;
1172
+ const generation = (this._checkoutGeneration += 1);
950
1173
  if (this._checkout === null)
951
1174
  return;
952
1175
  const old = this._checkout;
1176
+ // Symmetric with `_checkoutVersion`: snapshot before the active-source
1177
+ // pointer moves, and return early nowhere between here and the rebind.
1178
+ this._snapshotRootHandleKeys();
953
1179
  this._checkout = null;
954
1180
  // Re-point the app's handles back at the live doc and redraw them,
955
1181
  // symmetrically with the checkout path, so the model returns to the live
@@ -974,12 +1200,33 @@ export class Document {
974
1200
  this._checkoutStreamSeqs = null;
975
1201
  for (const openPromise of this._streamOpens.values()) {
976
1202
  void openPromise
977
- .then((stream) => stream._previewAt(undefined))
1203
+ .then((stream) => {
1204
+ // Each stream is reached a turn or more later, so a checkout the
1205
+ // reader started in the meantime owns the document by now and this
1206
+ // return would drag its streams back to the live head under it.
1207
+ if (generation !== this._checkoutGeneration)
1208
+ return;
1209
+ return stream._previewAt(undefined);
1210
+ })
978
1211
  .catch(() => {
979
1212
  // A stream that failed to open has nothing to return.
980
1213
  });
981
1214
  }
982
1215
  }
1216
+ // Capture every root map handle's key set for the resync's removed-key diff,
1217
+ // while the handles still describe the outgoing source. This is a phase of
1218
+ // its own, ahead of `_rebindRootHandles`, because a handle resolves its
1219
+ // container from its anchor but its paging state from this Document's active
1220
+ // source: both must still name the outgoing source when the keys are read,
1221
+ // and the caller moves the active-source pointer between the two calls.
1222
+ // Callers must not return early in that window; a snapshot left unconsumed
1223
+ // would be diffed against a later, unrelated swap.
1224
+ _snapshotRootHandleKeys() {
1225
+ for (const entry of this._rootHandles.values()) {
1226
+ if (entry.kind === 'map')
1227
+ snapshotRootMapKeys(entry.handle);
1228
+ }
1229
+ }
983
1230
  // Re-point every cached root handle at the corresponding container in
984
1231
  // `source` (the live doc or a checkout preview) and resync its subscribers.
985
1232
  // The app holds these same handle objects, so this is what makes a version
@@ -997,7 +1244,7 @@ export class Document {
997
1244
  rebindRootMapAnchor(entry.handle, source.getMap(entry.key));
998
1245
  }
999
1246
  else {
1000
- rebindRootListAnchor(entry.handle, source.getList(entry.key));
1247
+ rebindRootListAnchor(entry.handle, source.getMovableList(entry.key));
1001
1248
  }
1002
1249
  }
1003
1250
  for (const entry of this._rootHandles.values()) {
@@ -1078,6 +1325,21 @@ export class Document {
1078
1325
  setThumbnailUploader(uploader) {
1079
1326
  this._thumbnails.setUploader(uploader);
1080
1327
  }
1328
+ // Tell the document which version the server's picture of it depicts, from
1329
+ // the join, and start a seed watch when that picture is behind the content.
1330
+ /** @internal */
1331
+ _setThumbnailVersion(version) {
1332
+ this._thumbnailVersion = version;
1333
+ this._armSeedIfPictureIsStale();
1334
+ }
1335
+ // A picture at or above the head depicts what the document currently holds.
1336
+ _armSeedIfPictureIsStale() {
1337
+ const head = this.currentSeq ?? 0;
1338
+ if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
1339
+ return;
1340
+ }
1341
+ this._thumbnails.armSeed();
1342
+ }
1081
1343
  // Install the checkpoint-create hook; `null` detaches.
1082
1344
  /** @internal */
1083
1345
  setCheckpointCreator(creator) {
@@ -1099,11 +1361,8 @@ export class Document {
1099
1361
  setSourceBlobFetcher(fetcher) {
1100
1362
  this._sourceBlobFetcher = fetcher;
1101
1363
  }
1102
- // Publish this document's current state back to its drive source.
1103
- // Lets an app code one line on save / on a commit button to make its
1104
- // edits visible to people working outside Brass on the same source:
1105
- //
1106
- // await doc.pushToDrive({ message: 'Wall move' });
1364
+ // Publish this document's current state back to its drive source,
1365
+ // driving the hosted toolbar's publish control.
1107
1366
  //
1108
1367
  // The platform publishes the doc's current state, overwriting the
1109
1368
  // source (unknown drive fields preserved), and marks its own write so
@@ -1111,6 +1370,7 @@ export class Document {
1111
1370
  // concurrent drive-side edits. Throws on auth / link / write
1112
1371
  // failures; resolves with the new external ref and the published
1113
1372
  // entity count.
1373
+ /** @internal */
1114
1374
  async pushToDrive(input) {
1115
1375
  this._assertOpen();
1116
1376
  if (this._docId === null) {
@@ -1122,10 +1382,9 @@ export class Document {
1122
1382
  return await this._drivePusher(input ?? {});
1123
1383
  }
1124
1384
  // Resolve a `behind` / `diverged` drive-linked doc by opening the
1125
- // hosted resolution surface, so an app can offer pull (take the source)
1126
- // / keep both alongside the publish-over that `pushToDrive` does:
1127
- //
1128
- // if (doc.driveStatus?.state === 'diverged') await doc.resolveDrive();
1385
+ // hosted resolution surface, which offers pull (take the source) / keep
1386
+ // both alongside the publish-over that `pushToDrive` does. Driven by the
1387
+ // hosted toolbar.
1129
1388
  //
1130
1389
  // Unlike `pushToDrive`, the resolution can mint a NEW mirror doc that
1131
1390
  // takes over the drive link (take-source trashes this doc, keep-both
@@ -1135,6 +1394,7 @@ export class Document {
1135
1394
  // returned `newDocId` lets a multi-doc app do its own switch. Resolves
1136
1395
  // with the outcome, or `null` when the user dismissed the dialog.
1137
1396
  // Throws when there is no doc identity or no resolver is attached.
1397
+ /** @internal */
1138
1398
  async resolveDrive() {
1139
1399
  this._assertOpen();
1140
1400
  if (this._docId === null) {
@@ -1145,11 +1405,11 @@ export class Document {
1145
1405
  }
1146
1406
  return await this._driveResolver();
1147
1407
  }
1148
- // Fetch the original file behind an opaque-blob document: a short-lived
1149
- // presigned GET plus the file's name / content type / size. An app that
1150
- // knows the format fetches the bytes from `url`, parses them, and writes
1151
- // the structured content into this document through the normal handle
1152
- // surface (`doc.map('instances').mutate(...)`):
1408
+ // Fetch the file a document was uploaded as: a short-lived presigned GET
1409
+ // plus the file's name / content type / size. An app that knows the format
1410
+ // fetches the bytes from `url`, parses them, and writes the structured
1411
+ // content into this document through the normal handle surface
1412
+ // (`doc.map('instances').mutate(...)`):
1153
1413
  //
1154
1414
  // const src = await doc.sourceBlob();
1155
1415
  // const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
@@ -1161,9 +1421,8 @@ export class Document {
1161
1421
  // each with its own URL, so a multi-stream format (a glTF with its
1162
1422
  // `.bin` buffer) fetches only the streams it needs.
1163
1423
  //
1164
- // Rejects with a `BrassError` when the document is not a stored opaque
1165
- // blob (it has no original file to hand back) or the caller cannot
1166
- // reach it (both 404).
1424
+ // Rejects with a `BrassError` when the document holds no such file, or
1425
+ // the caller cannot reach it (both 404).
1167
1426
  async sourceBlob() {
1168
1427
  this._assertOpen();
1169
1428
  if (this._docId === null) {
@@ -1185,6 +1444,9 @@ export class Document {
1185
1444
  _streamRuntime = null;
1186
1445
  _streamManifest = null;
1187
1446
  _streamOpens = new Map();
1447
+ // The subset of `_streamOpens` that has resolved, so a park reaches them
1448
+ // without waiting on a microtask. Kept in step with the map above.
1449
+ _openStreams = new Map();
1188
1450
  _ownStream = null;
1189
1451
  // Which stream this Document's own handles read. An open names a stream
1190
1452
  // and the usual one has a default, so `default` is a name here rather
@@ -1261,14 +1523,30 @@ export class Document {
1261
1523
  // the same point in time rather than showing it live under a
1262
1524
  // historical default.
1263
1525
  if (this._checkout !== null) {
1264
- await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id]);
1526
+ await stream._previewAt(this._checkoutStreamSeqs?.[info.line_id], true);
1265
1527
  }
1266
1528
  return stream;
1267
1529
  })();
1268
1530
  this._streamOpens.set(name, open);
1269
- // A failed open (network, deleted stream) must not poison the cache:
1270
- // drop it so the next call retries.
1271
- open.catch(() => {
1531
+ // Hold the RESOLVED stream too. A park has to reach the streams
1532
+ // synchronously (an app's `close()` returns before any microtask runs, and
1533
+ // a remote op landing in between would still call the app back), and a
1534
+ // promise cannot be read that way. An open still in flight when the park
1535
+ // happens resolves into a parked document, so it is parked on arrival.
1536
+ void open.then((stream) => {
1537
+ if (this._streamOpens.get(name) !== open)
1538
+ return;
1539
+ this._openStreams.set(name, stream);
1540
+ // `savingCount` counts this stream from here on, so an indicator bound
1541
+ // to the document has to hear the stream's writes land.
1542
+ stream._inner._observeSavingCount(() => {
1543
+ this._fireSubscribers({ kind: 'saving-count' });
1544
+ });
1545
+ if (this._suspended)
1546
+ stream._suspend();
1547
+ }, () => {
1548
+ // A failed open (network, deleted stream) must not poison the cache:
1549
+ // drop it so the next call retries.
1272
1550
  if (this._streamOpens.get(name) === open)
1273
1551
  this._streamOpens.delete(name);
1274
1552
  });
@@ -1336,14 +1614,45 @@ export class Document {
1336
1614
  // Close every opened stream's own transport + state. Streams opened
1337
1615
  // while this Document was live do not outlive it.
1338
1616
  _closeStreams() {
1617
+ // A stream that has already opened closes IN THIS CALL, for the reason the
1618
+ // park reaches them through this map: `close()` returns to the app before
1619
+ // any microtask runs, so a stream closed on a `.then` spends the rest of
1620
+ // that turn taking writes and calling the app back while `doc.mutate` on
1621
+ // the same document already refuses. A write in that window reaches a
1622
+ // transport about to be torn down and is never persisted.
1623
+ for (const stream of this._openStreams.values()) {
1624
+ stream._inner._observeSavingCount(null);
1625
+ stream._close();
1626
+ }
1627
+ // An open still in flight has no Document to close yet, so it closes on
1628
+ // arrival. A stream reached both ways is closed twice, which is a no-op.
1339
1629
  for (const open of this._streamOpens.values()) {
1340
1630
  void open.then((s) => s._close()).catch(() => {
1341
1631
  // A failed open has nothing to close.
1342
1632
  });
1343
1633
  }
1344
1634
  this._streamOpens.clear();
1635
+ this._openStreams.clear();
1345
1636
  this._streamManifest = null;
1346
1637
  }
1638
+ // Park / unpark the streams opened off this document, alongside the
1639
+ // document's own park.
1640
+ //
1641
+ // A non-default stream is its own Document, which nothing else suspends: it
1642
+ // keeps its transport, so an app holding `await doc.stream(name)` across
1643
+ // `doc.close()` would go on receiving remote-op callbacks and could still
1644
+ // write through the stream, while `doc.mutate` correctly throws. That is the
1645
+ // cross-session callback a park exists to stop, plus a write path around the
1646
+ // closed check. The default stream's inner IS this document, which owns its
1647
+ // own lifecycle, so `_ownsInner` gates this exactly as it gates the close.
1648
+ _setStreamsSuspended(suspended) {
1649
+ for (const stream of this._openStreams.values()) {
1650
+ if (suspended)
1651
+ stream._suspend();
1652
+ else
1653
+ stream._resume();
1654
+ }
1655
+ }
1347
1656
  // Seal a deliberate checkpoint on this document's version-
1348
1657
  // history timeline. Compaction snapshots continue to appear as
1349
1658
  // no-action fallback markers, so an app that never calls this still
@@ -1411,6 +1720,13 @@ export class Document {
1411
1720
  else
1412
1721
  console.error('[Brass]', err);
1413
1722
  }
1723
+ /** @internal Report a failure from the SDK's own chrome (the version
1724
+ * bar's intents) through the same sink the built-in commands use, so a
1725
+ * refused checkout reaches the app's `onError` rather than being dropped
1726
+ * on the way back to a surface that only shows the reader a state. */
1727
+ _reportChromeError(err) {
1728
+ this._reportError(err);
1729
+ }
1414
1730
  // Fire all `subscribe` listeners with a synthetic event. Used for
1415
1731
  // metadata-only changes (checkout, return-to-head, branch switch,
1416
1732
  // branches list update, saving count change, commands change)
@@ -1419,20 +1735,118 @@ export class Document {
1419
1735
  // call `toJSON()` in the handler; the discriminant is there for
1420
1736
  // the rare consumer that wants to branch on the reason.
1421
1737
  _fireSubscribers(event) {
1738
+ // Every change to the outstanding-op map reports itself here, which
1739
+ // makes this the one place the unload guard has to hear about un-acked
1740
+ // work (`close()` releases the doc outright).
1741
+ if (event.kind === 'saving-count') {
1742
+ // Own ops rather than `savingCount`: a stream's inner Document registers
1743
+ // itself, so the registry's total already carries one entry per stream.
1744
+ trackUnsavedWork(this, this._outstandingByClientId.size);
1745
+ this._savingCountObserver?.();
1746
+ }
1422
1747
  this._ensureLoroSubscribed();
1423
1748
  this._subs.emit(event);
1424
1749
  }
1425
- // Saving indicator: number of locally-issued ops not yet acked by
1426
- // the server. Reactive: `subscribe` fires when this count changes
1427
- // (an op is sent or an `op_ack` lands). Apps bind directly inside
1428
- // the subscribe handler:
1750
+ // Told when this Document's outstanding-op count moves, so a parent
1751
+ // Document's `savingCount` covers the streams it has open and its
1752
+ // subscribers fire when a stream's writes land. One observer, because a
1753
+ // stream's inner Document is opened by, and belongs to, exactly one parent.
1754
+ _savingCountObserver = null;
1755
+ /** @internal */
1756
+ _observeSavingCount(observer) {
1757
+ this._savingCountObserver = observer;
1758
+ }
1759
+ // Saving indicator: number of locally-issued ops not yet acked by the
1760
+ // server, across this document's own state and every stream it has open. A
1761
+ // partitioned document keeps its bulk on its streams, so a count that named
1762
+ // the default stream alone reports "All changes saved" over a stream still
1763
+ // writing. Reactive: `subscribe` fires when this count changes (an op is
1764
+ // sent or an `op_ack` lands, on any of them). Apps bind directly inside the
1765
+ // subscribe handler:
1429
1766
  //
1430
1767
  // doc.subscribe(() => {
1431
1768
  // savingEl.textContent =
1432
1769
  // doc.savingCount === 0 ? 'All changes saved'
1433
1770
  // : `Saving ${doc.savingCount} change${doc.savingCount === 1 ? '' : 's'}...`;
1434
1771
  // });
1772
+ //
1773
+ // Only this count aggregates. `readOnly` and `atCapacity` gate `doc.mutate`,
1774
+ // which writes this document's own state, so folding another stream's
1775
+ // refusal into them would close an editor over a stream taking writes;
1776
+ // `stream.readOnly` / `stream.atCapacity` answer for one stream.
1777
+ // Supplied by the doc transport, which holds the sync state this reports.
1778
+ // Absent until a transport is wired (a read-only or detached Document),
1779
+ // where the honest answer is that nothing is outstanding.
1780
+ _diagnosticsProvider = null;
1781
+ /** @internal */
1782
+ _setDiagnosticsProvider(provider) {
1783
+ this._diagnosticsProvider = provider;
1784
+ }
1785
+ /**
1786
+ * How this document's sync is going, as the client sees it: how much is
1787
+ * outstanding, how long it has been, over which transport, across how many
1788
+ * reconnects, and under which correlator, which is what separates a
1789
+ * connection that is answering nothing from one that is merely behind.
1790
+ *
1791
+ * @internal Telemetry for Brass's own surfaces. An app reads `savingCount`
1792
+ * to know a save is outstanding, and the SDK resolves a stuck one itself.
1793
+ */
1794
+ diagnostics() {
1795
+ const own = this._diagnosticsProvider?.() ?? {
1796
+ unackedOps: 0,
1797
+ oldestUnackedMs: 0,
1798
+ oldestClientOpId: null,
1799
+ reconnects: 0,
1800
+ lastAckAgeMs: null,
1801
+ inFlightBytes: 0,
1802
+ httpOpsInFlight: 0,
1803
+ healRounds: 0,
1804
+ hidden: false,
1805
+ };
1806
+ // Cover the streams this document opened, the way `savingCount` does: a
1807
+ // reader watching a save waits on the document, and which line an op was
1808
+ // written to is not something they can act on. The work counters add
1809
+ // up and the waiting ones take the longest wait, so the answer is about
1810
+ // the oldest edit anywhere in the document.
1811
+ //
1812
+ // The connection counters are the parent's alone rather than a fold,
1813
+ // because every line rides one socket, so reconnects and page
1814
+ // visibility are one fact about the client and not one per line.
1815
+ for (const stream of this._openStreams.values()) {
1816
+ const line = stream._inner.diagnostics();
1817
+ own.unackedOps += line.unackedOps;
1818
+ own.inFlightBytes += line.inFlightBytes;
1819
+ own.httpOpsInFlight += line.httpOpsInFlight;
1820
+ own.healRounds = Math.max(own.healRounds, line.healRounds);
1821
+ // Take the stream's op when it waited longer, and also when this
1822
+ // document has none of its own: a strict comparison against a zero
1823
+ // wait leaves the fold reporting outstanding work while naming no op
1824
+ // to go looking for, which is the field the rest hangs off.
1825
+ if (line.oldestClientOpId !== null &&
1826
+ (own.oldestClientOpId === null ||
1827
+ line.oldestUnackedMs > own.oldestUnackedMs)) {
1828
+ own.oldestUnackedMs = line.oldestUnackedMs;
1829
+ own.oldestClientOpId = line.oldestClientOpId;
1830
+ }
1831
+ }
1832
+ return own;
1833
+ }
1435
1834
  get savingCount() {
1835
+ let total = this._ownSavingCount;
1836
+ // `_openStreams` holds the streams that opened their own line. The stream
1837
+ // this Document IS wraps `this`, and is not in it, so nothing is counted
1838
+ // twice.
1839
+ for (const stream of this._openStreams.values()) {
1840
+ total += stream._inner.savingCount;
1841
+ }
1842
+ return total;
1843
+ }
1844
+ // The ops issued against THIS line, which is what a stream reports for
1845
+ // itself: `doc.stream('default')` wraps this Document, so a default stream
1846
+ // reading the aggregate would report a sibling stream's writes as its own
1847
+ // and stand at "saving" while its own line has nothing outstanding.
1848
+ /** @internal */
1849
+ get _ownSavingCount() {
1436
1850
  return this._outstandingByClientId.size;
1437
1851
  }
1438
1852
  // Internal: forget an outstanding op once the server has acked it.
@@ -1449,8 +1863,52 @@ export class Document {
1449
1863
  this._resyncPushClientOpIds.delete(clientOpId);
1450
1864
  if (wasTracked)
1451
1865
  this._fireSubscribers({ kind: 'saving-count' });
1866
+ // An ack is proof the branch is taking writes again, which is the only
1867
+ // thing that can clear the capacity latch. A full branch refuses every
1868
+ // op, so no ack can arrive while the refusal stands; that is what makes
1869
+ // this evidence rather than a guess, and what keeps it from clearing a
1870
+ // state the next write would re-enter.
1871
+ if (this._atCapacity) {
1872
+ this._atCapacity = false;
1873
+ this._fireSubscribers({ kind: 'capacity' });
1874
+ }
1452
1875
  return wasTracked;
1453
1876
  }
1877
+ // Internal: turn this Document read-only because the server refused a
1878
+ // write on the reader's authority rather than on the op. It lands on
1879
+ // `readOnly` because that is the gate an app already renders, so a grant
1880
+ // withdrawn mid-session closes the editor with no new signal to learn.
1881
+ // One-way: the socket resolves write authority when it subscribes, so a
1882
+ // grant restored afterwards is refused by the server too until the
1883
+ // connection recycles, and a reopen is what picks the wider grant up.
1884
+ /** @internal */
1885
+ _markReadOnly(reason) {
1886
+ if (this._forcedReadOnly)
1887
+ return;
1888
+ this._forcedReadOnly = true;
1889
+ this._readOnlyReason = reason;
1890
+ this._fireSubscribers({ kind: 'commands' });
1891
+ this._maybeFireUndoState();
1892
+ }
1893
+ /**
1894
+ * Return this copy to what the platform holds, discarding anything it
1895
+ * refused. A refused change stays drawn on the copy that made it and
1896
+ * nowhere else, and no local operation removes it (a CRDT merges rather
1897
+ * than replaces, and an undo inverts an operation instead of discarding
1898
+ * it), so the only state holding exactly what the platform has is one
1899
+ * built from it.
1900
+ *
1901
+ * The session closes this document and opens the same id again, so a
1902
+ * `session.subscribe` handler rebinds the way it does on any document
1903
+ * switch.
1904
+ *
1905
+ * @internal The SDK drives this itself on the one refusal that reaches it,
1906
+ * telling the reader first, because an app that renders nothing for it is
1907
+ * exactly the case that must not leave someone on a diverged copy.
1908
+ */
1909
+ reload() {
1910
+ this._markGone('moved');
1911
+ }
1454
1912
  // Internal: forget an outstanding op the server TERMINALLY rejected (an
1455
1913
  // `op_rejected` frame: the op can never be accepted, e.g. it exceeded the
1456
1914
  // server's op-blob cap), as opposed to `resolveOutstandingOp`'s success. The
@@ -1459,15 +1917,71 @@ export class Document {
1459
1917
  // app can react (the optimistic local commit stays applied but never
1460
1918
  // persists, so the app's cue is to reload). Returns true if it was tracked.
1461
1919
  /** @internal */
1462
- _failOutstandingOp(clientOpId, message) {
1920
+ _failOutstandingOp(clientOpId, message,
1921
+ // Whether the app hears about this one. A refusal that describes the
1922
+ // DOCUMENT answers every op in flight at once, so reporting per op tells
1923
+ // the reader the same thing as many times as they happened to have
1924
+ // unacked writes, which on a bulk write is dozens. The caller reports it
1925
+ // once instead; the op still leaves the tracker either way, because
1926
+ // `savingCount` has to fall whether or not anyone is told.
1927
+ opts = {}) {
1463
1928
  const wasTracked = this._outstandingByClientId.delete(clientOpId);
1464
1929
  this._resyncPushClientOpIds.delete(clientOpId);
1465
1930
  if (wasTracked) {
1466
1931
  this._fireSubscribers({ kind: 'saving-count' });
1467
- this._reportError(new Error(message));
1932
+ if (opts.report !== false)
1933
+ this._reportError(new Error(message));
1468
1934
  }
1469
1935
  return wasTracked;
1470
1936
  }
1937
+ // Internal: whether this op is one the durability handshake pushed, rather
1938
+ // than one the app wrote. The transport reads it to tell a refusal the heal
1939
+ // itself provoked from one that arrived on its own, which is what keeps a
1940
+ // refused heal chunk from re-arming the heal that sent it.
1941
+ /** @internal */
1942
+ _isResyncPushOp(clientOpId) {
1943
+ return this._resyncPushClientOpIds.has(clientOpId);
1944
+ }
1945
+ _atCapacity = false;
1946
+ /**
1947
+ * Whether the document has stopped accepting new changes because it has
1948
+ * reached the largest size the platform can keep.
1949
+ *
1950
+ * Read from the server's refusal of a write rather than predicted locally,
1951
+ * so it turns true at the first edit that does not land, and back to false
1952
+ * on the first edit that does. Both directions are evidence: a full branch
1953
+ * refuses every op, so an ack cannot arrive while the refusal stands.
1954
+ *
1955
+ * Edits keep applying to the local copy while it is true, which is what
1956
+ * lets the app show the reader what they did alongside the fact that it is
1957
+ * not saved; the copy is discarded on reload.
1958
+ *
1959
+ * An app reads this to stop offering work that would only be refused (a
1960
+ * bulk import, a generate pass) and to say so once, instead of surfacing one
1961
+ * error per rejected op.
1962
+ */
1963
+ get atCapacity() {
1964
+ return this._atCapacity;
1965
+ }
1966
+ // Internal: record the capacity verdict from a server refusal. Idempotent.
1967
+ // Cleared again by `resolveOutstandingOp`, so a branch whose compaction
1968
+ // seals it back under the ceiling reaches the open reader through the next
1969
+ // write that lands rather than only through a reload.
1970
+ /**
1971
+ * Returns whether this call is the one that flipped it, which is what lets
1972
+ * the transport report the refusal exactly once. Every op in flight draws
1973
+ * the same refusal, and a bulk write has as many of those as it has unacked
1974
+ * writes, so the flip is the only moment worth telling the app about.
1975
+ *
1976
+ * @internal
1977
+ */
1978
+ _markAtCapacity() {
1979
+ if (this._atCapacity)
1980
+ return false;
1981
+ this._atCapacity = true;
1982
+ this._fireSubscribers({ kind: 'capacity' });
1983
+ return true;
1984
+ }
1471
1985
  _ensureLocalUpdatesSubscribed() {
1472
1986
  if (this._unsubLocal)
1473
1987
  return;
@@ -1487,15 +2001,46 @@ export class Document {
1487
2001
  // actually flushes (the WS client may queue the frame).
1488
2002
  const copy = new Uint8Array(bytes.byteLength);
1489
2003
  copy.set(bytes);
1490
- const clientOpId = hook(copy);
1491
- this._outstandingByClientId.set(clientOpId, copy);
1492
- // The local mutate has already fired `subscribe` (the Loro
1493
- // event), but `savingCount` just incremented; fire one more
1494
- // synthetic event so a subscribe handler bound on `savingCount`
1495
- // sees the change.
1496
- this._fireSubscribers({ kind: 'saving-count' });
2004
+ const chunks = this.splitLocalUpdate(copy);
2005
+ if (chunks === null) {
2006
+ this._shipLocalOp(copy);
2007
+ return;
2008
+ }
2009
+ // In order: each chunk continues the one before it, so a peer applying
2010
+ // them out of sequence holds the tail unapplied until the gap closes.
2011
+ for (const chunk of chunks)
2012
+ this._shipLocalOp(chunk);
1497
2013
  });
1498
2014
  }
2015
+ // Hand one op blob to the transport and count it as unsaved, or keep it
2016
+ // local when the server has evicted this document.
2017
+ //
2018
+ // A gone document still takes edits by design: it stays open so the app can
2019
+ // render its own "no longer available" state, and the person may keep
2020
+ // interacting until they navigate. Those edits have nowhere to land. The
2021
+ // transport dropped its retry machinery on `doc_gone`, and the frame would
2022
+ // ship onto a subscription the server evicted and come back as a `forbidden`
2023
+ // that describes nothing the app did. Counting them is the part that shows:
2024
+ // each one adds to `savingCount` for a save that cannot happen, so the doc
2025
+ // reports "no longer available" and "Saving 3 changes..." together and
2026
+ // re-arms the leave-site prompt `_markGone` just released.
2027
+ //
2028
+ // Returns the minted `client_op_id`, or null when the op stayed local.
2029
+ _shipLocalOp(copy) {
2030
+ const hook = this._localOpHook;
2031
+ if (!hook)
2032
+ return null;
2033
+ if (this._gone.reason !== null)
2034
+ return null;
2035
+ const clientOpId = hook(copy);
2036
+ this._outstandingByClientId.set(clientOpId, copy);
2037
+ // The local mutate has already fired `subscribe` (the Loro
2038
+ // event), but `savingCount` just incremented; fire one more
2039
+ // synthetic event so a subscribe handler bound on `savingCount`
2040
+ // sees the change.
2041
+ this._fireSubscribers({ kind: 'saving-count' });
2042
+ return clientOpId;
2043
+ }
1499
2044
  // Lazy-attach the underlying LoroDoc subscriber. **Do not** wire this
1500
2045
  // in the constructor or before the initial snapshot import: with a
1501
2046
  // subscriber attached, snapshot-import of a 50k doc takes ~8.5 s
@@ -1518,14 +2063,24 @@ export class Document {
1518
2063
  // One channel: every event reaches every subscriber, whatever
1519
2064
  // produced it. The app reconciles by value and reads the cause off
1520
2065
  // the event only when it has a non-render reason to.
1521
- this._subs.emit(event);
2066
+ //
2067
+ // What reaches a subscriber is this SDK's own `{ kind, source }`, not
2068
+ // the engine's event object. An app that receives the engine's shape
2069
+ // can read its internals (container ids, peer ids, the raw diff) and
2070
+ // build on them, which pins the storage engine through a surface that
2071
+ // never promised it. The cause survives as `source`, the same
2072
+ // engine-neutral vocabulary the handle surface reports.
2073
+ this._subs.emit({
2074
+ kind: 'change',
2075
+ source: changeSourceOf(event),
2076
+ });
1522
2077
  const isLocalCommit = event.by === 'local';
1523
2078
  this._maybeFireUndoState();
1524
2079
  // A local commit (mutate / undo / redo) schedules a debounced
1525
2080
  // thumbnail capture, when the app has registered a canvas via
1526
- // `useCanvas`. Remote ops never trigger a capture: a viewer or
1527
- // a passive collaborator must not re-upload a screenshot of
1528
- // someone else's edit.
2081
+ // `useCanvas`. The debounce is for the peer that drew the change, so
2082
+ // it is local-only: a passive collaborator re-uploading a screenshot
2083
+ // of someone else's edit is what a per-op capture would be.
1529
2084
  if (isLocalCommit) {
1530
2085
  this._thumbnails.schedule();
1531
2086
  // Same reasoning for the implicit auto-checkpoint: only the
@@ -1534,6 +2089,18 @@ export class Document {
1534
2089
  // idle period.
1535
2090
  this._checkpoints.scheduleAuto();
1536
2091
  }
2092
+ else {
2093
+ // Content arriving with no local commit behind it is what an import
2094
+ // looks like from here, and it moves the head past whatever picture
2095
+ // the document has. This DOES reach a reader who drew none of it,
2096
+ // which the debounce above deliberately does not: an import runs on
2097
+ // whichever client claimed it, so gating the seed on local authorship
2098
+ // would leave the documents that most need a picture without one.
2099
+ // Every such reader captures the same document at the same version,
2100
+ // and the targeted snapshot each upload enqueues is idempotent on
2101
+ // that version, so the concurrent seeds resolve themselves.
2102
+ this._armSeedIfPictureIsStale();
2103
+ }
1537
2104
  });
1538
2105
  }
1539
2106
  // Subscribe to document events. Returns an unsubscribe handle.
@@ -1541,12 +2108,12 @@ export class Document {
1541
2108
  // construction stays cheap.
1542
2109
  //
1543
2110
  // `cb` fires for EVERY change, whatever produced it: a local `mutate()`,
1544
- // a remote `import`, `undo`, `redo`, `checkout`, and the SDK's synthetic
1545
- // metadata events (saving-count, branches, commands). One channel; the
1546
- // app re-reads / reconciles in the handler and branches on the event only
1547
- // when it needs the cause. CRDT-origin events keep their Loro shape and
1548
- // carry `by` / `origin` so the cause is recoverable; the engine-neutral
1549
- // `origin.source` lives on the handle surface.
2111
+ // a remote op, `undo`, `redo`, a history move, and the SDK's own metadata
2112
+ // events (saving-count, branches, commands). One channel; the app re-reads
2113
+ // / reconciles in the handler and branches on `event.kind` only when it
2114
+ // needs the cause. A content change arrives as
2115
+ // `{ kind: 'change', source }`, carrying the same engine-neutral `source`
2116
+ // the handle surface reports.
1550
2117
  subscribe(cb) {
1551
2118
  this._assertOpen();
1552
2119
  this._ensureLoroSubscribed();
@@ -1632,10 +2199,10 @@ export class Document {
1632
2199
  // The Document is NOT auto-closed; callers may want to render a
1633
2200
  // "the document was deleted" overlay before closing. Disable any
1634
2201
  // pending mutations in the listener and call `close()` when ready.
1635
- // Mutations after `_markGone` still mutate the local CRDT and call
1636
- // the `LocalOpHook`, but the server will reject those op frames
1637
- // (the SUB# row was evicted at fanout time); the SDK consumer
1638
- // sees the rejection via the underlying transport's error channel.
2202
+ // Mutations after `_markGone` still mutate the local CRDT, and stay
2203
+ // local: `_shipLocalOp` keeps them off the transport and out of
2204
+ // `savingCount`, so this one notice is what the app hears about the
2205
+ // eviction rather than a refusal per later edit.
1639
2206
  onGone(cb) {
1640
2207
  // Guard like `subscribe` / `subscribeUndoState`: `_suspend` clears these
1641
2208
  // signals' listeners so a stale handle from a parked session does not
@@ -1676,11 +2243,20 @@ export class Document {
1676
2243
  // false`) is retained so a late `onStatus` subscriber still sees the
1677
2244
  // final state.
1678
2245
  /** @internal */
1679
- _emitStatus(status) {
2246
+ _emitStatus(status, opts) {
1680
2247
  // A late reload/reconcile closure that resolved after `close()` must not
1681
2248
  // re-emit onto a torn-down doc (its undo stack is freed below in `close`).
1682
2249
  if (this._closed)
1683
2250
  return;
2251
+ // Terminal work that still reports itself as locking editing is inbound
2252
+ // work that ended without content: the platform clears the status when
2253
+ // hydration / extraction succeeds, so what it reports terminally is a
2254
+ // failure. Anything else means the doc is either loading or holds what
2255
+ // it has.
2256
+ this._loadFailed =
2257
+ opts?.contentLoaded !== true &&
2258
+ status.pending === false &&
2259
+ status.blocking === true;
1684
2260
  this._workStatus.emit(status);
1685
2261
  // A blocking work-status is one of the inputs to `canUndo` / `canRedo`
1686
2262
  // (via `_isLoadLocked`), so a status that engages or releases the load
@@ -1690,10 +2266,10 @@ export class Document {
1690
2266
  this._maybeFireUndoState();
1691
2267
  }
1692
2268
  // The current drive sync state of this document, or `null` when the
1693
- // doc is not linked to a drive source. The read-side companion to
1694
- // `doc.pushToDrive(...)`: an app reads `canPush` / `state` to decide
1695
- // whether to surface a "Publish" affordance, and renders the
1696
- // server-authored `label` / `detail` as the wording. Seeded on open
2269
+ // doc is not linked to a drive source. An app reads `state` / `canPush`
2270
+ // to tell the reader they have unpublished edits, and renders the
2271
+ // server-authored `label` / `detail` as the wording; publishing itself
2272
+ // is the hosted toolbar's control. Seeded on open
1697
2273
  // (for a drive-linked doc) and kept live as edits, refreshes, and
1698
2274
  // pushes move the state.
1699
2275
  get driveStatus() {
@@ -1720,6 +2296,49 @@ export class Document {
1720
2296
  if (this._closed)
1721
2297
  return;
1722
2298
  this._driveStatus.emit(status);
2299
+ // A document that has become a mirror publishes through its link from
2300
+ // now on, so the create-an-artifact offer goes with the arrival of the
2301
+ // status that replaces it.
2302
+ this._drivePublishTarget.set(null);
2303
+ }
2304
+ // Whether this document's export runs in THIS app, which is what decides
2305
+ // whether a push carries bytes. Not on `driveStatus`: it is a fact about
2306
+ // the caller rather than about the document, and it is all an app is told
2307
+ // about the exporter, never which app it is when it is not this one.
2308
+ _exportsHere = false;
2309
+ /** @internal */
2310
+ get _exportRunsHere() {
2311
+ return this._exportsHere;
2312
+ }
2313
+ /** @internal */
2314
+ _setExportRunsHere(runsHere) {
2315
+ this._exportsHere = runsHere;
2316
+ }
2317
+ // Internal: where this document can be published while it mirrors nothing.
2318
+ /** @internal */
2319
+ get _publishTarget() {
2320
+ return this._drivePublishTarget.target;
2321
+ }
2322
+ /** @internal */
2323
+ _setPublishTarget(target) {
2324
+ if (this._closed)
2325
+ return;
2326
+ this._drivePublishTarget.set(target);
2327
+ }
2328
+ // Internal: how the publish of a document that mirrors nothing is going.
2329
+ /** @internal */
2330
+ get _publishState() {
2331
+ return this._drivePublishTarget.state;
2332
+ }
2333
+ /** @internal */
2334
+ _emitPublishState(state) {
2335
+ if (this._closed)
2336
+ return;
2337
+ this._drivePublishTarget.setState(state);
2338
+ }
2339
+ /** @internal */
2340
+ _onPublishState(cb) {
2341
+ return this._drivePublishTarget.onState(cb);
1723
2342
  }
1724
2343
  // Internal: mark the doc evicted. Idempotent (multiple frames arrive
1725
2344
  // for the same doc when both `doc:<id>` and `presence:doc:<id>`
@@ -1740,11 +2359,27 @@ export class Document {
1740
2359
  }
1741
2360
  // Plain-JS projection of the current state.
1742
2361
  toJSON() {
2362
+ this._assertNotClosed();
1743
2363
  // When in checkout, read from the preview LoroDoc so the app
1744
2364
  // renders the historical state. Returning to head clears
1745
2365
  // `_checkout` and reads go back to the live `_doc`.
1746
2366
  const source = this._checkout !== null ? this._checkout.previewDoc : this._doc;
1747
- return source.toJSON();
2367
+ const json = source.toJSON();
2368
+ // A paged root holds its records inside page entries beside the store's
2369
+ // own edit bookkeeping, so flatten it to the same record lookup
2370
+ // `doc.map(root).read()` hands back: a serializer and a whole-document
2371
+ // renderer read this surface, and a store that crossed the paging
2372
+ // threshold has to reach them as records. Every root is asked, because a
2373
+ // root no handle was ever taken on is paged by the same rule as one that
2374
+ // was; a root the marker does not name comes back as loro produced it.
2375
+ for (const [rootKey, value] of Object.entries(json)) {
2376
+ if (!isRootLookup(value))
2377
+ continue;
2378
+ if (!readRootPaged(source, rootKey))
2379
+ continue;
2380
+ json[rootKey] = flattenPagedLookup(value, true);
2381
+ }
2382
+ return json;
1748
2383
  }
1749
2384
  /**
1750
2385
  * Snapshot bytes; inverse of `openFromSnapshot`, for round-trip tests.
@@ -1761,27 +2396,35 @@ export class Document {
1761
2396
  // in either caller. No-op when the durable frontier already covers the
1762
2397
  // local doc, while a prior resync push is still unacked, or when the
1763
2398
  // missing slice is too large to ship as one op (see the cap below).
2399
+ //
2400
+ // The return value says whether the caller may stop asking. `covered`
2401
+ // means the durable frontier holds everything local does, so the
2402
+ // handshake is finished; every other outcome leaves the doc diverged and
2403
+ // is the caller's cue to run the handshake again once the push settles.
1764
2404
  /** @internal */
1765
2405
  maybeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
1766
2406
  // `computeResyncPush` reads `this._doc.version()` / `export()`; refuse on a
1767
2407
  // closed (freed) doc so a late `doc_ops_dropped` / periodic-reverify
1768
2408
  // closure can't touch freed WASM.
1769
2409
  if (this._closed)
1770
- return;
2410
+ return 'closed';
1771
2411
  // Loop guard: skip while any chunk of a prior push is still unacked,
1772
2412
  // so a flurry of reconnects cannot re-push the same missing slice.
1773
2413
  for (const id of this._resyncPushClientOpIds) {
1774
2414
  if (this._outstandingByClientId.has(id))
1775
- return;
2415
+ return 'in-flight';
1776
2416
  }
1777
2417
  const chunks = this.computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs);
1778
- if (chunks === null)
1779
- return;
2418
+ if (chunks === 'covered')
2419
+ return 'covered';
2420
+ if (chunks === 'undecodable')
2421
+ return 'undecodable';
1780
2422
  for (const chunk of chunks) {
1781
2423
  const id = this.injectLocalUpdate(chunk);
1782
2424
  if (id !== null)
1783
2425
  this._resyncPushClientOpIds.add(id);
1784
2426
  }
2427
+ return 'pushed';
1785
2428
  }
1786
2429
  // Given the server's persisted snapshot version vector and the op
1787
2430
  // blobs the server is also serving on the join tail, decide whether
@@ -1798,10 +2441,13 @@ export class Document {
1798
2441
  // case. `decodeImportBlobMeta` reads each blob's end vector from its
1799
2442
  // header without a full import, so this stays cheap.
1800
2443
  //
1801
- // Returns the chunks to resync-push, or null when the durable state
1802
- // already covers everything local holds (or a malformed vector / tail
1803
- // blob, which degrades the handshake to a no-op and warns rather than
1804
- // throwing out of the frame handler that drives the live transport).
2444
+ // Returns the chunks to resync-push, `'covered'` when the durable state
2445
+ // already holds everything local does, or `'undecodable'` for a malformed
2446
+ // vector / tail blob, which degrades the handshake to a no-op and warns
2447
+ // rather than throwing out of the frame handler that drives the live
2448
+ // transport. `'undecodable'` is distinct from `'covered'` because it says
2449
+ // nothing about whether the doc has diverged, so a caller healing a known
2450
+ // divergence must keep asking.
1805
2451
  //
1806
2452
  // The common case is one chunk: an `update` export from the durable
1807
2453
  // frontier carries only the missing ops plus their dependencies, never
@@ -1813,6 +2459,44 @@ export class Document {
1813
2459
  // large divergence heals instead of being skipped. Import is idempotent
1814
2460
  // and order-independent (Loro buffers a chunk whose dependency is in a
1815
2461
  // sibling chunk until it arrives), so the chunks need no ordering.
2462
+ /**
2463
+ * Whether the durable snapshot holds changes by an author this doc has never
2464
+ * heard from.
2465
+ *
2466
+ * Every author a client can hear from reaches it as ops, so a peer present
2467
+ * in the durable frontier and absent from the local doc after the tail has
2468
+ * applied is one whose changes exist only inside the snapshot. That is the
2469
+ * platform having rewritten how the document stores what it stores
2470
+ * (compaction's patch fold is the one thing that does this), which a client
2471
+ * cannot merge with and cannot undo across: its undo stack inverts the
2472
+ * operations it made, and the values those operations wrote now live
2473
+ * somewhere else.
2474
+ *
2475
+ * The answer drives a re-import, which is the existing recovery for exactly
2476
+ * this shape (`_applySnapshot` drops both stacks, so undo reports itself
2477
+ * unavailable instead of silently reverting nothing).
2478
+ *
2479
+ * @internal
2480
+ */
2481
+ _snapshotHasUnseenAuthor(persistedSnapshotVersionVector) {
2482
+ if (this._closed)
2483
+ return false;
2484
+ let durable;
2485
+ try {
2486
+ durable = VersionVector.decode(persistedSnapshotVersionVector);
2487
+ }
2488
+ catch {
2489
+ // Undecodable says nothing, and the durability handshake already warns
2490
+ // about it on the same frame.
2491
+ return false;
2492
+ }
2493
+ const local = this._doc.version().toJSON();
2494
+ for (const [peer, counter] of durable.toJSON()) {
2495
+ if (counter > 0 && !local.has(peer))
2496
+ return true;
2497
+ }
2498
+ return false;
2499
+ }
1816
2500
  /** @internal */
1817
2501
  computeResyncPush(persistedSnapshotVersionVector, durableTailBlobs) {
1818
2502
  let durable;
@@ -1825,7 +2509,7 @@ export class Document {
1825
2509
  }
1826
2510
  catch (err) {
1827
2511
  console.warn('[Brass] durability handshake skipped: could not decode the persisted version vector or a tail op blob', err);
1828
- return null;
2512
+ return 'undecodable';
1829
2513
  }
1830
2514
  const local = this._doc.version();
1831
2515
  const cmp = local.compare(durable);
@@ -1835,7 +2519,7 @@ export class Document {
1835
2519
  // lacks AND vice versa). Push in the ahead and concurrent cases; the
1836
2520
  // export from `durable` carries only the local-only ops either way.
1837
2521
  if (cmp !== undefined && cmp <= 0)
1838
- return null;
2522
+ return 'covered';
1839
2523
  // Common case: the whole missing slice fits in one frame. Keep this
1840
2524
  // path byte-identical to a plain `update` export so the no-chunking
1841
2525
  // case is unchanged.
@@ -1860,35 +2544,96 @@ export class Document {
1860
2544
  return ranges;
1861
2545
  }
1862
2546
  // Recursively export the given ranges as one `updates-in-range` blob,
1863
- // splitting by op count until each blob is under the per-frame cap. An
1864
- // irreducible single op (one that alone exceeds the cap) is emitted up
1865
- // to the hard ceiling, or skipped with a warning beyond it so the loop
1866
- // never wedges on an un-sendable frame.
1867
- chunkMissingSlice(local, durable) {
1868
- const pack = (ranges) => {
1869
- const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
1870
- if (total === 0)
1871
- return [];
1872
- const blob = this._doc.export({
1873
- mode: 'updates-in-range',
1874
- spans: ranges.map((r) => ({
1875
- id: { peer: r.peer, counter: r.from },
1876
- len: r.to - r.from,
1877
- })),
1878
- });
1879
- if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
2547
+ // splitting by op count until each blob is under the per-chunk cap.
2548
+ //
2549
+ // `dropOversizedSingle` decides what happens to an irreducible op (one that
2550
+ // alone exceeds the cap and the hard ceiling). A resync drops it with a
2551
+ // warning so the loop never wedges on an un-sendable frame while the rest
2552
+ // of the slice heals. A local commit keeps it: it is an edit the app just
2553
+ // made and nothing else will carry it, so it goes to the write path, which
2554
+ // reports the refusal to the app rather than losing it silently.
2555
+ chunkRanges(ranges, dropOversizedSingle) {
2556
+ const total = ranges.reduce((n, r) => n + (r.to - r.from), 0);
2557
+ if (total === 0)
2558
+ return [];
2559
+ const blob = this._doc.export({
2560
+ mode: 'updates-in-range',
2561
+ spans: ranges.map((r) => ({
2562
+ id: { peer: r.peer, counter: r.from },
2563
+ len: r.to - r.from,
2564
+ })),
2565
+ });
2566
+ if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
2567
+ return [blob];
2568
+ if (total === 1) {
2569
+ // A single op that cannot be split further.
2570
+ if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES)
2571
+ return [blob];
2572
+ if (!dropOversizedSingle)
1880
2573
  return [blob];
1881
- if (total === 1) {
1882
- // A single op that cannot be split further.
1883
- if (blob.byteLength <= RESYNC_PUSH_HARD_MAX_BYTES)
1884
- return [blob];
1885
- console.warn(`[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`);
1886
- return [];
2574
+ console.warn(`[Brass] durability resync skipped one op of ${blob.byteLength} bytes, over the ${RESYNC_PUSH_HARD_MAX_BYTES}-byte single-frame ceiling. The rest of the slice still heals; this op heals on a future snapshot-based recovery.`);
2575
+ return [];
2576
+ }
2577
+ const [first, second] = splitRangesByLength(ranges, total);
2578
+ return [
2579
+ ...this.chunkRanges(first, dropOversizedSingle),
2580
+ ...this.chunkRanges(second, dropOversizedSingle),
2581
+ ];
2582
+ }
2583
+ chunkMissingSlice(local, durable) {
2584
+ return this.chunkRanges(this.missingRanges(local, durable), true);
2585
+ }
2586
+ // A local commit re-exported as several op blobs, or null to ship it whole.
2587
+ //
2588
+ // `mutate` bounds what one commit carries by committing as it goes, but a
2589
+ // commit Loro makes for itself takes no such budget: `redo` replays a whole
2590
+ // undo step, so a bulk write that correctly split into many ops comes back
2591
+ // as one op the size of the entire write. Past `MAX_OP_BLOB_CHARS` that op
2592
+ // is refused outright and the redone edit never persists, and below it, it
2593
+ // still lands as a single large row on the document's one storage partition.
2594
+ //
2595
+ // The ops already exist, so the only lever left is which spans they ship in.
2596
+ // The blob names the counter range it covers, and re-exporting a sub-range
2597
+ // is what the durability resync already does, so the chunks are ordinary ops
2598
+ // a peer applies in sequence.
2599
+ //
2600
+ // Splitting is abandoned unless EVERY chunk comes in under the cap. A commit
2601
+ // holding one value nothing can split (a mesh, a large embedded body) would
2602
+ // otherwise ship as a small chunk that lands and an irreducible one that the
2603
+ // write path refuses, which puts a record on the server missing the field
2604
+ // that made it large and leaves the two copies permanently apart. Whole, its
2605
+ // refusal covers the whole edit and the app is told once.
2606
+ splitLocalUpdate(blob) {
2607
+ if (blob.byteLength <= RESYNC_PUSH_MAX_BYTES)
2608
+ return null;
2609
+ let ranges;
2610
+ try {
2611
+ const meta = decodeImportBlobMeta(blob, false);
2612
+ const start = meta.partialStartVersionVector.toJSON();
2613
+ ranges = [];
2614
+ for (const [peer, to] of meta.partialEndVersionVector.toJSON()) {
2615
+ const from = start.get(peer) ?? 0;
2616
+ if (to > from)
2617
+ ranges.push({ peer, from, to });
1887
2618
  }
1888
- const [first, second] = splitRangesByLength(ranges, total);
1889
- return [...pack(first), ...pack(second)];
1890
- };
1891
- return pack(this.missingRanges(local, durable));
2619
+ }
2620
+ catch {
2621
+ // An undecodable blob is one this cannot describe in spans. Shipping it
2622
+ // whole is what happens without this path at all.
2623
+ return null;
2624
+ }
2625
+ const total = rangeOpCount(ranges);
2626
+ if (total < 2)
2627
+ return null;
2628
+ // The blob's own size names how many pieces to ask for, so the split does
2629
+ // not rediscover that by halving.
2630
+ const parts = Math.min(total, Math.ceil(blob.byteLength / RESYNC_PUSH_MAX_BYTES));
2631
+ const chunks = splitRangesEvenly(ranges, parts).flatMap((piece) => this.chunkRanges(piece, false));
2632
+ if (chunks.length < 2)
2633
+ return null;
2634
+ if (chunks.some((c) => c.byteLength > RESYNC_PUSH_MAX_BYTES))
2635
+ return null;
2636
+ return chunks;
1892
2637
  }
1893
2638
  // Feed an op blob into the local-op send path as if it were a fresh
1894
2639
  // local commit: ship it through the transport's send hook (which mints
@@ -1901,15 +2646,9 @@ export class Document {
1901
2646
  /** @internal */
1902
2647
  injectLocalUpdate(blob) {
1903
2648
  this._ensureLocalUpdatesSubscribed();
1904
- const hook = this._localOpHook;
1905
- if (!hook)
1906
- return null;
1907
2649
  const copy = new Uint8Array(blob.byteLength);
1908
2650
  copy.set(blob);
1909
- const clientOpId = hook(copy);
1910
- this._outstandingByClientId.set(clientOpId, copy);
1911
- this._fireSubscribers({ kind: 'saving-count' });
1912
- return clientOpId;
2651
+ return this._shipLocalOp(copy);
1913
2652
  }
1914
2653
  // Internal: park the Document in the SDK cache. Clears app-side
1915
2654
  // listeners so a stale handle from this session does not receive
@@ -1924,6 +2663,7 @@ export class Document {
1924
2663
  if (this._closed || this._suspended)
1925
2664
  return;
1926
2665
  this._suspended = true;
2666
+ this._setStreamsSuspended(true);
1927
2667
  // A pending auto-checkpoint would otherwise fire against a parked
1928
2668
  // Document and silently no-op via the scheduler's guard.
1929
2669
  // Cancelling here keeps the timer count clean across long-lived
@@ -1934,6 +2674,7 @@ export class Document {
1934
2674
  this._gone.clearListeners();
1935
2675
  this._workStatus.clearListeners();
1936
2676
  this._driveStatus.clearListeners();
2677
+ this._drivePublishTarget.clearListeners();
1937
2678
  // Presence subscribes its OWN WS topic and keeps it alive across a park
1938
2679
  // (resume does not re-create the controller), so drop its app-side
1939
2680
  // change listeners here the same way as the sets above: a parked doc
@@ -1965,6 +2706,7 @@ export class Document {
1965
2706
  /** @internal */
1966
2707
  _resume() {
1967
2708
  this._suspended = false;
2709
+ this._setStreamsSuspended(false);
1968
2710
  // Pair of the `_suspend` presence gate: reopen the presence controller's
1969
2711
  // outbound path and re-advertise this client's cursor on the resumed open.
1970
2712
  // `_suspend` only closed the gate; without this the parked doc's presence
@@ -1998,12 +2740,13 @@ export class Document {
1998
2740
  this._undo.free();
1999
2741
  this._undo = null;
2000
2742
  }
2001
- this._thumbnails.cancel();
2743
+ this._thumbnails.destroy();
2002
2744
  this._checkpoints.cancelAuto();
2003
2745
  this._localOpHook = null;
2004
2746
  this._currentSeqProvider = null;
2005
2747
  this._outstandingByClientId.clear();
2006
2748
  this._resyncPushClientOpIds.clear();
2749
+ releaseUnsavedWork(this);
2007
2750
  this._subs.clear();
2008
2751
  this._undoStateSubs.clear();
2009
2752
  // Drop the signal listeners too, mirroring `_suspend()`. Their frames
@@ -2013,6 +2756,7 @@ export class Document {
2013
2756
  this._gone.clearListeners();
2014
2757
  this._workStatus.clearListeners();
2015
2758
  this._driveStatus.clearListeners();
2759
+ this._drivePublishTarget.clearListeners();
2016
2760
  // Detach handle subscriptions before freeing the doc (below): a handle's
2017
2761
  // per-container Loro subscription outlives the cache reference, and
2018
2762
  // `_doc.free()` must not run with subscriptions still attached.