@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/handles.js CHANGED
@@ -4,9 +4,8 @@
4
4
  //
5
5
  // Engine-neutral: Loro events are translated to a minimal `Change` shape at
6
6
  // the handle boundary, so handle-only app code does not pin the SDK to Loro.
7
- import { LoroList, LoroMap, isContainer, } from 'loro-crdt';
8
- import { PAGE_TAG, decodePageBody, decodeRecordValue, encodePageBody, flattenPagedLookup, isPagingSchemeEditable, pageKeyFor, peekRecordTag, } from './record-paging.js';
9
- import { BrassError } from './errors.js';
7
+ import { LoroMovableList, LoroMap, isContainer, } from 'loro-crdt';
8
+ import { PAGE_TAG, UNPAGED_MAX_RECORDS, PAGE_KEYS_FIELD, assertUsableRecordKey, classifyStoreKey, decodePageKeys, decodePageRecords, decodeRecordValue, encodePage, flattenPagedLookup, isReservedKey, overlayKeyFor, packWholeValue, parseSessionPageKey, repackRecord, parsePatchKey, patchKeyFor, applyRecordPatches, recordsPerPage, sampleBodyBytes, sessionPageKey, tombstoneKeyFor, unpackRecord, } from './record-paging.js';
10
9
  import { MutateBatcher } from './mutate-batching.js';
11
10
  // ---- Plain-JS-proxy machinery, scoped per-container --------------------
12
11
  function isPlainObject(v) {
@@ -42,9 +41,47 @@ function toPlainDeep(v) {
42
41
  }
43
42
  return v;
44
43
  }
44
+ /**
45
+ * One live map entry as a plain value: a container through `toJSON`, any other
46
+ * value passed through.
47
+ *
48
+ * Every container kind is materialised, not just the two a record is usually
49
+ * written as: what a key holds is the app's choice, so an array under a record
50
+ * key arrives as a `LoroMovableList`, and a raw handle fails the tests a caller makes
51
+ * of it while the bulk read of the same store hands back a plain value.
52
+ *
53
+ * Returns the handle to wasm once the plain value is out. `m.get` mints a new
54
+ * wasm-bindgen handle every call, finalisation lags allocation, and wasm linear
55
+ * memory never shrinks, so a handle left undropped is held until the tab
56
+ * closes. This runs on every keyed read and every subscriber fire, so the
57
+ * arithmetic reaches tens of MB over a working session.
58
+ */
59
+ function plainEntry(m, key) {
60
+ const v = m.get(key);
61
+ if (!isContainer(v))
62
+ return v;
63
+ const container = v;
64
+ try {
65
+ return container.toJSON();
66
+ }
67
+ finally {
68
+ container.free();
69
+ }
70
+ }
71
+ /** Whether a key is set, without keeping the handle the read minted. A
72
+ * presence check discards the value, so the handle is pure waste; it is
73
+ * still a handle, and this runs once per key on an ordered-map write. */
74
+ function entryExists(m, key) {
75
+ const v = m.get(key);
76
+ if (v === undefined)
77
+ return false;
78
+ if (isContainer(v))
79
+ v.free();
80
+ return true;
81
+ }
45
82
  function assignPlain(target, value) {
46
83
  // Recursively materialize nested objects / arrays as nested LoroMap /
47
- // LoroList containers so the CRDT mirrors the JS shape. Clear keys not in
84
+ // LoroMovableList containers so the CRDT mirrors the JS shape. Clear keys not in
48
85
  // `value` so assignment is a replace, not a merge (property-level patching
49
86
  // uses `d.entities[id].x = ...`).
50
87
  for (const k of target.keys()) {
@@ -52,96 +89,209 @@ function assignPlain(target, value) {
52
89
  target.delete(k);
53
90
  }
54
91
  for (const [k, rawV] of Object.entries(value)) {
55
- const v = materializeContainers(rawV);
56
- // Plain-VALUE preservation (as in the proxy set trap): a key holding a
57
- // plain object/array value (a record body) stays a value, never promoted.
58
- const existingV = target.get(k);
59
- if (existingV !== undefined &&
60
- !isContainer(existingV) &&
61
- typeof existingV === 'object' &&
62
- existingV !== null &&
63
- typeof v === 'object' &&
64
- v !== null) {
65
- target.set(k, toPlainDeep(v));
66
- continue;
67
- }
68
- if (isPlainObject(v)) {
69
- // Reuse an existing LoroMap at this key: a fresh `setContainer` mints a
70
- // new container ID, which under Loro's UndoManager is a structural
71
- // destroy+create that does NOT merge within the merge window. In-place
72
- // field overwrites DO merge, which whole-entity-rewrites-at-drag-cadence
73
- // expects.
74
- const existing = target.get(k);
75
- const sub = existing instanceof LoroMap
76
- ? existing
77
- : target.setContainer(k, new LoroMap());
78
- assignPlain(sub, v);
79
- }
80
- else if (Array.isArray(v)) {
81
- const existingList = target.get(k);
82
- const sub = existingList instanceof LoroList
83
- ? existingList
84
- : target.setContainer(k, new LoroList());
85
- assignList(sub, v);
86
- }
87
- else {
88
- target.set(k, v);
89
- }
92
+ assignEntry(target, k, rawV);
93
+ }
94
+ }
95
+ // Write one value at one map key, in the shape that key should hold: a nested
96
+ // object / array becomes a child container mirroring the JS shape, anything
97
+ // else a plain value. The single definition of what assigning to a map entry
98
+ // means, shared by `assignPlain` and the mutate proxy's set trap, so the two
99
+ // cannot disagree about the shape a value lands in.
100
+ function assignEntry(target, key, rawValue) {
101
+ const value = materializeContainers(rawValue);
102
+ const existing = target.get(key);
103
+ // Plain-VALUE preservation: when the key holds a plain (non-container)
104
+ // object or array, the new structure is written as a plain value too, never
105
+ // promoted. This keeps a record body (`rec.params`, one JSON value whose
106
+ // whole-replace is the merge unit) a value across edits; promoting it would
107
+ // change the merge granularity and bloat the snapshot with per-element
108
+ // containers.
109
+ if (existing !== undefined &&
110
+ !isContainer(existing) &&
111
+ typeof existing === 'object' &&
112
+ existing !== null &&
113
+ typeof value === 'object' &&
114
+ value !== null) {
115
+ target.set(key, toPlainDeep(value));
116
+ return;
117
+ }
118
+ if (isPlainObject(value)) {
119
+ // Reuse an existing LoroMap at this key: a fresh `setContainer` mints a
120
+ // new container ID, which under Loro's UndoManager is a structural
121
+ // destroy+create that does NOT merge within the merge window. In-place
122
+ // field overwrites DO merge, which whole-entity-rewrites-at-drag-cadence
123
+ // expects.
124
+ const sub = existing instanceof LoroMap
125
+ ? existing
126
+ : target.setContainer(key, new LoroMap());
127
+ assignPlain(sub, value);
128
+ }
129
+ else if (Array.isArray(value)) {
130
+ const sub = existing instanceof LoroMovableList
131
+ ? existing
132
+ : target.setContainer(key, new LoroMovableList());
133
+ assignList(sub, value);
134
+ }
135
+ else {
136
+ target.set(key, value);
90
137
  }
91
138
  }
92
139
  function assignList(target, value) {
93
- // Same rationale as the LoroMap reuse above: replace contents in place
94
- // rather than minting a new LoroList, so adjacent commits collapse into one
95
- // undo step.
140
+ // Assigning a whole array writes the positions that differ, in place. Two
141
+ // properties come out of that, and the second is why a position is SET
142
+ // rather than deleted and re-inserted.
143
+ //
144
+ // A position already holding the value needs no op at all. Re-assigning an
145
+ // array is how an app states an edit to one of its rows (`rows[1].n = 2;
146
+ // m.rows = rows`), so most positions are unchanged, and an op written for an
147
+ // unchanged one is both a container a peer's concurrent field edit is
148
+ // detached from and one more thing for it to resolve against.
149
+ //
150
+ // A position that DOES differ is set, which is one op naming that position.
151
+ // Deleting and re-inserting states the same intent to a movable list, but
152
+ // the delete and the insert commute with a peer's concurrent write to the
153
+ // same position, so the pair leaves the entry stored twice while `set`
154
+ // leaves it stored once. That is the same reasoning `assignListPosition`
155
+ // carries, and a whole-array assign is the write that reaches it most.
96
156
  const oldLen = target.length;
97
157
  for (let i = 0; i < value.length; i++) {
98
158
  const item = materializeContainers(value[i]);
99
- if (isPlainObject(item)) {
100
- if (i < oldLen)
101
- target.delete(i, 1);
102
- const child = target.insertContainer(i, new LoroMap());
103
- assignPlain(child, item);
104
- }
105
- else if (Array.isArray(item)) {
106
- // A nested list: rebuild as a child LoroList rather than inserting the
107
- // array as an opaque value.
108
- if (i < oldLen)
109
- target.delete(i, 1);
110
- const child = target.insertContainer(i, new LoroList());
111
- assignList(child, item);
112
- }
113
- else {
114
- if (i < oldLen) {
115
- target.delete(i, 1);
116
- target.insert(i, item);
117
- }
118
- else {
119
- target.insert(i, item);
120
- }
159
+ if (i >= oldLen) {
160
+ insertListAt(target, i, item);
161
+ continue;
121
162
  }
163
+ if (samePlainValue(plainListItem(target, i), item))
164
+ continue;
165
+ assignListPosition(target, i, item);
122
166
  }
123
167
  if (value.length < oldLen) {
124
168
  target.delete(value.length, oldLen - value.length);
125
169
  }
126
170
  }
127
- // Append one value at the tail (same plain-vs-container handling as the
128
- // indexed-set trap). The proxy's `push` fast-path uses this so a tail append
129
- // stays O(1) instead of the full-list replay.
130
- function insertListTail(target, rawItem) {
131
- const idx = target.length;
171
+ /** Whether a position already holds this value, so assigning it writes
172
+ * nothing. Compared as plain data, which is the form both sides are already
173
+ * in: the stored side through `toJSON`, the incoming side as the app wrote
174
+ * it. A pair that differs only in key ORDER compares unequal and is
175
+ * rewritten, which costs an op and stores the same thing. */
176
+ function samePlainValue(stored, next) {
177
+ if (stored === next)
178
+ return true;
179
+ if (stored === null ||
180
+ next === null ||
181
+ typeof stored !== 'object' ||
182
+ typeof next !== 'object') {
183
+ return false;
184
+ }
185
+ return JSON.stringify(stored) === JSON.stringify(next);
186
+ }
187
+ // Insert one value at `idx` (same plain-vs-container handling as the indexed-
188
+ // set trap). The proxy's native mutators build on this so an insertion costs
189
+ // one op at one position instead of a full-list replay.
190
+ function insertListAt(target, idx, rawItem) {
132
191
  const item = materializeContainers(rawItem);
133
192
  if (isPlainObject(item)) {
134
193
  const child = target.insertContainer(idx, new LoroMap());
135
194
  assignPlain(child, item);
136
195
  }
137
196
  else if (Array.isArray(item)) {
138
- const child = target.insertContainer(idx, new LoroList());
197
+ const child = target.insertContainer(idx, new LoroMovableList());
139
198
  assignList(child, item);
140
199
  }
141
200
  else {
142
201
  target.insert(idx, item);
143
202
  }
144
203
  }
204
+ function insertListTail(target, rawItem) {
205
+ insertListAt(target, target.length, rawItem);
206
+ }
207
+ // Replace what an EXISTING position holds, in one op at that position.
208
+ //
209
+ // A movable list can set a position, where a plain one has to delete and
210
+ // insert. That matters beyond op count: the delete and the insert commute with
211
+ // a peer's concurrent write to the same position, so the pair leaves the entry
212
+ // stored twice, and `set` leaves it stored once.
213
+ function assignListPosition(target, idx, rawItem) {
214
+ const item = materializeContainers(rawItem);
215
+ if (isPlainObject(item)) {
216
+ const child = target.setContainer(idx, new LoroMap());
217
+ assignPlain(child, item);
218
+ }
219
+ else if (Array.isArray(item)) {
220
+ const child = target.setContainer(idx, new LoroMovableList());
221
+ assignList(child, item);
222
+ }
223
+ else {
224
+ target.set(idx, item);
225
+ }
226
+ }
227
+ /** One element as plain data, for a mutator's return value. */
228
+ function plainListItem(target, idx) {
229
+ const v = target.get(idx);
230
+ if (isContainer(v))
231
+ return v.toJSON();
232
+ return v;
233
+ }
234
+ /**
235
+ * A record's BODY, wrapped so writing a field inside it lands.
236
+ *
237
+ * A body is one plain JSON value, not a container, so reading it hands back
238
+ * an ordinary object. Without this, `m[key].params.height = 3` mutates that
239
+ * object and stops there: nothing is written, and nothing says so, which is
240
+ * the shape of every field edit a developer would reach for first. The write
241
+ * is rebuilt into a copy and pushed back through `commit`, which reassigns
242
+ * the whole body, so the record stays the merge unit exactly as an explicit
243
+ * `m[key].params = {...}` does.
244
+ *
245
+ * Only objects and arrays are wrapped, and only as they are reached, so a
246
+ * mutation that assigns without reading allocates nothing.
247
+ *
248
+ * The traps compose on ONE working copy rather than deriving a fresh copy of
249
+ * the original per write. An array mutator (`push`, `splice`, `sort`,
250
+ * `reverse`) writes several properties in sequence, each reading what the
251
+ * last one left, so a per-write copy keeps only the final assignment and
252
+ * commits an array that was never asked for: `push` on `[1,2,3]` writes index
253
+ * 3 and then `length`, and the `length` write derived from the original
254
+ * commits `[1,2,3,<empty>]`. The working copy is shallow and every write
255
+ * replaces rather than mutates in place, so the value handed in is never
256
+ * touched.
257
+ */
258
+ function writeThroughBody(value, commit) {
259
+ if (value === null || typeof value !== 'object')
260
+ return value;
261
+ const working = Array.isArray(value)
262
+ ? [...value]
263
+ : { ...value };
264
+ const snapshot = () => Array.isArray(working)
265
+ ? [...working]
266
+ : { ...working };
267
+ return new Proxy(working, {
268
+ get(target, prop, receiver) {
269
+ const inner = Reflect.get(target, prop, receiver);
270
+ if (typeof prop === 'symbol')
271
+ return inner;
272
+ if (inner === null || typeof inner !== 'object')
273
+ return inner;
274
+ return writeThroughBody(inner, (next) => {
275
+ working[prop] = next;
276
+ commit(snapshot());
277
+ });
278
+ },
279
+ set(_target, prop, next) {
280
+ if (typeof prop === 'symbol')
281
+ return false;
282
+ working[prop] = next;
283
+ commit(snapshot());
284
+ return true;
285
+ },
286
+ deleteProperty(_target, prop) {
287
+ if (typeof prop === 'symbol')
288
+ return false;
289
+ delete working[prop];
290
+ commit(snapshot());
291
+ return true;
292
+ },
293
+ });
294
+ }
145
295
  // Exported because `Document.mutate` reuses the same proxy for its
146
296
  // `entities` root. Load-bearing: `getOwnPropertyDescriptor` does a single
147
297
  // `loroMap.get(key)` (O(1) per key, so `Object.keys()` is O(N)). Iterating
@@ -152,23 +302,49 @@ export function wrapLoroMapProxy(loroMap,
152
302
  // mutation can hold thousands of them (`m[id].params = ...` in a loop),
153
303
  // which reaches the same size a bulk write does while touching no
154
304
  // top-level key.
155
- batcher) {
305
+ batcher, divert,
306
+ // Whether this map's keys are RECORD keys, and so must stay out of the
307
+ // reserved space the store's own bookkeeping occupies. True for a root map,
308
+ // which is what can page; false inside a record, whose field names are the
309
+ // app's own and constrained by nothing.
310
+ guardKeys = false) {
156
311
  const handler = {
157
312
  get(_t, key) {
158
313
  if (typeof key === 'symbol')
159
314
  return undefined;
315
+ if (divert?.holds(key)) {
316
+ const held = divert.peek(key);
317
+ if (held === null || typeof held !== 'object')
318
+ return held;
319
+ return writeThroughBody(held, (next) => {
320
+ // Charged like the set trap's own assignment: a record filled field
321
+ // by field (`m[id] = {}` then `m[id].params = ...`) reaches the
322
+ // divert through here and nowhere else, so without this it is
323
+ // charged only whatever its first assigned value was and a bulk
324
+ // write built that way rides ops sized by the mutation.
325
+ batcher?.record(next);
326
+ divert.take(key, next);
327
+ });
328
+ }
160
329
  const v = loroMap.get(key);
330
+ if (!isContainer(v) && v !== null && typeof v === 'object') {
331
+ // A plain object / array body: hand back a view that writes a field
332
+ // edit through, rather than a detached copy that swallows it.
333
+ return writeThroughBody(v, (next) => {
334
+ handler.set(_t, key, next, _t);
335
+ });
336
+ }
161
337
  if (isContainer(v)) {
162
338
  // Present an engine-neutral view, never the raw container, so app
163
339
  // code can't take a hard dependency on the CRDT engine. A LoroMap
164
- // recurses into another proxy; a LoroList reads back via `toJSON`
340
+ // recurses into another proxy; a LoroMovableList reads back via `toJSON`
165
341
  // (not `toArray`, which is shallow and hands back live element
166
342
  // handles that corrupt on reassignment; list editing goes through
167
343
  // `parent.list(key).mutate(...)`). Other container types are outside
168
344
  // the v1 portable surface, so project them via toJSON().
169
345
  if (v instanceof LoroMap)
170
346
  return wrapLoroMapProxy(v, batcher);
171
- if (v instanceof LoroList)
347
+ if (v instanceof LoroMovableList)
172
348
  return v.toJSON();
173
349
  return v.toJSON();
174
350
  }
@@ -177,64 +353,54 @@ batcher) {
177
353
  set(_t, key, value) {
178
354
  if (typeof key === 'symbol')
179
355
  return false;
356
+ if (guardKeys)
357
+ assertUsableRecordKey(key);
180
358
  // Before the write, so a commit it triggers seals the preceding
181
359
  // batch rather than splitting this record out of it.
182
360
  batcher?.record(value);
183
- const existing = loroMap.get(key);
184
- // Plain-VALUE preservation: when the key holds a plain (non-container)
185
- // object or array, the new structure is written as a plain value too,
186
- // never promoted. This keeps a record body (`rec.params`, one JSON
187
- // value whose whole-replace is the merge unit) a value across edits;
188
- // promoting it would change the merge granularity and bloat the
189
- // snapshot with per-element containers.
190
- if (existing !== undefined &&
191
- !isContainer(existing) &&
192
- typeof existing === 'object' &&
193
- existing !== null &&
194
- typeof value === 'object' &&
195
- value !== null) {
196
- loroMap.set(key, toPlainDeep(value));
361
+ if (divert?.take(key, value))
197
362
  return true;
198
- }
199
- if (isPlainObject(value)) {
200
- // Reuse the existing LoroMap (see `assignPlain`): a fresh
201
- // `setContainer` is a destroy+create Loro won't merge across, so
202
- // `entities[id] = {...}` at drag cadence accumulates one undo entry
203
- // per frame instead of collapsing into the merge window.
204
- const sub = existing instanceof LoroMap
205
- ? existing
206
- : loroMap.setContainer(key, new LoroMap());
207
- assignPlain(sub, value);
208
- }
209
- else if (Array.isArray(value)) {
210
- const sub = existing instanceof LoroList
211
- ? existing
212
- : loroMap.setContainer(key, new LoroList());
213
- assignList(sub, value);
214
- }
215
- else {
216
- loroMap.set(key, value);
217
- }
363
+ assignEntry(loroMap, key, value);
364
+ // Named to the batcher AFTER the write, never before: `record` above
365
+ // may have committed the batch this call arrived in, and a key named
366
+ // to that commit would be one whose write the commit does not carry.
367
+ batcher?.noteKeys([key]);
218
368
  return true;
219
369
  },
220
370
  deleteProperty(_t, key) {
221
371
  if (typeof key === 'symbol')
222
372
  return false;
223
- batcher?.recordDelete();
373
+ if (guardKeys)
374
+ assertUsableRecordKey(key);
375
+ batcher?.recordDelete(key);
376
+ divert?.drop(key);
224
377
  loroMap.delete(key);
378
+ batcher?.noteKeys([key]);
225
379
  return true;
226
380
  },
227
381
  has(_t, key) {
228
382
  if (typeof key === 'symbol')
229
383
  return false;
384
+ if (divert?.holds(key))
385
+ return true;
230
386
  return loroMap.get(key) !== undefined;
231
387
  },
232
388
  ownKeys() {
233
- return loroMap.keys();
389
+ if (!divert)
390
+ return loroMap.keys();
391
+ return [...new Set([...loroMap.keys(), ...divert.keys()])];
234
392
  },
235
393
  getOwnPropertyDescriptor(_t, key) {
236
394
  if (typeof key === 'symbol')
237
395
  return undefined;
396
+ if (divert?.holds(key)) {
397
+ return {
398
+ enumerable: true,
399
+ configurable: true,
400
+ writable: true,
401
+ value: divert.peek(key),
402
+ };
403
+ }
238
404
  // Single get per key. Do NOT iterate `loroMap.keys()` here: it makes
239
405
  // Object.keys() O(N^2) at 50k entities.
240
406
  const v = loroMap.get(key);
@@ -250,27 +416,95 @@ batcher) {
250
416
  };
251
417
  return new Proxy({}, handler);
252
418
  }
253
- // Array.prototype methods that mutate the array in place. Called on a list
254
- // mutate-proxy, their result is replayed into the LoroList; every other
255
- // method is a pure read that leaves the container untouched, so a `find` /
256
- // `findIndex` inside a mutate reads fields without rewriting the whole list.
419
+ // Array.prototype methods that mutate in place, applied to the container as
420
+ // the targeted edits they are rather than replayed over it.
421
+ //
422
+ // The list stores as a `LoroMovableList`, so a reposition is `move(from, to)`
423
+ // and a replacement is `set(pos, value)`, both of which name the position they
424
+ // touch. That keeps the ENTRY the merge unit: two peers reordering different
425
+ // entries commute, and two reordering the same one converge on a list holding
426
+ // it once, where a delete-plus-insert replay leaves it named twice. Each edit
427
+ // is also its own op, so the commit budget bounds these the way it bounds
428
+ // every other list write.
429
+ //
257
430
  // `push` is fast-pathed natively above the generic fallback and never reaches
258
- // this set.
259
- const MUTATING_ARRAY_METHODS = new Set([
260
- 'pop',
261
- 'shift',
262
- 'unshift',
263
- 'splice',
431
+ // this set; every other array method is a pure read.
432
+ const REORDERING_ARRAY_METHODS = new Set([
264
433
  'sort',
265
434
  'reverse',
435
+ ]);
436
+ const REPLACING_ARRAY_METHODS = new Set([
266
437
  'fill',
267
438
  'copyWithin',
268
439
  ]);
440
+ /**
441
+ * Bring the container's order to `want` using moves, comparing by identity of
442
+ * the values already stored.
443
+ *
444
+ * A permutation is applied front to back: at each position, find where the
445
+ * wanted entry currently sits and move it up. Every entry lands in one move at
446
+ * most, and a position already correct costs nothing, so a `sort` over a
447
+ * nearly-ordered list writes nearly nothing.
448
+ *
449
+ * Duplicate values are matched left to right from the first position not yet
450
+ * settled, which is what keeps a list holding the same value twice from
451
+ * collapsing onto one of them.
452
+ */
453
+ function applyOrder(target, want, batcher) {
454
+ const current = target.toJSON();
455
+ const print = (v) => JSON.stringify(v) ?? 'undefined';
456
+ // Where each value still sits, in order, so a lookup does not rescan.
457
+ const positions = new Map();
458
+ for (let i = 0; i < current.length; i += 1) {
459
+ const k = print(current[i]);
460
+ const at = positions.get(k);
461
+ if (at)
462
+ at.push(i);
463
+ else
464
+ positions.set(k, [i]);
465
+ }
466
+ for (let i = 0; i < want.length && i < current.length; i += 1) {
467
+ const k = print(want[i]);
468
+ const at = positions.get(k);
469
+ if (!at || at.length === 0)
470
+ continue;
471
+ // The leftmost occurrence at or after `i`; anything before it is settled.
472
+ let pick = -1;
473
+ for (let j = 0; j < at.length; j += 1) {
474
+ if (at[j] >= i) {
475
+ pick = j;
476
+ break;
477
+ }
478
+ }
479
+ if (pick === -1)
480
+ continue;
481
+ const from = at[pick];
482
+ at.splice(pick, 1);
483
+ if (from === i)
484
+ continue;
485
+ // A move costs the entry, not the list, so it is charged like one write.
486
+ batcher?.record(want[i]);
487
+ target.move(from, i);
488
+ // Everything between the destination and the source shifted up by one.
489
+ for (const list of positions.values()) {
490
+ for (let j = 0; j < list.length; j += 1) {
491
+ const p = list[j];
492
+ if (p >= i && p < from)
493
+ list[j] = p + 1;
494
+ }
495
+ }
496
+ }
497
+ }
269
498
  // An array-proxy for list mutators: index assignments, `.push` / `.splice`,
270
- // and length-set route into the underlying LoroList so the user writes
499
+ // and length-set route into the underlying LoroMovableList so the user writes
271
500
  // idiomatic JS. Reads delegate to a fresh snapshot per access so a sequence
272
501
  // of reads inside the mutator sees each write.
273
- function wrapLoroListProxy(loroList) {
502
+ function wrapLoroListProxy(loroList,
503
+ // Threaded into nested element proxies too, so a field edit inside an
504
+ // element counts. A list takes the same bulk writes a map does (an app
505
+ // building rows, the record store's own order list), and nothing else bounds
506
+ // what one call puts in a single op.
507
+ batcher) {
274
508
  const handler = {
275
509
  get(_t, key) {
276
510
  if (key === 'length')
@@ -287,13 +521,58 @@ function wrapLoroListProxy(loroList) {
287
521
  if (isContainer(v)) {
288
522
  // Engine-neutral view only (see the map proxy's get trap).
289
523
  if (v instanceof LoroMap)
290
- return wrapLoroMapProxy(v);
291
- if (v instanceof LoroList)
524
+ return wrapLoroMapProxy(v, batcher);
525
+ if (v instanceof LoroMovableList)
292
526
  return v.toJSON();
293
527
  return v.toJSON();
294
528
  }
529
+ if (v !== null && typeof v === 'object') {
530
+ // A plain object / array element, the same case the map proxy
531
+ // wraps: hand back a view that writes a field edit through instead
532
+ // of a detached copy that swallows it.
533
+ //
534
+ // The write-back replaces the element as a plain VALUE rather than
535
+ // going through the `set` trap, which promotes a plain object to a
536
+ // container. Promoting here would change what merges on a field
537
+ // edit, and a field edit is exactly the operation that should not
538
+ // move the merge boundary.
539
+ //
540
+ // `set` rather than a delete plus an insert, for the reason
541
+ // `assignListPosition` carries: the pair commutes with a peer's
542
+ // concurrent write to this position and leaves the element stored
543
+ // twice. `set` takes a plain value, so it keeps the shape this
544
+ // branch exists to preserve.
545
+ return writeThroughBody(v, (next) => {
546
+ batcher?.record(next);
547
+ if (idx < loroList.length)
548
+ loroList.set(idx, next);
549
+ else
550
+ loroList.insert(idx, next);
551
+ });
552
+ }
295
553
  return v;
296
554
  }
555
+ // Reposition an entry, which is the operation a plain array cannot
556
+ // state: spelled as a removal plus an insertion it merges with a peer's
557
+ // reposition of the same entry by keeping both insertions, and the entry
558
+ // ends up in the list twice.
559
+ if (key === 'move') {
560
+ return (rawFrom, rawTo) => {
561
+ const len = loroList.length;
562
+ const from = Math.trunc(Number(rawFrom));
563
+ let to = Math.trunc(Number(rawTo));
564
+ if (!Number.isFinite(from) || from < 0 || from >= len)
565
+ return;
566
+ if (!Number.isFinite(to))
567
+ return;
568
+ to = to < 0 ? Math.max(len + to, 0) : Math.min(to, len - 1);
569
+ if (to === from)
570
+ return;
571
+ // A move writes one entry, so it is charged like one write.
572
+ batcher?.record(plainListItem(loroList, from));
573
+ loroList.move(from, to);
574
+ };
575
+ }
297
576
  // `push` is the hot path (the ordered-map primitive appends every new
298
577
  // key through it; apps bulk-build lists with it), so append natively.
299
578
  // The toArray + replay below is O(list) PER call, turning N appends
@@ -301,27 +580,115 @@ function wrapLoroListProxy(loroList) {
301
580
  // is O(1).
302
581
  if (key === 'push') {
303
582
  return (...items) => {
304
- for (const item of items)
583
+ for (const item of items) {
584
+ // Before the write, so a commit it triggers seals the preceding
585
+ // batch rather than splitting this element out of it.
586
+ batcher?.record(item);
305
587
  insertListTail(loroList, item);
588
+ }
589
+ return loroList.length;
590
+ };
591
+ }
592
+ // The mutators that name the positions they touch, so each writes those
593
+ // positions and leaves the rest of the list alone. This is what keeps an
594
+ // element the merge unit: two peers removing different entries produce
595
+ // two targeted deletes that commute, where a replay of the whole list
596
+ // from each produces two full re-insertions that both survive.
597
+ if (key === 'splice') {
598
+ return (...args) => {
599
+ if (args.length === 0)
600
+ return [];
601
+ const len = loroList.length;
602
+ const rawStart = Number(args[0]);
603
+ let start = Number.isFinite(rawStart) ? Math.trunc(rawStart) : 0;
604
+ start = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
605
+ const deleteCount = args.length === 1
606
+ ? len - start
607
+ : Math.min(Math.max(Math.trunc(Number(args[1])) || 0, 0), len - start);
608
+ // Read before writing, so the return value matches Array#splice.
609
+ const removed = [];
610
+ for (let i = 0; i < deleteCount; i += 1) {
611
+ removed.push(plainListItem(loroList, start + i));
612
+ }
613
+ const items = args.slice(2);
614
+ // A splice that removes and inserts over the same positions is a
615
+ // REPLACEMENT of them, which `l.splice(i, 1, x)` is the common way
616
+ // to write. Setting each such position states that as one op naming
617
+ // it, where deleting the run and re-inserting states it as a pair
618
+ // that commutes with a peer's concurrent write to the same position
619
+ // and leaves both entries stored. Only the part of the splice that
620
+ // genuinely changes the list's length is a delete or an insert.
621
+ const replaced = Math.min(deleteCount, items.length);
622
+ for (let i = 0; i < replaced; i += 1) {
623
+ batcher?.record(items[i]);
624
+ assignListPosition(loroList, start + i, items[i]);
625
+ }
626
+ if (deleteCount > replaced) {
627
+ loroList.delete(start + replaced, deleteCount - replaced);
628
+ }
629
+ for (let i = replaced; i < items.length; i += 1) {
630
+ batcher?.record(items[i]);
631
+ insertListAt(loroList, start + i, items[i]);
632
+ }
633
+ return removed;
634
+ };
635
+ }
636
+ if (key === 'pop') {
637
+ return () => {
638
+ const len = loroList.length;
639
+ if (len === 0)
640
+ return undefined;
641
+ const last = plainListItem(loroList, len - 1);
642
+ loroList.delete(len - 1, 1);
643
+ return last;
644
+ };
645
+ }
646
+ if (key === 'shift') {
647
+ return () => {
648
+ if (loroList.length === 0)
649
+ return undefined;
650
+ const first = plainListItem(loroList, 0);
651
+ loroList.delete(0, 1);
652
+ return first;
653
+ };
654
+ }
655
+ if (key === 'unshift') {
656
+ return (...items) => {
657
+ for (let i = 0; i < items.length; i += 1) {
658
+ batcher?.record(items[i]);
659
+ insertListAt(loroList, i, items[i]);
660
+ }
306
661
  return loroList.length;
307
662
  };
308
663
  }
309
664
  // Remaining array methods: bind to a deep-plain `toJSON` snapshot so a
310
665
  // callback reading an element's fields (`findIndex((c) => c.id === x)`,
311
666
  // `find`, `filter`, `map`) sees plain objects, not the raw child
312
- // containers `toArray` hands back (whose fields read undefined). A
313
- // mutating method replays its result into the LoroList; a pure read
314
- // does not, or a `find` inside a mutate would rewrite the whole list.
315
- // Best-effort for v1; efficient mutation uses indexed assignment /
316
- // length-set / `push`.
667
+ // containers `toArray` hands back (whose fields read undefined). A pure
668
+ // read writes nothing back, so a `find` inside a mutate leaves the list
669
+ // alone.
317
670
  const arr = loroList.toJSON();
318
671
  const val = arr[key];
319
672
  if (typeof val === 'function') {
320
673
  return (...args) => {
321
674
  const fn = val;
322
675
  const result = fn.apply(arr, args);
323
- if (MUTATING_ARRAY_METHODS.has(key))
324
- assignList(loroList, arr);
676
+ // The mutators run against the snapshot to decide the answer, then
677
+ // that answer is applied to the container as the edits it implies:
678
+ // moves for a reorder, a set per position for a replacement. What
679
+ // reaches the document is the positions that actually changed.
680
+ if (REORDERING_ARRAY_METHODS.has(key)) {
681
+ applyOrder(loroList, arr, batcher);
682
+ }
683
+ else if (REPLACING_ARRAY_METHODS.has(key)) {
684
+ for (let i = 0; i < arr.length && i < loroList.length; i += 1) {
685
+ const next = arr[i];
686
+ if (loroList.get(i) === next)
687
+ continue;
688
+ batcher?.record(next);
689
+ assignListPosition(loroList, i, next);
690
+ }
691
+ }
325
692
  return result;
326
693
  };
327
694
  }
@@ -337,21 +704,18 @@ function wrapLoroListProxy(loroList) {
337
704
  }
338
705
  if (typeof key === 'string' && /^\d+$/.test(key)) {
339
706
  const idx = Number(key);
340
- const oldLen = loroList.length;
341
- if (idx < oldLen) {
342
- loroList.delete(idx, 1);
343
- }
344
- if (isPlainObject(value)) {
345
- const child = loroList.insertContainer(idx, new LoroMap());
346
- assignPlain(child, value);
347
- }
348
- else if (Array.isArray(value)) {
349
- const child = loroList.insertContainer(idx, new LoroList());
350
- assignList(child, value);
351
- }
352
- else {
353
- loroList.insert(idx, value);
354
- }
707
+ // Before the write, so a commit it triggers seals the preceding batch
708
+ // rather than splitting this element out of it.
709
+ batcher?.record(value);
710
+ // Replacing a position is `set`, which is ONE op at that position. A
711
+ // delete plus an insert is what the list has to do without one, and it
712
+ // leaves the entry duplicated when a peer edits the same position
713
+ // concurrently, because a list CRDT commutes the deletes and keeps
714
+ // both insertions.
715
+ if (idx < loroList.length)
716
+ assignListPosition(loroList, idx, value);
717
+ else
718
+ insertListAt(loroList, idx, value);
355
719
  return true;
356
720
  }
357
721
  return false;
@@ -361,38 +725,444 @@ function wrapLoroListProxy(loroList) {
361
725
  }
362
726
  const TAG_FIELD = 'type';
363
727
  const BODY_FIELD = 'params';
364
- // A page entry on the live LoroMap is a CONTAINER, not a plain
365
- // `{ type, params }`, so it must be materialised before the page codec
366
- // (which expects plain values) reads its tag / body.
367
- function pageValue(m, key) {
728
+ // The record keys one page packs, read off the live container without
729
+ // materialising its bodies. Two fields out of wasm rather than the whole
730
+ // container: a store's bodies are its bulk, and an index build asks every page
731
+ // this question. `key` has already been classified as a page, so an entry that
732
+ // decodes to nothing is a corrupt page rather than something else.
733
+ function pageKeyList(m, key) {
368
734
  const v = m.get(key);
369
- if (v instanceof LoroMap)
370
- return v.toJSON();
371
- return v;
735
+ if (!(v instanceof LoroMap))
736
+ return [];
737
+ try {
738
+ return decodePageKeys(v.get(PAGE_KEYS_FIELD));
739
+ }
740
+ finally {
741
+ v.free();
742
+ }
372
743
  }
373
744
  // Decode a live paged `instances` map to the flat `key -> { type, params }`
374
745
  // lookup. `toJSON` materialises the page containers to the plain shape the
375
746
  // shared `flattenPagedLookup` consumes, so page flattening lives in one place.
376
747
  // Only ever called on the handle's paged path (guarded by `_isPaged()` /
377
- // `_pageCount`), so the store is known paged. Pass `paged: true` rather than
748
+ // the handle's page count), so the store is known paged. Pass `paged: true` rather than
378
749
  // re-deriving it from a tag scan.
379
- function flattenPagedMap(m) {
380
- return flattenPagedLookup(m.toJSON(), true);
381
- }
382
- // Resolve a single record without flattening the whole store: hash the key
383
- // to its page, decode only that page, find the triple. Keeps a per-key `get`
384
- // proportional to one page, not the whole file.
385
- function recordFromPage(m, recordKey, pageCount) {
386
- const page = pageValue(m, pageKeyFor(recordKey, pageCount));
387
- if (peekRecordTag(page) !== PAGE_TAG)
750
+ function flattenPagedMap(m, into) {
751
+ return flattenPagedLookup(m.toJSON(), true, into);
752
+ }
753
+ // Resolve a single record without flattening the whole store: take the key's
754
+ // page from the index, decode only that page, find the triple. Keeps a per-key
755
+ // `get` proportional to one page, not the whole file.
756
+ function recordFromPage(m, recordKey, memo, index, patchIndex) {
757
+ const base = recordBeforePatches(m, recordKey, memo, index);
758
+ if (base === undefined)
388
759
  return undefined;
389
- for (const packed of decodePageBody(decodeRecordValue(page)?.body)) {
390
- if (Array.isArray(packed) && packed[0] === recordKey) {
391
- return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
760
+ // The patches fold on LAST, over whichever copy the passes above resolved,
761
+ // matching `flattenPagedLookup` so a keyed read and a bulk read cannot
762
+ // disagree about an edited record.
763
+ const keys = patchIndex?.keysFor(recordKey);
764
+ if (keys === undefined || keys.length === 0)
765
+ return base;
766
+ const patches = [];
767
+ for (const key of keys) {
768
+ const parsed = parsePatchKey(key);
769
+ if (parsed === null)
770
+ continue;
771
+ patches.push({
772
+ peer: parsed.peer,
773
+ counter: parsed.counter,
774
+ fields: plainEntry(m, key),
775
+ });
776
+ }
777
+ return applyRecordPatches(base, patches);
778
+ }
779
+ /**
780
+ * Record key -> the patch keys editing it.
781
+ *
782
+ * Kept apart from `PagedKeyIndex` because the two cost different things. That
783
+ * index answers WHICH PAGE holds a record, which costs a read of every page's
784
+ * key list. A patch is a plain map key, so learning every patch costs a walk
785
+ * of the key list and no decode, which every keyed read can afford. Without it
786
+ * a `get` returns the page's pre-edit copy while `read` returns the edited
787
+ * one.
788
+ */
789
+ class PatchIndex {
790
+ _byRecord = new Map();
791
+ _version = null;
792
+ /** Rebuild against `m` unless the document is at the version this index was
793
+ * built at. A mutation's own writes do not go through here: they call
794
+ * `note` / `clear`, which keep the index current while the open transaction
795
+ * holds the version still. */
796
+ refresh(m, version) {
797
+ if (this._version !== null && sameStateVersion(this._version, version)) {
798
+ return;
799
+ }
800
+ this._version = version;
801
+ this._byRecord.clear();
802
+ for (const key of m.keys()) {
803
+ const parsed = parsePatchKey(key);
804
+ if (parsed === null)
805
+ continue;
806
+ const list = this._byRecord.get(parsed.recordKey);
807
+ if (list)
808
+ list.push(key);
809
+ else
810
+ this._byRecord.set(parsed.recordKey, [key]);
392
811
  }
393
812
  }
813
+ /** The patch keys editing `recordKey`, in no particular order (the fold
814
+ * orders them by counter and peer). */
815
+ keysFor(recordKey) {
816
+ return this._byRecord.get(recordKey);
817
+ }
818
+ /** The counter a new patch on `recordKey` must carry: one above every
819
+ * counter the record already holds.
820
+ *
821
+ * Per RECORD, because the fold's order has to be causal and this index is
822
+ * what a writer has merged. A writer that has seen an earlier patch on this
823
+ * record mints above it and so applies over it; two writers that have not
824
+ * seen each other mint the same number and are tied apart by peer id, which
825
+ * is the only case where the answer is arbitrary and the only case where it
826
+ * may be. Counting the writer's own edits instead orders patches by how
827
+ * busy each session was, which loses the later edit. */
828
+ nextCounterFor(recordKey) {
829
+ const keys = this._byRecord.get(recordKey);
830
+ if (keys === undefined)
831
+ return 1;
832
+ let highest = 0;
833
+ for (const key of keys) {
834
+ const parsed = parsePatchKey(key);
835
+ if (parsed === null)
836
+ continue;
837
+ const counter = Number(parsed.counter);
838
+ if (Number.isFinite(counter) && counter > highest)
839
+ highest = counter;
840
+ }
841
+ return highest + 1;
842
+ }
843
+ /** Record a patch this build just wrote, so a read later in the same
844
+ * mutation sees it without a rebuild. */
845
+ note(patchKey, recordKey) {
846
+ const list = this._byRecord.get(recordKey);
847
+ if (list)
848
+ list.push(patchKey);
849
+ else
850
+ this._byRecord.set(recordKey, [patchKey]);
851
+ }
852
+ /** Forget `recordKey`'s patches, for a caller that has just removed them. */
853
+ clear(recordKey) {
854
+ this._byRecord.delete(recordKey);
855
+ }
856
+ }
857
+ /**
858
+ * The record the map holds under `recordKey` ITSELF, beside the pages rather
859
+ * than under a reserved key, or undefined when it holds none.
860
+ *
861
+ * A record under its own bare key is every record written before the map
862
+ * paged, plus what a client that did not know it was paged leaves behind. The
863
+ * store's own entries are the ones under `RESERVED_KEY_PREFIX`, so a record
864
+ * key never names one and the value is a record whatever it holds.
865
+ *
866
+ * Both the read path and the write path ask through this, so they cannot
867
+ * disagree about which entries of a paged map are records.
868
+ */
869
+ function strayRecordAt(m, recordKey) {
870
+ if (isReservedKey(recordKey))
871
+ return undefined;
872
+ return plainEntry(m, recordKey);
873
+ }
874
+ function recordBeforePatches(m, recordKey, memo, index) {
875
+ // A reserved key names one of the store's own entries, never a record, so a
876
+ // read of one answers as it would for a key nothing holds.
877
+ if (isReservedKey(recordKey))
878
+ return undefined;
879
+ // A delete a page still holds is its own entry, and it outranks every copy
880
+ // of the record, so it is resolved first. That is one map read this path
881
+ // would not otherwise make, and it is what keeps a concurrent edit from
882
+ // resurrecting a record its own reader was told is gone.
883
+ if (entryExists(m, tombstoneKeyFor(recordKey)))
884
+ return undefined;
885
+ // Same precedence as `flattenPagedLookup`, so a keyed read and a bulk read
886
+ // cannot disagree about which copy of a record wins: an edit written beside
887
+ // the pages, then a record held under its own key, then the page.
888
+ //
889
+ // The overlay slot is checked before the index is consulted, and its
890
+ // presence decides on its own: a remote edit that landed after the index was
891
+ // built rewrites that container, which this read reaches whatever the index
892
+ // says.
893
+ const own = plainEntry(m, overlayKeyFor(recordKey));
894
+ if (own !== undefined)
895
+ return own;
896
+ const stray = strayRecordAt(m, recordKey);
897
+ if (stray !== undefined)
898
+ return stray;
899
+ // A page is filled in creation order, so the index is the only thing that
900
+ // says which one holds this record. No index, no page answer.
901
+ const pageKey = index?.readsFromPage(recordKey);
902
+ if (pageKey === undefined)
903
+ return undefined;
904
+ const page = plainEntry(m, pageKey);
905
+ if (memo)
906
+ return memo.resolve(pageKey, page).get(recordKey);
907
+ for (const packed of decodePageRecords(page)) {
908
+ if (packed[0] === recordKey)
909
+ return unpackRecord(packed);
910
+ }
394
911
  return undefined;
395
912
  }
913
+ /**
914
+ * Which page each record in a paged map lives in.
915
+ *
916
+ * A page is filled in creation order (see `SESSION_PAGE_PREFIX`), so the only
917
+ * way to find a record in one is to have read the pages' key lists. This reads
918
+ * them once and keeps the answer.
919
+ *
920
+ * Validity is stamped with the document's version, the same way `PatchIndex`
921
+ * stamps itself: one commit that both removes an entry and adds one leaves the
922
+ * map's entry COUNT where it found it while moving which keys are there, and a
923
+ * count-stamped index then answers from a page set the map no longer has, with
924
+ * nothing to correct it. That divergence has no read that resolves it, since a
925
+ * whole-store `read` walks the map and a keyed `get` walks the index, so the
926
+ * two disagree for as long as the handle lives; and a write consulting the
927
+ * stale index takes a key it already holds for an addition and seals a second
928
+ * copy of it into a new page.
929
+ */
930
+ class PagedKeyIndex {
931
+ _stateVersion;
932
+ // Record key -> the page whose body PACKS it. One map for the whole store,
933
+ // so a build costs one insert per record: a second map alongside it costs
934
+ // far more than the extra inserts (measured at 250,000 records, 415 ms
935
+ // against 62 ms, the two together outgrowing what one keeps resident).
936
+ _inPage = new Map();
937
+ // The records a container of its own answers for, which outrank the page's
938
+ // copy on a read, and the ones a tombstone removes. Both are bounded by the
939
+ // records actually edited rather than by the store's size, so they stay
940
+ // small beside the page map.
941
+ //
942
+ // Kept apart from the page map because the two answer different questions
943
+ // and diverge: a record read through its overlay container is still packed
944
+ // into the page it was written into, and a page cannot be rewritten to drop
945
+ // it. That is exactly what decides whether deleting the record needs a
946
+ // tombstone or can just remove its container, so conflating the two loses
947
+ // the record's deletion.
948
+ _contained = new Set();
949
+ _removed = new Set();
950
+ // The document version this index's answers were last known to match, or
951
+ // null while it has never been built.
952
+ _version = null;
953
+ // Rebuilds this index has done. A rebuild decodes every page, so an edit
954
+ // that forces one costs the whole store rather than one record; a test
955
+ // reads this to pin that an ordinary edit does not.
956
+ rebuilds = 0;
957
+ constructor(_stateVersion) {
958
+ this._stateVersion = _stateVersion;
959
+ }
960
+ /** Whether the document has moved since the last build, and so whether this
961
+ * index has to be built again before it answers. */
962
+ stale() {
963
+ return (this._version === null ||
964
+ !sameStateVersion(this._version, this._stateVersion()));
965
+ }
966
+ /** Take this index's answers from a walk that is already resolving them (a
967
+ * whole-store read decodes every page, which is the same question), rather
968
+ * than decoding the store a second time on the first keyed read. The sink
969
+ * is called from `flattenPagedLookup`'s own three passes, so the entries
970
+ * arrive in the order `refresh` walks them.
971
+ *
972
+ * The stamp is dropped up front and set again by `stamp`, so a walk that
973
+ * throws leaves an index that rebuilds rather than an empty one that reads
974
+ * as current. */
975
+ harvest() {
976
+ this.rebuilds += 1;
977
+ this._version = null;
978
+ this._inPage.clear();
979
+ this._contained.clear();
980
+ this._removed.clear();
981
+ return {
982
+ page: (recordKey, pageKey) => {
983
+ this._inPage.set(recordKey, pageKey);
984
+ },
985
+ contained: (recordKey) => {
986
+ this._contained.add(recordKey);
987
+ },
988
+ removed: (recordKey) => {
989
+ this._removed.add(recordKey);
990
+ },
991
+ };
992
+ }
993
+ /** Stamp a harvested index as current. */
994
+ stamp() {
995
+ this._version = this._stateVersion();
996
+ }
997
+ /** Rebuild against `m` if the document has moved since the last build. */
998
+ refresh(m) {
999
+ const version = this._stateVersion();
1000
+ if (this._version !== null && sameStateVersion(this._version, version)) {
1001
+ return;
1002
+ }
1003
+ this.rebuilds += 1;
1004
+ this._inPage.clear();
1005
+ this._contained.clear();
1006
+ this._removed.clear();
1007
+ // Same passes, in the same order, as `flattenPagedLookup`: pages, then
1008
+ // records under their own key, then the edits beside them, then the
1009
+ // deletes. A key's location has to agree with which copy of it a read
1010
+ // returns, and resolving in one pass would make that depend on the order
1011
+ // the map hands its keys back, so a record a tombstone removes could be
1012
+ // indexed to the page still holding it.
1013
+ const strays = [];
1014
+ const overlays = [];
1015
+ const removed = [];
1016
+ for (const key of m.keys()) {
1017
+ const entry = classifyStoreKey(key);
1018
+ switch (entry.kind) {
1019
+ case 'record':
1020
+ strays.push(key);
1021
+ break;
1022
+ case 'overlay':
1023
+ overlays.push(entry.recordKey);
1024
+ break;
1025
+ case 'tombstone':
1026
+ removed.push(entry.recordKey);
1027
+ break;
1028
+ case 'page':
1029
+ // A page is asked for its KEY LIST alone. Materialising the whole
1030
+ // container instead would copy every record body in the store out of
1031
+ // wasm and then decompress and parse it, to learn what the keys
1032
+ // already say: measured at 250,000 records, 1.48 s against 0.45 s.
1033
+ for (const recordKey of pageKeyList(m, key)) {
1034
+ this._inPage.set(recordKey, key);
1035
+ }
1036
+ break;
1037
+ // A patch names no record of its own, so it must not be taken for a
1038
+ // stray. Which records they edit is `PatchIndex`'s business.
1039
+ case 'patch':
1040
+ case 'unknown':
1041
+ break;
1042
+ }
1043
+ }
1044
+ for (const key of strays)
1045
+ this._contained.add(key);
1046
+ for (const recordKey of overlays)
1047
+ this._contained.add(recordKey);
1048
+ for (const recordKey of removed) {
1049
+ this._removed.add(recordKey);
1050
+ this._contained.delete(recordKey);
1051
+ }
1052
+ this._version = version;
1053
+ }
1054
+ /** Every record key the map stores, from the page key lists and the entries
1055
+ * that outrank them. Answered without decoding a page BODY, which is what
1056
+ * separates it from a whole-store read: the key lists alone are measured at
1057
+ * 0.45 s against 1.48 s for the bodies at 250,000 records. */
1058
+ liveKeys() {
1059
+ const out = new Set();
1060
+ for (const key of this._inPage.keys()) {
1061
+ if (!this._removed.has(key))
1062
+ out.add(key);
1063
+ }
1064
+ for (const key of this._contained)
1065
+ out.add(key);
1066
+ return out;
1067
+ }
1068
+ /** Whether the map stores a record under `recordKey`. */
1069
+ has(recordKey) {
1070
+ if (this._removed.has(recordKey))
1071
+ return false;
1072
+ return this._contained.has(recordKey) || this._inPage.has(recordKey);
1073
+ }
1074
+ /** The page a keyed read should decode for `recordKey`, or undefined when a
1075
+ * container of its own answers for it (or it is not stored at all). */
1076
+ readsFromPage(recordKey) {
1077
+ if (this._contained.has(recordKey) || this._removed.has(recordKey)) {
1078
+ return undefined;
1079
+ }
1080
+ return this._inPage.get(recordKey);
1081
+ }
1082
+ /** Whether some page's body still holds `recordKey`, and so whether deleting
1083
+ * it has to be stated as a tombstone. */
1084
+ packedInPage(recordKey) {
1085
+ return this._inPage.has(recordKey);
1086
+ }
1087
+ /** Record a page write this handle just made: every record `pageKey` now
1088
+ * packs. Takes the whole page rather than a record at a time so the version
1089
+ * is read once for the write instead of once per record, which a bulk write
1090
+ * reaches hundreds of thousands of times. */
1091
+ notePage(pageKey, recordKeys) {
1092
+ for (const recordKey of recordKeys) {
1093
+ this._inPage.set(recordKey, pageKey);
1094
+ this._contained.delete(recordKey);
1095
+ this._removed.delete(recordKey);
1096
+ }
1097
+ this._restamp();
1098
+ }
1099
+ /** Record that a container of its own now answers for `recordKey`. */
1100
+ noteContained(recordKey) {
1101
+ this._contained.add(recordKey);
1102
+ this._removed.delete(recordKey);
1103
+ this._restamp();
1104
+ }
1105
+ /** Note a write that moved no record between a page and a container, so the
1106
+ * stamp stays current and the next read does not rebuild.
1107
+ *
1108
+ * A patch is this: it names no record of its own, so writing or removing
1109
+ * one leaves every answer this index gives unchanged while moving the
1110
+ * document the stamp is taken against. Without it a session-paged store
1111
+ * rebuilds on the access after each edit, and a rebuild decodes every page:
1112
+ * measured at 20,000 records, 79 ms per edit against 2.8 ms. */
1113
+ noteSideEntries() {
1114
+ this._restamp();
1115
+ }
1116
+ /** Record that `recordKey` is no longer stored. What a page still holds is
1117
+ * left alone: the page keeps its copy, and a later re-add has to know that
1118
+ * removing the tombstone alone would resurrect it. */
1119
+ noteRemoved(recordKey) {
1120
+ this._removed.add(recordKey);
1121
+ this._contained.delete(recordKey);
1122
+ this._restamp();
1123
+ }
1124
+ // Carry the stamp forward over a write this index has just taken account of.
1125
+ // Every caller notes AFTER its writes, so the version read here is the one
1126
+ // the noted content matches. Committing does not move the version, so a
1127
+ // mutation that commits as it goes stays stamped throughout.
1128
+ //
1129
+ // An index that has never been built stays unbuilt: stamping one fresh would
1130
+ // leave it empty and authoritative.
1131
+ _restamp() {
1132
+ if (this._version === null)
1133
+ return;
1134
+ this._version = this._stateVersion();
1135
+ }
1136
+ }
1137
+ /**
1138
+ * A one-entry memo of the last decoded page, so repeated keyed reads inside
1139
+ * one page decode it once. Keyed on the page's stored BODY, which is one
1140
+ * immutable string, so any write that changes the page misses and a stale
1141
+ * read is not expressible.
1142
+ */
1143
+ class PageMemo {
1144
+ _pageKey = null;
1145
+ _body = undefined;
1146
+ _records = new Map();
1147
+ resolve(pageKey, page) {
1148
+ const body = decodeRecordValue(page)?.body;
1149
+ if (this._pageKey === pageKey && this._body === body)
1150
+ return this._records;
1151
+ const records = new Map();
1152
+ for (const packed of decodePageRecords(page)) {
1153
+ records.set(packed[0], unpackRecord(packed));
1154
+ }
1155
+ this._pageKey = pageKey;
1156
+ this._body = body;
1157
+ this._records = records;
1158
+ return records;
1159
+ }
1160
+ }
1161
+ /** Whether a page still holds this record, which decides whether a delete
1162
+ * needs a marker or can just remove the record's own container. */
1163
+ function pageHoldsRecord(recordKey, index) {
1164
+ return index !== null && index.packedInPage(recordKey);
1165
+ }
396
1166
  // ---- Change deltas (`{ delta: true }` map subscribers) ----------------
397
1167
  //
398
1168
  // A delta subscriber reports which keys changed since the previous fire so a
@@ -406,54 +1176,75 @@ function recordFromPage(m, recordKey, pageCount) {
406
1176
  // the changed PAGE. Recover record granularity with a per-subscription
407
1177
  // page -> keys index, diffing a changed page's current keys against it; a
408
1178
  // deleted record was in the prior key set and absent from the rewritten page.
409
- // The root key of a root container (`cid:root-<key>:Map`), else null. A field
410
- // edit is keyed by its first path segment under root, which only a root
411
- // record map can resolve; nested handles fall back to direct keys only.
412
- function rootKeyOf(m) {
413
- const match = /^cid:root-(.+):Map$/.exec(m.id);
414
- return match?.[1] ?? null;
415
- }
416
- // The record keys currently packed into one page, or null for a missing /
417
- // non-page entry. Seeds and advances the page index for delete detection.
418
- function pageRecordKeys(m, pageKey) {
419
- const records = pageRecords(m, pageKey);
420
- return records ? new Set(records.keys()) : null;
1179
+ // How many path segments name `m` itself. An event's `path` is absolute from
1180
+ // the document root, so a descendant event's entry key is the segment at this
1181
+ // index: 1 for a root container (`path[0]` is the root key), one more for each
1182
+ // container it is nested inside.
1183
+ //
1184
+ // Measured from the container rather than from the handle's anchor chain
1185
+ // because the two can disagree: a scoped handle over a paged store resolves
1186
+ // its record through the copy that answers for it, which sits one level below
1187
+ // the root either way but under a key the anchor does not name.
1188
+ function containerDepth(m) {
1189
+ let depth = 0;
1190
+ let current = m;
1191
+ while (current) {
1192
+ depth += 1;
1193
+ current = current.parent();
1194
+ }
1195
+ return depth;
421
1196
  }
422
1197
  // Decode one page to its `key -> { type, params }` records in a single pass,
423
1198
  // so a paged delta gets the changed records' VALUES from the same decode it
424
1199
  // uses to find the changed keys (a second per-key decode would make a large
425
- // initial build O(records x page-size)).
1200
+ // initial build O(records x page-size)). Null for a page key nothing holds,
1201
+ // which is how a delta tells a rewritten page from a removed one.
426
1202
  function pageRecords(m, pageKey) {
427
- const page = pageValue(m, pageKey);
428
- if (peekRecordTag(page) !== PAGE_TAG)
1203
+ const page = plainEntry(m, pageKey);
1204
+ if (page === undefined)
429
1205
  return null;
430
1206
  const records = new Map();
431
- for (const packed of decodePageBody(decodeRecordValue(page)?.body)) {
432
- if (Array.isArray(packed) && typeof packed[0] === 'string') {
433
- records.set(packed[0], { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] });
434
- }
1207
+ for (const packed of decodePageRecords(page)) {
1208
+ records.set(packed[0], unpackRecord(packed));
435
1209
  }
436
1210
  return records;
437
1211
  }
438
- // Materialise one unpaged map entry to a plain value (containers via
439
- // `toJSON`, scalars passthrough), so a delta carries the changed value
440
- // without a re-read.
441
- function entryValue(m, key) {
442
- const v = m.get(key);
443
- if (v instanceof LoroMap || v instanceof LoroList)
444
- return v.toJSON();
445
- return v;
1212
+ /** Note the record a store entry answers for, if it answers for one. */
1213
+ function noteShadowedRecord(shadowed, key) {
1214
+ const entry = classifyStoreKey(key);
1215
+ switch (entry.kind) {
1216
+ case 'patch':
1217
+ shadowed.add(entry.parsed.recordKey);
1218
+ break;
1219
+ case 'overlay':
1220
+ case 'record':
1221
+ case 'tombstone':
1222
+ shadowed.add(entry.recordKey);
1223
+ break;
1224
+ case 'page':
1225
+ case 'unknown':
1226
+ break;
1227
+ }
446
1228
  }
447
- function buildPageIndex(m) {
448
- const index = new Map();
449
- for (const pageKey of m.keys()) {
450
- const keys = pageRecordKeys(m, pageKey);
451
- if (keys)
452
- index.set(pageKey, keys);
1229
+ function shadowedRecords(m) {
1230
+ const shadowed = new Set();
1231
+ for (const key of m.keys())
1232
+ noteShadowedRecord(shadowed, key);
1233
+ return shadowed;
1234
+ }
1235
+ function buildPagedDeltaIndex(m) {
1236
+ const pages = new Map();
1237
+ const shadowed = new Set();
1238
+ for (const key of m.keys()) {
1239
+ if (classifyStoreKey(key).kind === 'page') {
1240
+ pages.set(key, new Set(pageKeyList(m, key)));
1241
+ continue;
1242
+ }
1243
+ noteShadowedRecord(shadowed, key);
453
1244
  }
454
- return index;
1245
+ return { pages, shadowed };
455
1246
  }
456
- function unpagedMapDelta(m, rootKey, batch) {
1247
+ function unpagedMapDelta(m, depth, batch) {
457
1248
  const setKeys = new Set();
458
1249
  const deleted = new Set();
459
1250
  for (const ev of batch.events) {
@@ -467,11 +1258,11 @@ function unpagedMapDelta(m, rootKey, batch) {
467
1258
  setKeys.add(key);
468
1259
  }
469
1260
  }
470
- else if (rootKey !== null) {
471
- // A descendant event (a field edit on a record's nested container).
472
- // Every batch event is within the root map's subtree, so the record's
473
- // key is the path segment directly under root.
474
- const key = ev.path[1];
1261
+ else {
1262
+ // A descendant event (a field edit on an entry's nested container).
1263
+ // Every batch event is within this map's subtree, so the entry's key is
1264
+ // the path segment directly under it, at this map's own depth.
1265
+ const key = ev.path[depth];
475
1266
  if (typeof key === 'string')
476
1267
  setKeys.add(key);
477
1268
  }
@@ -480,34 +1271,171 @@ function unpagedMapDelta(m, rootKey, batch) {
480
1271
  setKeys.delete(key);
481
1272
  const set = new Map();
482
1273
  for (const key of setKeys)
483
- set.set(key, entryValue(m, key));
1274
+ set.set(key, plainEntry(m, key));
484
1275
  return { set, deleted: [...deleted] };
485
1276
  }
486
- function pagedMapDelta(m, batch, pageIndex) {
1277
+ /**
1278
+ * Read a changed page out to the records it holds, and name the ones it lost.
1279
+ *
1280
+ * A page is one value, so a change to it says nothing about WHICH of its
1281
+ * records moved; the index of what the page held before is what recovers that.
1282
+ * Both routes a page change arrives by resolve through here so they cannot
1283
+ * disagree about what a rewrite meant.
1284
+ *
1285
+ * A record something outside the page answers for is RESOLVED rather than read
1286
+ * out of the page, for the same reason the entry cases resolve: the page's copy
1287
+ * of it is one of several the store holds, and it is the one every read
1288
+ * discards. Reading it out hands a subscriber the value an edit replaced, or
1289
+ * puts a deleted record back into a view that then holds it for good.
1290
+ */
1291
+ function expandPage(m, pageKey, index, set, deleted, expanded, resolve) {
1292
+ if (expanded.has(pageKey))
1293
+ return;
1294
+ expanded.add(pageKey);
1295
+ const before = index.pages.get(pageKey);
1296
+ const after = pageRecords(m, pageKey);
1297
+ if (after) {
1298
+ for (const [key, value] of after) {
1299
+ if (!index.shadowed.has(key)) {
1300
+ set.set(key, value);
1301
+ continue;
1302
+ }
1303
+ const live = resolve(key);
1304
+ if (live === undefined)
1305
+ deleted.add(key);
1306
+ else
1307
+ set.set(key, live);
1308
+ }
1309
+ index.pages.set(pageKey, new Set(after.keys()));
1310
+ }
1311
+ else {
1312
+ index.pages.delete(pageKey);
1313
+ }
1314
+ if (before) {
1315
+ for (const key of before) {
1316
+ if (after?.has(key))
1317
+ continue;
1318
+ // A record the page dropped that the store still answers for is not
1319
+ // gone, and the copy that answers for it is one this rewrite did not
1320
+ // touch, so the change says nothing about its value either.
1321
+ if (index.shadowed.has(key) && resolve(key) !== undefined)
1322
+ continue;
1323
+ deleted.add(key);
1324
+ }
1325
+ }
1326
+ }
1327
+ function pagedMapDelta(m, batch, index, resolve) {
487
1328
  const set = new Map();
488
1329
  const deleted = new Set();
1330
+ // Every entry that answers for a record is noted before any page is read
1331
+ // out. An entry and a page carrying the same record can arrive in either
1332
+ // order within one batch, and both write the record into `set`, so a page
1333
+ // read out first would report the copy the entry supersedes.
489
1334
  for (const ev of batch.events) {
490
- if (ev.target !== m.id || ev.diff.type !== 'map')
1335
+ if (ev.target === m.id && ev.diff.type === 'map') {
1336
+ for (const changedKey of Object.keys(ev.diff.updated)) {
1337
+ noteShadowedRecord(index.shadowed, changedKey);
1338
+ }
491
1339
  continue;
492
- for (const pageKey of Object.keys(ev.diff.updated)) {
493
- const before = pageIndex.get(pageKey);
494
- const after = pageRecords(m, pageKey);
495
- if (after) {
496
- for (const [key, value] of after)
497
- set.set(key, value);
498
- pageIndex.set(pageKey, new Set(after.keys()));
1340
+ }
1341
+ const segment = ev.path[1];
1342
+ if (typeof segment === 'string')
1343
+ noteShadowedRecord(index.shadowed, segment);
1344
+ }
1345
+ // Pages this batch has already been read out of. One rewrite can reach here
1346
+ // from both directions (the map-level diff and the page's own container
1347
+ // event), and a page body is gzipped, so decoding it twice per batch is real
1348
+ // work on a store whose pages a pass rewrites all at once.
1349
+ const expanded = new Set();
1350
+ // A patch key names no record, so the record it edits is resolved once at
1351
+ // the end however many patches this batch carried for it. Reporting the
1352
+ // patch key itself would name a record no reader has, and dropping it (it
1353
+ // is neither an overlay key nor a page) would leave a subscriber's view
1354
+ // showing the pre-edit value.
1355
+ const patched = new Set();
1356
+ for (const ev of batch.events) {
1357
+ if (ev.target === m.id && ev.diff.type === 'map') {
1358
+ for (const changedKey of Object.keys(ev.diff.updated)) {
1359
+ const entry = classifyStoreKey(changedKey);
1360
+ switch (entry.kind) {
1361
+ case 'patch':
1362
+ patched.add(entry.parsed.recordKey);
1363
+ break;
1364
+ // An entry that names one record: the record's own container beside
1365
+ // the pages, or the record under its own bare key. Either is one of
1366
+ // several copies a paged store can hold, so the record is RESOLVED
1367
+ // rather than read off the entry and a subscriber is handed the
1368
+ // value a reader gets. A peer's patch outranks the container it was
1369
+ // written over, and a page copy survives the container going, so
1370
+ // the entry alone answers neither what the record holds nor whether
1371
+ // it is still there.
1372
+ case 'overlay':
1373
+ case 'record': {
1374
+ const value = resolve(entry.recordKey);
1375
+ if (value === undefined)
1376
+ deleted.add(entry.recordKey);
1377
+ else
1378
+ set.set(entry.recordKey, value);
1379
+ break;
1380
+ }
1381
+ // The marker arriving removes the record; the marker going is a
1382
+ // re-add, whose own entry the same batch carries, so the record is
1383
+ // resolved rather than reported from here.
1384
+ case 'tombstone': {
1385
+ if (entryExists(m, changedKey)) {
1386
+ deleted.add(entry.recordKey);
1387
+ break;
1388
+ }
1389
+ const value = resolve(entry.recordKey);
1390
+ if (value !== undefined)
1391
+ set.set(entry.recordKey, value);
1392
+ break;
1393
+ }
1394
+ case 'page':
1395
+ expandPage(m, changedKey, index, set, deleted, expanded, resolve);
1396
+ break;
1397
+ case 'unknown':
1398
+ break;
1399
+ }
499
1400
  }
500
- else {
501
- pageIndex.delete(pageKey);
1401
+ continue;
1402
+ }
1403
+ // An event on a container under the root, whose key is the path segment
1404
+ // there. Two shapes reach here.
1405
+ //
1406
+ // A field edit fires on the record's own container, and names that record.
1407
+ //
1408
+ // A PAGE fires here when a covering mutation rewrites it: the page's
1409
+ // container already exists, so the rewrite sets fields on it rather than
1410
+ // assigning a key of the store's own map, and the map-level diff carries
1411
+ // nothing. Reading only that diff drops every record in every page a pass
1412
+ // covered, which is the whole store for a pass over the whole store, and
1413
+ // drops them silently: the writer's own app repaints the few records that
1414
+ // took another route and leaves the rest drawn where they were.
1415
+ const segment = ev.path[1];
1416
+ if (typeof segment === 'string') {
1417
+ const entry = classifyStoreKey(segment);
1418
+ if (entry.kind === 'page') {
1419
+ expandPage(m, segment, index, set, deleted, expanded, resolve);
502
1420
  }
503
- if (before) {
504
- for (const key of before) {
505
- if (!after || !after.has(key))
506
- deleted.add(key);
507
- }
1421
+ else if (entry.kind === 'overlay' || entry.kind === 'record') {
1422
+ // Resolved for the same reason the map-level cases are: the container
1423
+ // this event fired on is one copy of the record, not the answer.
1424
+ const value = resolve(entry.recordKey);
1425
+ if (value !== undefined)
1426
+ set.set(entry.recordKey, value);
508
1427
  }
509
1428
  }
510
1429
  }
1430
+ for (const recordKey of patched) {
1431
+ if (set.has(recordKey) || deleted.has(recordKey))
1432
+ continue;
1433
+ const value = resolve(recordKey);
1434
+ if (value === undefined)
1435
+ deleted.add(recordKey);
1436
+ else
1437
+ set.set(recordKey, value);
1438
+ }
511
1439
  for (const key of deleted)
512
1440
  set.delete(key);
513
1441
  return { set, deleted: [...deleted] };
@@ -516,70 +1444,152 @@ function pagedMapDelta(m, batch, pageIndex) {
516
1444
  // OVERLAY of in-batch changes (key -> record, or a tombstone for a delete);
517
1445
  // reads consult the overlay first then the stored page, so an edit is
518
1446
  // visible to later reads in the same `fn` as on an unpaged live proxy.
519
- // Afterwards only the touched pages are decoded, rewritten, and re-encoded,
520
- // so a single field edit rewrites a single page. A `pruneRefs` sweep that
521
- // iterates the whole draft is the only path that reads every page, the rare
522
- // structural-delete case already treated as O(file).
523
- function mutatePaged(m, pageCount, fn) {
524
- // A paged store always carries a positive `meta.page_count`, so a
525
- // non-positive count means the store is corrupt: the page layout can't be
526
- // resolved and guessing would scramble which record lives where. Refuse
527
- // rather than corrupt.
528
- if (pageCount <= 0) {
529
- throw new Error('Cannot edit a paged record store with no page_count.');
530
- }
1447
+ // Afterwards `applyOverlay` writes each EDITED record to its own container
1448
+ // beside the pages, leaving every existing page byte-identical. That is what
1449
+ // keeps two people editing different records in one page from overwriting each
1450
+ // other: a page body is a single gzipped value, so rewriting it would make the
1451
+ // page the merge unit and hand the whole page to the last writer. A `pruneRefs`
1452
+ // sweep that iterates the whole draft is the only path that reads every page,
1453
+ // the rare structural-delete case already treated as O(file).
1454
+ function mutatePaged(m, fn, pager, batcher) {
531
1455
  const TOMBSTONE = Symbol('deleted');
532
1456
  // In-batch changes: key -> new record, or TOMBSTONE when deleted. Absent
533
1457
  // means "unchanged, read from the page".
534
1458
  const overlay = new Map();
1459
+ // One decoded page shared by every read this mutation makes. Reading a
1460
+ // record through the draft (`m[key].params`) resolves it once per field
1461
+ // touched, and the ordinary edit shape touches several, so without this a
1462
+ // single-record edit gunzips and parses its whole page a handful of times.
1463
+ // `applyOverlay` never rewrites a page, so a page decoded here stays valid
1464
+ // for the whole mutation.
1465
+ const memo = new PageMemo();
1466
+ // Taken ONCE, before the first write. The version advances as the mutation
1467
+ // writes, so taking it again per record would rebuild the index per record;
1468
+ // the write path keeps this instance current instead (`note` / `clear`).
1469
+ const patchIndex = pager ? pager.patches(m) : null;
535
1470
  // The record a draft read should see: the overlay value if touched this
536
- // batch, else the stored record (one page decode).
1471
+ // batch, else the stored record.
537
1472
  const currentRecord = (key) => {
538
1473
  if (overlay.has(key)) {
539
1474
  const v = overlay.get(key);
540
1475
  return v === TOMBSTONE ? undefined : v;
541
1476
  }
542
- return recordFromPage(m, key, pageCount);
1477
+ return recordFromPage(m, key, memo, pager?.liveIndex ?? null, patchIndex);
543
1478
  };
544
1479
  // Per-record sub-proxy: a field assignment writes through to the overlay
545
1480
  // record so a later read sees it and the page rewrite picks it up. Field
546
1481
  // reads return the deep-plain stored value, so spreading `{ ...rec.params }`
547
1482
  // never leaks a live handle.
548
1483
  const recordProxy = (key) => {
1484
+ // The record a field write starts from. A value that is not an object has
1485
+ // no fields to keep, so the write starts from an empty record rather than
1486
+ // from that value's own entries (`'ab'` would contribute `0` and `1`).
1487
+ const fieldBase = () => {
1488
+ const rec = currentRecord(key);
1489
+ return isPlainObject(rec) ? { ...rec } : {};
1490
+ };
549
1491
  return new Proxy({}, {
550
1492
  get(_t, field) {
551
1493
  if (typeof field === 'symbol')
552
1494
  return undefined;
553
1495
  const rec = currentRecord(key);
554
- return rec ? rec[field] : undefined;
1496
+ const v = isPlainObject(rec) ? rec[field] : undefined;
1497
+ if (v !== null && typeof v === 'object') {
1498
+ // Same reason as the unpaged proxy: a body is a plain value, so
1499
+ // a field edit inside it has to be written back rather than
1500
+ // applied to the copy the read handed out.
1501
+ return writeThroughBody(v, (next) => {
1502
+ const base = fieldBase();
1503
+ base[field] = toPlainDeep(next);
1504
+ overlay.set(key, base);
1505
+ });
1506
+ }
1507
+ return v;
555
1508
  },
556
1509
  set(_t, field, value) {
557
1510
  if (typeof field === 'symbol')
558
1511
  return false;
559
- const base = { ...currentRecord(key) };
1512
+ const base = fieldBase();
560
1513
  base[field] = toPlainDeep(value);
561
1514
  overlay.set(key, base);
562
1515
  return true;
563
1516
  },
1517
+ // A field removal has to write through here as an assignment does.
1518
+ // The default trap would delete from this proxy's own empty target
1519
+ // and report success, so `delete d[key].field` would apply on a small
1520
+ // store and silently do nothing once the store paged.
1521
+ deleteProperty(_t, field) {
1522
+ if (typeof field === 'symbol')
1523
+ return false;
1524
+ const base = fieldBase();
1525
+ if (!(field in base))
1526
+ return true;
1527
+ delete base[field];
1528
+ overlay.set(key, base);
1529
+ return true;
1530
+ },
1531
+ // Enumerating a record is what `{ ...rec }`, `Object.keys(rec)` and
1532
+ // `JSON.stringify(rec)` all reach for, so the record answers with its
1533
+ // stored fields here as it does through the unpaged proxy. Without
1534
+ // these the proxy enumerates its own empty target, and an app that
1535
+ // copies a record reads it as `{}` on a paged store while the same
1536
+ // code works on a small one.
1537
+ has(_t, field) {
1538
+ if (typeof field === 'symbol')
1539
+ return false;
1540
+ const rec = currentRecord(key);
1541
+ return isPlainObject(rec) && field in rec;
1542
+ },
1543
+ ownKeys() {
1544
+ const rec = currentRecord(key);
1545
+ return isPlainObject(rec) ? Object.keys(rec) : [];
1546
+ },
1547
+ getOwnPropertyDescriptor(_t, field) {
1548
+ if (typeof field === 'symbol')
1549
+ return undefined;
1550
+ const rec = currentRecord(key);
1551
+ if (!isPlainObject(rec) || !(field in rec))
1552
+ return undefined;
1553
+ return {
1554
+ enumerable: true,
1555
+ configurable: true,
1556
+ writable: true,
1557
+ value: rec[field],
1558
+ };
1559
+ },
564
1560
  });
565
1561
  };
566
1562
  const draft = new Proxy({}, {
567
1563
  get(_t, key) {
568
1564
  if (typeof key === 'symbol')
569
1565
  return undefined;
570
- if (currentRecord(key) === undefined)
1566
+ const rec = currentRecord(key);
1567
+ if (rec === undefined)
571
1568
  return undefined;
1569
+ // As on an unpaged map: a stored value that is not an object is handed
1570
+ // back as itself, and a plain array as a view that writes an element
1571
+ // edit through instead of into a copy the caller then drops. Only an
1572
+ // object has the fields the per-record sub-proxy answers for.
1573
+ if (!isPlainObject(rec)) {
1574
+ if (!Array.isArray(rec))
1575
+ return rec;
1576
+ return writeThroughBody(rec, (next) => {
1577
+ overlay.set(key, toPlainDeep(next));
1578
+ });
1579
+ }
572
1580
  return recordProxy(key);
573
1581
  },
574
1582
  set(_t, key, value) {
575
1583
  if (typeof key === 'symbol')
576
1584
  return false;
1585
+ assertUsableRecordKey(key);
577
1586
  overlay.set(key, toPlainDeep(value));
578
1587
  return true;
579
1588
  },
580
1589
  deleteProperty(_t, key) {
581
1590
  if (typeof key === 'symbol')
582
1591
  return false;
1592
+ assertUsableRecordKey(key);
583
1593
  overlay.set(key, TOMBSTONE);
584
1594
  return true;
585
1595
  },
@@ -610,52 +1620,584 @@ function mutatePaged(m, pageCount, fn) {
610
1620
  fn(draft);
611
1621
  if (overlay.size === 0)
612
1622
  return;
613
- applyOverlay(m, pageCount, overlay, TOMBSTONE);
614
- }
615
- // Rewrite the pages the overlay touched: each is decoded once, untouched
616
- // records carried over, changed records re-stated (or dropped on a
617
- // tombstone), then re-encoded (or deleted if it ends empty).
618
- function applyOverlay(m, pageCount, overlay, tombstone) {
619
- const touchedPages = new Set();
620
- for (const key of overlay.keys()) {
621
- touchedPages.add(pageKeyFor(key, pageCount));
622
- }
623
- for (const pageKey of touchedPages) {
624
- const existing = pageValue(m, pageKey);
625
- const triples = [];
626
- // Carry over the page's untouched records.
627
- if (peekRecordTag(existing) === PAGE_TAG) {
628
- for (const packed of decodePageBody(decodeRecordValue(existing)?.body)) {
629
- if (Array.isArray(packed) &&
630
- typeof packed[0] === 'string' &&
631
- !overlay.has(packed[0])) {
632
- triples.push(packed);
1623
+ applyOverlay(m, overlay, TOMBSTONE, patchIndex, memo, pager, batcher);
1624
+ }
1625
+ // The container holding one record beside the pages, reusing the one already
1626
+ // at this key. A fresh `setContainer` is a destroy+create rather than a write
1627
+ // (the same reason `assignPlain` reuses), so minting one per edit charges the
1628
+ // snapshot for every edit instead of for every edited RECORD: 2,000 edits to
1629
+ // one record cost 71.8 KB minting and 8.6 KB reusing. A drag, a slider, and a
1630
+ // rename are all repeated edits to one record, and every reader downloads the
1631
+ // difference on every open.
1632
+ function overlayContainer(m, overlayKey) {
1633
+ const existing = m.get(overlayKey);
1634
+ return existing instanceof LoroMap
1635
+ ? existing
1636
+ : m.setContainer(overlayKey, new LoroMap());
1637
+ }
1638
+ // State that a record a page still holds is deleted. The marker carries
1639
+ // nothing but its own existence, so it is a plain value under a key of its
1640
+ // own: a field on the record's container would be a field of the app's record,
1641
+ // and a record that happened to carry it would read as its own deletion.
1642
+ function markDeleted(m, recordKey) {
1643
+ m.set(tombstoneKeyFor(recordKey), true);
1644
+ }
1645
+ // Take back a record's delete marker, and report whether there was one.
1646
+ // Called wherever a record's own container is written, since that container is
1647
+ // the record being there again.
1648
+ function clearTombstone(m, recordKey) {
1649
+ const key = tombstoneKeyFor(recordKey);
1650
+ if (!entryExists(m, key))
1651
+ return false;
1652
+ m.delete(key);
1653
+ return true;
1654
+ }
1655
+ // Write out what a paged mutation changed.
1656
+ //
1657
+ // An EDITED record is written OUTSIDE the page holding it, as a patch naming
1658
+ // the fields that changed or, once this session has patched it before, as a
1659
+ // container of its own. A page body is one value, so writing the edit into the
1660
+ // page would make the page the merge unit: two peers changing different
1661
+ // records in one page would resolve last-writer-wins, and an undo would
1662
+ // restore the whole page over a peer's record. What that costs is an entry per
1663
+ // record edited, bounded by the records actually edited rather than by the
1664
+ // store's size.
1665
+ //
1666
+ // The exception is a mutation that replaces EVERY record a page holds, which
1667
+ // `writeEdits` rewrites in place: nothing in the page is left for the rewrite
1668
+ // to resolve on another writer's behalf, so the merge unit does not move. That
1669
+ // is the one route by which an edited store returns to the entry count it
1670
+ // imported at, and it is why a pass over the whole store is much cheaper than
1671
+ // the same edits made a scattered subset at a time.
1672
+ //
1673
+ // A bulk ADD is written as fresh pages instead, in the writer's own key space,
1674
+ // which is safe for the same reason an edit in place is not: those keys are
1675
+ // new, so no peer holds a copy to lose.
1676
+ function applyOverlay(m, overlay, tombstone, patchIndex, memo, pager,
1677
+ // Commits as the writes accumulate, on the same size budget an unpaged
1678
+ // mutation uses. Without it every page a mutation writes rides one op,
1679
+ // whose size then scales with the mutation rather than with the budget.
1680
+ batcher) {
1681
+ let index = pager?.liveIndex ?? null;
1682
+ // Records that MIGHT be additions: nothing holds a container for them yet,
1683
+ // so each is either new or living in a page. Held back so a bulk add is
1684
+ // written as whole pages instead of a container each. A record that already
1685
+ // has a container keeps it: the page holding a record cannot be rewritten to
1686
+ // carry an edit (see above), and moving it would mean deleting a container a
1687
+ // peer may be writing to at that moment.
1688
+ const candidates = [];
1689
+ for (const [key, rec] of overlay) {
1690
+ const overlayKey = overlayKeyFor(key);
1691
+ if (rec === tombstone) {
1692
+ // Charged before the writes, so a commit it triggers closes the batch
1693
+ // that precedes this delete rather than splitting it out of one. A
1694
+ // paged store's deletes are unbounded where an unpaged store's stop at
1695
+ // the threshold, so this is the path where a bulk delete would
1696
+ // otherwise ride one op sized by the delete instead of by the budget.
1697
+ batcher?.recordDelete(key);
1698
+ // A record held under its own key outgoes the page on every read, so a
1699
+ // delete that left it would not remove the record. Off the hot read
1700
+ // path: only a delete pays for this.
1701
+ if (entryExists(m, key))
1702
+ m.delete(key);
1703
+ if (entryExists(m, overlayKey))
1704
+ m.delete(overlayKey);
1705
+ dropPatches(m, patchIndex, key);
1706
+ // A record a page still holds needs the delete stated, since the page
1707
+ // cannot be rewritten to drop it. One that exists only as its own
1708
+ // container is simply removed, so an add-then-delete leaves nothing and
1709
+ // no marker outlives the record it was about.
1710
+ if (pageHoldsRecord(key, index))
1711
+ markDeleted(m, key);
1712
+ index?.noteRemoved(key);
1713
+ batcher?.noteKeys([key]);
1714
+ continue;
1715
+ }
1716
+ const r = rec;
1717
+ // A record already answering for itself beside the pages: rewrite that
1718
+ // container in place, so repeated edits merge per field and cost one write
1719
+ // each rather than a fresh container.
1720
+ if (m.get(overlayKey) !== undefined) {
1721
+ dropPatches(m, patchIndex, key);
1722
+ writeRecordContainer(m, key, r);
1723
+ index?.noteContained(key);
1724
+ batcher?.noteKeys([key]);
1725
+ batcher?.record(r);
1726
+ continue;
1727
+ }
1728
+ // A record under its own BARE key is edited where it already is, exactly
1729
+ // as an unpaged store edits it.
1730
+ //
1731
+ // Giving it an overlay container instead is a `setContainer` at a key no
1732
+ // peer holds yet, so two peers making that record's first edit at the same
1733
+ // time each mint one and the map entry resolves last-writer-wins, dropping
1734
+ // an edit outright. Writing into the container already there merges per
1735
+ // field, and leaves the record stored once rather than twice.
1736
+ if (strayRecordAt(m, key) !== undefined) {
1737
+ dropPatches(m, patchIndex, key);
1738
+ // The record is present again, so its delete marker goes, exactly as it
1739
+ // does when a record is written to a container of its own. A marker
1740
+ // outranks every copy of a record on a read, so an edit that left one
1741
+ // behind would store the new record and answer that it is gone.
1742
+ clearTombstone(m, key);
1743
+ assignEntry(m, key, r);
1744
+ index?.noteContained(key);
1745
+ batcher?.noteKeys([key]);
1746
+ batcher?.record(r);
1747
+ continue;
1748
+ }
1749
+ if (pager) {
1750
+ candidates.push([key, r]);
1751
+ continue;
1752
+ }
1753
+ dropPatches(m, patchIndex, key);
1754
+ writeRecordContainer(m, key, r);
1755
+ index?.noteContained(key);
1756
+ batcher?.noteKeys([key]);
1757
+ batcher?.record(r);
1758
+ }
1759
+ if (candidates.length === 0)
1760
+ return;
1761
+ // Below a page's worth there is nothing to PAGE, so nothing here adds a
1762
+ // page; the two branches differ only in whether a bulk write refreshes the
1763
+ // index up front before telling an edit from an addition.
1764
+ const perPage = recordsPerPage(sampleBodyBytes(candidates, pagedBodyOf));
1765
+ const added = [];
1766
+ if (candidates.length >= perPage && pager) {
1767
+ index = pager.ensureIndex(m);
1768
+ const edits = [];
1769
+ for (const entry of candidates) {
1770
+ // The page already holds this record, so this is an EDIT rather than an
1771
+ // addition. That covers a record this store DELETED as well: the page
1772
+ // keeps its copy whatever the marker says, so re-adding it has to write
1773
+ // beside the page rather than into a second one.
1774
+ if (pager.holds(entry[0]) || index.packedInPage(entry[0])) {
1775
+ edits.push(entry);
1776
+ }
1777
+ else
1778
+ added.push(entry);
1779
+ }
1780
+ writeEdits(m, pager, index, patchIndex, edits, memo, batcher);
1781
+ }
1782
+ else if (pager) {
1783
+ for (const [key, r] of candidates) {
1784
+ if (pageHoldsRecord(key, index)) {
1785
+ writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
1786
+ continue;
1787
+ }
1788
+ writeRecordContainer(m, key, r);
1789
+ index?.noteContained(key);
1790
+ batcher?.noteKeys([key]);
1791
+ batcher?.record(r);
1792
+ }
1793
+ return;
1794
+ }
1795
+ else {
1796
+ for (const [key, r] of candidates) {
1797
+ writeRecordContainer(m, key, r);
1798
+ index?.noteContained(key);
1799
+ batcher?.noteKeys([key]);
1800
+ batcher?.record(r);
1801
+ }
1802
+ return;
1803
+ }
1804
+ const written = pager.writePages(m, added, perPage, batcher);
1805
+ for (let i = written; i < added.length; i += 1) {
1806
+ const [key, r] = added[i];
1807
+ writeRecordContainer(m, key, r);
1808
+ index.noteContained(key);
1809
+ batcher?.noteKeys([key]);
1810
+ batcher?.record(r);
1811
+ }
1812
+ }
1813
+ /**
1814
+ * Write a mutation's edits to records their pages hold.
1815
+ *
1816
+ * A page whose every record this mutation replaces is REWRITTEN in place, and
1817
+ * the patches on those records go with it. That is what keeps a transform over
1818
+ * the whole store from landing it in its worst state: a patch each would add
1819
+ * an entry per record, where rewriting leaves the store at the entry count it
1820
+ * had. A bulk pass naturally covers whole pages, because a read hands its keys
1821
+ * back grouped by page.
1822
+ *
1823
+ * The rewrite is safe against the edits that are not rewrites, which is every
1824
+ * other route an edit can take. A page body is one value, so a rewrite is
1825
+ * last-writer-wins on the PAGE, but a peer's patch and a peer's own container
1826
+ * both live outside it and both outrank it on read, so a concurrent edit
1827
+ * survives a rewrite that did not see it. Only another rewrite of the same
1828
+ * page can lose one, and that is two people transforming one model at once.
1829
+ *
1830
+ * Patches are dropped only for the records this rewrite absorbed, so a patch
1831
+ * written concurrently, under a key this writer never saw, is left to apply
1832
+ * over the rewritten page.
1833
+ */
1834
+ function writeEdits(m, pager, index, patchIndex, edits, memo, batcher) {
1835
+ const byPage = new Map();
1836
+ const loose = [];
1837
+ for (const entry of edits) {
1838
+ const pageKey = index.readsFromPage(entry[0]);
1839
+ if (pageKey === undefined || pageKey === null) {
1840
+ loose.push(entry);
1841
+ continue;
1842
+ }
1843
+ const group = byPage.get(pageKey);
1844
+ if (group)
1845
+ group.push(entry);
1846
+ else
1847
+ byPage.set(pageKey, [entry]);
1848
+ }
1849
+ for (const [pageKey, group] of byPage) {
1850
+ if (!rewritePage(m, pageKey, group, batcher)) {
1851
+ for (const [key, r] of group) {
1852
+ writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
1853
+ }
1854
+ continue;
1855
+ }
1856
+ for (const [key] of group)
1857
+ dropPatches(m, patchIndex, key);
1858
+ index.noteSideEntries();
1859
+ batcher?.noteKeys(group.map(([key]) => key));
1860
+ }
1861
+ for (const [key, r] of loose) {
1862
+ writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
1863
+ }
1864
+ }
1865
+ /**
1866
+ * Rewrite `pageKey`'s body with `group`'s values, or report that this mutation
1867
+ * does not cover the page and leave it untouched.
1868
+ *
1869
+ * Covering means every record the body holds is one this mutation replaces, or
1870
+ * one a container already answers for, whose page copy is dead either way. A
1871
+ * partial rewrite would be the merge unit moving from the record to the page,
1872
+ * which is what patches exist to prevent.
1873
+ */
1874
+ function rewritePage(m, pageKey, group, batcher) {
1875
+ const page = plainEntry(m, pageKey);
1876
+ if (page === undefined)
1877
+ return false;
1878
+ // The RAW triples, not the memo's unpacked records: a triple carries either
1879
+ // a tag beside a body or one whole value, and `repackRecord` needs the
1880
+ // original to keep each one in the shape its readers expect.
1881
+ const held = decodePageRecords(page);
1882
+ if (held.length === 0)
1883
+ return false;
1884
+ const next = new Map(group);
1885
+ for (const packed of held) {
1886
+ if (next.has(packed[0]))
1887
+ continue;
1888
+ if (entryExists(m, overlayKeyFor(packed[0])))
1889
+ continue;
1890
+ if (entryExists(m, tombstoneKeyFor(packed[0])))
1891
+ continue;
1892
+ return false;
1893
+ }
1894
+ const triples = held.map((packed) => {
1895
+ const value = next.get(packed[0]);
1896
+ return value === undefined ? packed : repackRecord(packed, value);
1897
+ });
1898
+ const encoded = encodePage(triples);
1899
+ writePage(m, pageKey, encoded);
1900
+ // Charged by what the page PACKS rather than by the gzipped bytes it
1901
+ // occupies, so a record costs the same here as it costs assigned into an
1902
+ // unpaged map and one budget means one thing on every route.
1903
+ batcher?.recordChars(encoded.chars);
1904
+ return true;
1905
+ }
1906
+ // Write an encoded page into its container: the tag, the bodies, and the keys
1907
+ // they belong to. The two strings are set together because a reader zips them
1908
+ // by index, so a page carrying one without the other holds no records.
1909
+ function writePage(m, pageKey, encoded) {
1910
+ const container = overlayContainer(m, pageKey);
1911
+ container.set(TAG_FIELD, PAGE_TAG);
1912
+ container.set(BODY_FIELD, encoded.body);
1913
+ container.set(PAGE_KEYS_FIELD, encoded.keys);
1914
+ }
1915
+ /**
1916
+ * Remove the patches a whole-record write supersedes.
1917
+ *
1918
+ * A read folds patches on LAST, over whichever copy of the record the page and
1919
+ * container passes resolved, so a patch left behind by an earlier edit would
1920
+ * overwrite the record this write is putting there. Every site that makes a
1921
+ * record's own container authoritative (an edit, a re-add over a tombstone,
1922
+ * the tombstone itself) therefore clears them first.
1923
+ *
1924
+ * This races nothing. A patch key is never written twice, so a peer editing
1925
+ * the same record concurrently writes a key this cannot have seen, and that
1926
+ * patch survives to apply over the container, which is the merge the record
1927
+ * would have had anyway.
1928
+ */
1929
+ function dropPatches(m, patchIndex, key) {
1930
+ const keys = patchIndex?.keysFor(key);
1931
+ if (keys === undefined)
1932
+ return;
1933
+ for (const patchKey of keys)
1934
+ m.delete(patchKey);
1935
+ patchIndex.clear(key);
1936
+ }
1937
+ /**
1938
+ * Write an edit to a record a page already holds.
1939
+ *
1940
+ * A patch carries only the fields this edit changed, under a key nothing will
1941
+ * write again, so the record stays in its page and two peers changing
1942
+ * different fields of it both keep theirs. A whole-record write cannot do
1943
+ * either: it takes the record out of its page for good, and the second writer
1944
+ * overwrites the first.
1945
+ *
1946
+ * Removing a field is the one edit a patch cannot state, since a patch is
1947
+ * applied by overlaying its keys. That falls back to the whole-record write,
1948
+ * which is correct and merely gives up the packing for that record.
1949
+ *
1950
+ * A record this session has ALREADY patched takes that fallback too. Patches
1951
+ * accumulate where a container's keys are rewritten in place, so a record
1952
+ * edited over and over (one drag is many edits to one record) would grow the
1953
+ * document without bound. Making the first edit a patch and the second a
1954
+ * container bounds it at one patch per record: an entropy sweep that touches
1955
+ * every record once stays packed, and a hot record costs what it always did.
1956
+ * The FIRST edit is the one that has to be a patch, because that is the moment
1957
+ * two peers editing one record would otherwise mint a container at the same
1958
+ * key and lose one of the two.
1959
+ */
1960
+ function writeRecordEdit(m, pager, index, patchIndex, key, record, memo, batcher) {
1961
+ const previous = recordFromPage(m, key, memo, index, patchIndex);
1962
+ const patch = alreadyPatchedHere(patchIndex, key, pager.token)
1963
+ ? null
1964
+ : changedFields(previous, record);
1965
+ if (patch === null) {
1966
+ dropPatches(m, patchIndex, key);
1967
+ writeRecordContainer(m, key, record);
1968
+ index?.noteContained(key);
1969
+ batcher?.noteKeys([key]);
1970
+ batcher?.record(record);
1971
+ return;
1972
+ }
1973
+ if (Object.keys(patch).length === 0)
1974
+ return;
1975
+ const patchKey = pager.nextPatchKey(key, patchIndex);
1976
+ m.set(patchKey, toPlainDeep(patch));
1977
+ patchIndex?.note(patchKey, key);
1978
+ index?.noteSideEntries();
1979
+ batcher?.noteKeys([key]);
1980
+ batcher?.record(patch);
1981
+ }
1982
+ /** Whether this session already holds a patch on `key`. Another peer's patch
1983
+ * does not count: every peer's FIRST edit has to be a patch for two of them
1984
+ * editing one record to merge. */
1985
+ function alreadyPatchedHere(patchIndex, key, token) {
1986
+ const keys = patchIndex?.keysFor(key);
1987
+ if (keys === undefined)
1988
+ return false;
1989
+ for (const patchKey of keys) {
1990
+ if (parsePatchKey(patchKey)?.peer === token)
1991
+ return true;
1992
+ }
1993
+ return false;
1994
+ }
1995
+ /** The fields `next` changes, or null when this edit is not one a patch can
1996
+ * express: a field was removed, or either side is not an object, and a patch
1997
+ * is applied by overlaying field names. */
1998
+ function changedFields(previous, next) {
1999
+ if (!isPlainObject(previous) || !isPlainObject(next))
2000
+ return null;
2001
+ for (const field of Object.keys(previous)) {
2002
+ if (!(field in next))
2003
+ return null;
2004
+ }
2005
+ const patch = {};
2006
+ for (const [field, value] of Object.entries(next)) {
2007
+ const before = previous[field];
2008
+ if (before === value)
2009
+ continue;
2010
+ if (JSON.stringify(before) === JSON.stringify(value))
2011
+ continue;
2012
+ patch[field] = value;
2013
+ }
2014
+ return patch;
2015
+ }
2016
+ // Write one record beside the pages, under the key that shadows the page copy.
2017
+ // A record store's record is `{ type, params }`; an app's is whatever the app
2018
+ // assigned, and both are stored the same way here so the paged path carries an
2019
+ // app's own shape through unchanged.
2020
+ //
2021
+ // An OBJECT takes a container, field by field as PLAIN values: every field
2022
+ // stays a plain value rather than being promoted to a child container, which is
2023
+ // what keeps the RECORD the merge unit (see the proxy's plain-value
2024
+ // preservation). Any other value an app assigned (a number, a string, an
2025
+ // array) is stored as that value, because a container cannot hold one and
2026
+ // walking its entries into fields is what destroys it: `5` has none, so it
2027
+ // would store as an empty container, and `'hello'` has one per character.
2028
+ //
2029
+ // Takes back the record's delete marker, if it had one: this entry IS the
2030
+ // record being present. Every write that puts a record back clears the marker,
2031
+ // here and on the in-place edit of a record still under its own bare key,
2032
+ // because a marker outranks every copy of a record on a read, so a route that
2033
+ // left one would re-add a record nothing can read.
2034
+ function writeRecordContainer(m, recordKey, record) {
2035
+ // The RECORD's key, not the key it is written under. Both are strings, so a
2036
+ // caller that hands over the key it already computed writes the record one
2037
+ // prefix deeper, where every reader resolves a different record. The draft
2038
+ // refuses a reserved key at the write, so one arriving here is that mistake.
2039
+ assertUsableRecordKey(recordKey);
2040
+ clearTombstone(m, recordKey);
2041
+ const overlayKey = overlayKeyFor(recordKey);
2042
+ if (!isPlainObject(record)) {
2043
+ m.set(overlayKey, toPlainDeep(record));
2044
+ return;
2045
+ }
2046
+ const container = overlayContainer(m, overlayKey);
2047
+ for (const field of container.keys()) {
2048
+ if (!(field in record))
2049
+ container.delete(field);
2050
+ }
2051
+ for (const [field, value] of Object.entries(record)) {
2052
+ container.set(field, toPlainDeep(value));
2053
+ }
2054
+ }
2055
+ /**
2056
+ * One editing session's writer of dense pages, and the key index that finds
2057
+ * records in them.
2058
+ *
2059
+ * A page is one value, so two writers appending to the same page container
2060
+ * resolve last-writer-wins and one writer's records are lost. Each session
2061
+ * therefore writes only pages carrying its own token, and never rewrites one
2062
+ * it has sealed. Nothing is ever moved out of a container into a page, so no
2063
+ * write here can race a peer's edit of a record.
2064
+ */
2065
+ class SessionPager {
2066
+ _paging;
2067
+ _index;
2068
+ _patchIndex = new PatchIndex();
2069
+ // Whether the index has been built. A pager over an unpaged root never needs
2070
+ // one, so it stays unbuilt until something asks the map where a record is.
2071
+ _live = false;
2072
+ _token;
2073
+ _next = -1;
2074
+ constructor(_paging) {
2075
+ this._paging = _paging;
2076
+ this._token = _paging.sessionToken();
2077
+ this._index = new PagedKeyIndex(() => _paging.stateVersion());
2078
+ }
2079
+ /** The index, if it is being kept; null when nothing has needed it. */
2080
+ get liveIndex() {
2081
+ return this._live ? this._index : null;
2082
+ }
2083
+ /** How many times the page index has been rebuilt. */
2084
+ get indexRebuilds() {
2085
+ return this._index.rebuilds;
2086
+ }
2087
+ /** Build the index if it does not exist, and bring it up to date. */
2088
+ ensureIndex(m) {
2089
+ this._live = true;
2090
+ this._index.refresh(m);
2091
+ return this._index;
2092
+ }
2093
+ /** The sink a whole-store read feeds the index through, or null when the
2094
+ * index already answers for the map as it stands. Close it with
2095
+ * `endHarvest` once the read has walked every entry. */
2096
+ beginHarvest() {
2097
+ if (this._live && !this._index.stale())
2098
+ return null;
2099
+ this._live = true;
2100
+ return this._index.harvest();
2101
+ }
2102
+ /** Stamp the harvested index as current. */
2103
+ endHarvest() {
2104
+ this._index.stamp();
2105
+ }
2106
+ /** The patch lookup, brought up to date against the document. Always kept,
2107
+ * unlike the page index: it costs a walk of the keys with no page decode,
2108
+ * and a keyed read that skipped it would return the record's pre-edit copy.
2109
+ *
2110
+ * A WRITE takes this once, before its first write, and the `note` / `clear`
2111
+ * calls under it keep that one instance current. Taking it again per record
2112
+ * would rebuild per record, since the version advances as the mutation
2113
+ * writes. */
2114
+ patches(m) {
2115
+ this._patchIndex.refresh(m, this._paging.stateVersion());
2116
+ return this._patchIndex;
2117
+ }
2118
+ /** Whether the map already stores a record under `key`. Only meaningful
2119
+ * after `ensureIndex`. */
2120
+ holds(key) {
2121
+ return this._index.has(key);
2122
+ }
2123
+ /** Whether some page's body holds `key`, which stays true after the record
2124
+ * is deleted: a page cannot be rewritten to drop it. Only meaningful after
2125
+ * `ensureIndex`. */
2126
+ packedInPage(key) {
2127
+ return this._index.packedInPage(key);
2128
+ }
2129
+ /** This writer's token, which names the page and patch keys it may write. */
2130
+ get token() {
2131
+ return this._token;
2132
+ }
2133
+ /** A patch key for an edit to `recordKey`: this session's token, plus a
2134
+ * counter one above what the record already carries, so the fold applies
2135
+ * this edit over every patch the writer had merged.
2136
+ *
2137
+ * `patchIndex` is the same instance `patches` hands out, which the write
2138
+ * path keeps current as it goes; the counter has to come from it rather
2139
+ * than from a rebuild, since a patch this mutation just wrote is one this
2140
+ * one must still mint above. */
2141
+ nextPatchKey(recordKey, patchIndex) {
2142
+ const counter = (patchIndex ?? this._patchIndex).nextCounterFor(recordKey);
2143
+ return patchKeyFor(recordKey, this._token, counter);
2144
+ }
2145
+ /** Write as many of `records` as fill whole pages, and return how many were
2146
+ * written. A remainder shorter than one page is left to the caller, which
2147
+ * gives it a container each. */
2148
+ writePages(m, records, perPage, batcher) {
2149
+ const pages = Math.floor(records.length / perPage);
2150
+ if (pages === 0)
2151
+ return 0;
2152
+ for (let p = 0; p < pages; p += 1) {
2153
+ const slice = records.slice(p * perPage, (p + 1) * perPage);
2154
+ const key = this._mintPageKey(m);
2155
+ const encoded = encodePage(slice.map(([k, r]) => packWholeValue(k, r)));
2156
+ writePage(m, key, encoded);
2157
+ this._index.notePage(key, slice.map(([k]) => k));
2158
+ batcher?.noteKeys(slice.map(([k]) => k));
2159
+ // Charged by what the page PACKS rather than by the gzipped bytes it
2160
+ // occupies: this is the only charge a bulk add into an already-paged
2161
+ // store takes, so pricing it compressed would let the same records ride
2162
+ // one op here and several on the route that pages part-way through.
2163
+ batcher?.recordChars(encoded.chars);
2164
+ }
2165
+ return pages * perPage;
2166
+ }
2167
+ // The next unused page key for this token. Seeded above whatever the store
2168
+ // already holds, so a session that reopens a document cannot overwrite the
2169
+ // pages an earlier session under the same token sealed, and stepped past any
2170
+ // key already occupied by something that is not one of this session's pages.
2171
+ _mintPageKey(m) {
2172
+ if (this._next < 0) {
2173
+ let highest = -1;
2174
+ for (const key of m.keys()) {
2175
+ const parsed = parseSessionPageKey(key);
2176
+ if (parsed && parsed.token === this._token) {
2177
+ highest = Math.max(highest, parsed.index);
633
2178
  }
634
2179
  }
2180
+ this._next = highest + 1;
635
2181
  }
636
- // Add the overlay's survivors that hash to this page.
637
- for (const [key, rec] of overlay) {
638
- if (rec === tombstone)
639
- continue;
640
- if (pageKeyFor(key, pageCount) !== pageKey)
641
- continue;
642
- const r = rec;
643
- triples.push([key, String(r[TAG_FIELD] ?? ''), r[BODY_FIELD]]);
644
- }
645
- if (triples.length === 0) {
646
- if (existing !== undefined)
647
- m.delete(pageKey);
648
- continue;
2182
+ let key = sessionPageKey(this._token, this._next);
2183
+ while (m.get(key) !== undefined) {
2184
+ this._next += 1;
2185
+ key = sessionPageKey(this._token, this._next);
649
2186
  }
650
- const page = m.setContainer(pageKey, new LoroMap());
651
- page.set(TAG_FIELD, PAGE_TAG);
652
- page.set(BODY_FIELD, encodePageBody(triples));
2187
+ this._next += 1;
2188
+ return key;
653
2189
  }
654
2190
  }
2191
+ // The value a page holds of one record, which is what `sampleBodyBytes` weighs
2192
+ // to size a page. An app's map packs each record's whole stored value as its
2193
+ // triple's body (`packWholeValue`), so the record itself is that value.
2194
+ function pagedBodyOf(entry) {
2195
+ return entry[1];
2196
+ }
655
2197
  // A root anchor holds one container that a version checkout swaps in place
656
2198
  // (`rebind`). Between rebinds `resolve()` returns the same instance, so the
657
2199
  // per-container Loro subscription attaches to a stable identity. `LoroMap` and
658
- // `LoroList` roots share this; the kind is the type parameter.
2200
+ // `LoroMovableList` roots share this; the kind is the type parameter.
659
2201
  function rootAnchor(root) {
660
2202
  let current = root;
661
2203
  return {
@@ -695,6 +2237,12 @@ function childAnchor(parent, key, ctor) {
695
2237
  cached = v;
696
2238
  return cached;
697
2239
  }
2240
+ // A container of the OTHER kind reads as absent, and the handle this
2241
+ // read minted is one nothing keeps. A live subscription resolves on
2242
+ // every change to the document it hangs off, so a handle left undropped
2243
+ // here ratchets wasm memory for the length of the session.
2244
+ if (isContainer(v))
2245
+ v.free();
698
2246
  return null;
699
2247
  },
700
2248
  resolveOrCreate: () => {
@@ -707,15 +2255,109 @@ function childAnchor(parent, key, ctor) {
707
2255
  cached = v;
708
2256
  return cached;
709
2257
  }
2258
+ // The key may hold a PLAIN value rather than a container: that is how a
2259
+ // record body is stored above the paging threshold, and how a promoted
2260
+ // record is stored beside its pages. Taking a scoped handle there is a
2261
+ // request for finer granularity on that record, so the container is
2262
+ // seeded from the value it replaces. Creating an empty one instead
2263
+ // discards every field the record already had, which the caller cannot
2264
+ // see coming: it asked for a handle, not for a delete.
710
2265
  const created = p.setContainer(key, new ctor());
2266
+ if (created instanceof LoroMap && isPlainObject(v)) {
2267
+ assignPlain(created, v);
2268
+ }
2269
+ else if (created instanceof LoroMovableList && Array.isArray(v)) {
2270
+ assignList(created, v);
2271
+ }
711
2272
  cached = created;
712
2273
  return created;
713
2274
  },
2275
+ // The plain value at this key, when it holds one instead of a container.
2276
+ // A read through a scoped handle answers from this rather than reporting
2277
+ // the key empty, so `read()` agrees with what the parent map returns.
2278
+ plain: () => {
2279
+ const p = parent.resolve();
2280
+ if (!p)
2281
+ return undefined;
2282
+ const v = p.get(key);
2283
+ // A container is what `resolve` answers for, whatever kind it is, so
2284
+ // what this reports is a plain value or nothing. The handle the read
2285
+ // minted goes back either way (see `plainEntry`).
2286
+ if (isContainer(v)) {
2287
+ v.free();
2288
+ return undefined;
2289
+ }
2290
+ return v;
2291
+ },
714
2292
  invalidate: () => {
715
2293
  cached = null;
716
2294
  },
717
2295
  };
718
2296
  }
2297
+ /**
2298
+ * Keep a composed handle's per-container subscription bound to whatever
2299
+ * currently answers for its entry.
2300
+ *
2301
+ * Which container that is moves under the app. A map key deleted and written
2302
+ * again holds a NEW one, and over a paged record store an edit is parked
2303
+ * beside the pages first, so for a while none answers for the record at all.
2304
+ * Loro's subscribe is per-container, so a subscription bound once goes silent
2305
+ * on the record it was taken for while `read()` follows that record, and the
2306
+ * app keeps rendering what it last heard with nothing to correct it.
2307
+ *
2308
+ * `root` is the topmost container of the handle's chain, which the document
2309
+ * holds for its whole life, so every write that could reach this entry passes
2310
+ * through its subtree and no delete takes the watch down with it. A watch on
2311
+ * the nearest live ancestor cannot promise that: deleting that ancestor
2312
+ * strands the subscription on a dead container, including for the write that
2313
+ * puts the ancestor back.
2314
+ *
2315
+ * Each fire compares what answers for the entry now against what the
2316
+ * subscription is bound to, and on a difference hands the subscriber the whole
2317
+ * entry before re-binding, the sequence `_resync` runs for a source swap.
2318
+ * `catchUp` runs inside the root's fire, so the subscription `attach` returns
2319
+ * misses the batch that made the difference, which is why the whole entry goes
2320
+ * first.
2321
+ *
2322
+ * The print covers an entry no container answers for: its value still moves (a
2323
+ * page rewritten around it, an edit beside the pages, a delete marker), and
2324
+ * comparing it is the only way to see that. It is taken only while nothing is
2325
+ * bound, so an entry with a container of its own costs one cached resolve per
2326
+ * fire and no read.
2327
+ */
2328
+ function trackEntryContainer(root, spec) {
2329
+ if (!root)
2330
+ return () => { };
2331
+ let bound = spec.resolve();
2332
+ let print = bound === null ? spec.print(spec.read()) : '';
2333
+ let detach = bound === null ? () => { } : spec.attach(bound);
2334
+ const watch = root.subscribe(() => {
2335
+ const next = spec.resolve();
2336
+ if (next === bound) {
2337
+ // A bound container reports its own changes, so there is nothing to
2338
+ // compare and nothing to re-bind.
2339
+ if (next !== null)
2340
+ return;
2341
+ const value = spec.read();
2342
+ const current = spec.print(value);
2343
+ if (current === print)
2344
+ return;
2345
+ print = current;
2346
+ spec.catchUp(value);
2347
+ return;
2348
+ }
2349
+ detach();
2350
+ bound = next;
2351
+ const value = spec.read();
2352
+ print = next === null ? spec.print(value) : '';
2353
+ spec.catchUp(value);
2354
+ detach = next === null ? () => { } : spec.attach(next);
2355
+ });
2356
+ return () => {
2357
+ watch();
2358
+ detach();
2359
+ };
2360
+ }
719
2361
  // Shared per-container subscription bookkeeping for both handle kinds. Holds
720
2362
  // the active { callback, detach } records so a version checkout can re-fire
721
2363
  // and re-attach each against the swapped source, and a suspend / close can
@@ -725,12 +2367,75 @@ function childAnchor(parent, key, ctor) {
725
2367
  // dedup, a different shape: it models no per-listener external resource, and
726
2368
  // the Loro subscription each record must re-create on rebind is exactly
727
2369
  // that.)
2370
+ /** Run a callback the SDK owns, without letting it break the caller. */
2371
+ /** Report a subscriber that threw, then carry on. One app callback failing
2372
+ * must not break a registration or stop the other subscribers, but swallowing
2373
+ * it leaves an app that stalled inside its own callback with nothing in the
2374
+ * console to read. */
2375
+ function reportSubscriberThrow(err) {
2376
+ console.error('[Brass] subscriber threw:', err);
2377
+ }
2378
+ function fireQuietly(cb) {
2379
+ try {
2380
+ cb();
2381
+ }
2382
+ catch (err) {
2383
+ reportSubscriberThrow(err);
2384
+ }
2385
+ }
728
2386
  class SubscriptionRegistry {
729
2387
  _subs = new Set();
2388
+ _settled = new Set();
2389
+ // Whether a whole-container delivery is in progress. Always false while a
2390
+ // delivery is one synchronous call, and the reason `onSettled` is defined
2391
+ // against a delivery rather than against a call: when a delivery arrives in
2392
+ // several parts, registering mid-way waits for the end instead of reporting
2393
+ // a container the caller has only some of.
2394
+ _delivering = false;
2395
+ /**
2396
+ * Report each time this container has been delivered whole.
2397
+ *
2398
+ * Fires on registration, and again after every re-delivery: a version
2399
+ * checkout, a return to head, a hydrate. Repeating rather than one-shot is
2400
+ * what an app needs, since the work it hangs here (frame the camera, run the
2401
+ * first scan, decide whether seeding is safe) is owed again each time the
2402
+ * container is replaced under it. A one-shot signal is the bug four demos
2403
+ * hand-rolled, where a `firstFire` flag never re-arms and the camera never
2404
+ * frames the version the reader switched to.
2405
+ *
2406
+ * It does NOT fire when some OTHER subscriber registers: that delivery went
2407
+ * to one new subscriber, and says nothing to the callers already holding the
2408
+ * container.
2409
+ *
2410
+ * What it can claim is what this peer had converged to when the delivery
2411
+ * began. More always arrives afterwards.
2412
+ */
2413
+ onSettled(cb) {
2414
+ this._settled.add(cb);
2415
+ if (!this._delivering)
2416
+ fireQuietly(cb);
2417
+ return () => {
2418
+ this._settled.delete(cb);
2419
+ };
2420
+ }
2421
+ _settle() {
2422
+ // Copied before the walk: a callback that unsubscribes another would
2423
+ // otherwise remove it from the set mid-iteration, and an entry deleted
2424
+ // before it is reached is never visited.
2425
+ const listeners = Array.from(this._settled);
2426
+ for (const cb of listeners)
2427
+ fireQuietly(cb);
2428
+ }
730
2429
  // Fire the whole-container catch-up once, attach the per-container change
731
2430
  // subscription, track the record, and return an idempotent unsubscribe.
732
2431
  subscribe(cb, fireWhole, attach) {
733
- fireWhole(cb);
2432
+ this._delivering = true;
2433
+ try {
2434
+ fireWhole(cb);
2435
+ }
2436
+ finally {
2437
+ this._delivering = false;
2438
+ }
734
2439
  const rec = { cb, detach: attach(cb) };
735
2440
  this._subs.add(rec);
736
2441
  return () => {
@@ -741,30 +2446,70 @@ class SubscriptionRegistry {
741
2446
  // Re-fire every active subscriber with the whole current container and
742
2447
  // re-attach its change subscription to it (checkout / return-to-head).
743
2448
  resync(fireWhole, attach) {
744
- for (const rec of this._subs) {
745
- rec.detach();
746
- fireWhole(rec.cb);
747
- rec.detach = attach(rec.cb);
2449
+ this._delivering = true;
2450
+ try {
2451
+ for (const rec of this._subs) {
2452
+ rec.detach();
2453
+ fireWhole(rec.cb);
2454
+ rec.detach = attach(rec.cb);
2455
+ }
2456
+ }
2457
+ finally {
2458
+ this._delivering = false;
748
2459
  }
2460
+ // The container has been delivered whole again, which is the transition
2461
+ // `onSettled` exists to report.
2462
+ this._settle();
749
2463
  }
750
2464
  // Detach every active subscription and forget it (suspend / close).
751
2465
  teardown() {
752
2466
  for (const rec of this._subs)
753
2467
  rec.detach();
754
2468
  this._subs.clear();
2469
+ this._settled.clear();
2470
+ }
2471
+ }
2472
+ function sameStateVersion(a, b) {
2473
+ if (a.length !== b.length)
2474
+ return false;
2475
+ for (let i = 0; i < a.length; i += 1) {
2476
+ if (a[i].peer !== b[i].peer || a[i].counter !== b[i].counter) {
2477
+ return false;
2478
+ }
2479
+ }
2480
+ return true;
2481
+ }
2482
+ /**
2483
+ * Hide a handle's own fields from enumeration.
2484
+ *
2485
+ * A handle is a VIEW onto a container, not the container, so `Object.keys` on
2486
+ * one is a natural thing to reach for and it type-checks. Enumerable, it
2487
+ * answers with the handle's own fields, which look enough like record keys to
2488
+ * pass and are the same handful whatever the document holds, so the mistake
2489
+ * reads as a small document rather than as a mistake. Hidden, the answer is
2490
+ * empty, and the reader goes looking for `keys()`.
2491
+ *
2492
+ * Every field is declared with an initialiser, so they all exist by the time
2493
+ * this runs and a later assignment keeps the descriptor this sets.
2494
+ */
2495
+ function hideOwnFields(handle) {
2496
+ for (const key of Object.keys(handle)) {
2497
+ Object.defineProperty(handle, key, { enumerable: false });
755
2498
  }
756
2499
  }
757
2500
  class MapHandleImpl {
758
2501
  _anchor;
759
2502
  _owner;
760
- // The store's page count (0 when unpaged). `null` for handles that can
761
- // never be a record store's `instances` root (composed child handles, and
762
- // doc-less roots), which skip the paged paths entirely.
763
- _pageCount;
764
- // The paging scheme of the record store this root maps, read the same way
765
- // and at the same time as `_pageCount` (both off the active source's `meta`).
766
- // `null` for any handle that is never a record store's `instances` root.
767
- _pagingScheme;
2503
+ // How this root reaches its store's paging state. `null` for handles that
2504
+ // can never be a paged root (composed child handles, and doc-less roots),
2505
+ // which skip the paged paths entirely.
2506
+ _paging;
2507
+ // Writes this session's own dense pages and indexes the store's records.
2508
+ // Built on first use over a paged store and kept for the handle's life; see
2509
+ // `SessionPager`.
2510
+ _pager = null;
2511
+ // Memo for this handle's keyed reads over a paged store; see `PageMemo`.
2512
+ _pageMemo = new PageMemo();
768
2513
  _childMaps = new Map();
769
2514
  _childLists = new Map();
770
2515
  // Active subscriptions, tracked so a version checkout (which swaps the
@@ -775,80 +2520,117 @@ class MapHandleImpl {
775
2520
  // matching `_resync` to report the keys the source swap removed. Null
776
2521
  // outside a rebind cycle, so a bare `_resync` reports no removals.
777
2522
  _priorKeys = null;
778
- constructor(anchor, owner, pageCount = null, pagingScheme = null) {
2523
+ // The handle this one is composed off, or null for a root. A subscription
2524
+ // taken before the container exists watches up this chain for the write
2525
+ // that creates it (`_nearestLiveContainer`).
2526
+ _parent;
2527
+ constructor(anchor, owner, paging = null, parent = null) {
779
2528
  this._anchor = anchor;
780
2529
  this._owner = owner;
781
- this._pageCount = pageCount;
782
- this._pagingScheme = pagingScheme;
783
- }
784
- // Whether the map is a PAGED record store. Defined by `meta.page_count`
785
- // (set whenever the writer pages, never changed by a field edit), threaded
786
- // in as `_pageCount` and read in O(1). MUST stay O(1): it is on the hot
787
- // path of every `read` / `mutate` (twice per ordered-map edit), so scanning
788
- // the map to peek tags would make a single field edit O(file).
2530
+ this._paging = paging;
2531
+ this._parent = parent;
2532
+ hideOwnFields(this);
2533
+ }
2534
+ // The container at the top of this handle's composition chain: the root it
2535
+ // is composed off, which the document holds for its whole life (`getMap` is
2536
+ // get-or-create, and a root anchor always resolves). A composed handle's
2537
+ // subscription watches it rather than its nearest live ancestor, which a
2538
+ // delete can take away. Internal: `ListHandleImpl` walks the same chain from
2539
+ // its parent.
2540
+ _chainRoot() {
2541
+ return this._parent ? this._parent._chainRoot() : this._anchor.resolve();
2542
+ }
2543
+ // Whether the map is a PAGED record store. Defined by the root's own `meta`
2544
+ // marker (set once when the root is paged, never changed by a field edit),
2545
+ // read through the hooks in O(1). MUST stay O(1): it is on the hot path of every
2546
+ // `read` / `mutate` (twice per ordered-map edit), so scanning the map to
2547
+ // peek tags would make a single field edit O(file).
789
2548
  _isPaged() {
790
- return this._pageCount !== null && this._pageCount() > 0;
791
- }
792
- // Whether this build's page-key hash matches the scheme the store was
793
- // written under. A per-key `get` hashes the key to its page (`pageKeyFor`),
794
- // so a store written under a NEWER scheme (one that changed the hash) would
795
- // be looked up in the wrong page and read back as absent. When this is
796
- // false, the per-key read paths fall back to the hash-independent whole-store
797
- // flatten (O(file), only on this rare newer-scheme read) so `get` / `has`
798
- // agree with `read()` instead of silently reporting present records missing.
799
- // The edit path refuses such a store loudly (`_runMutate`); reads stay
800
- // best-effort. Absent / current-or-older scheme is a match.
801
- _pageHashMatchesStore() {
802
- return !this._pagingScheme || isPagingSchemeEditable(this._pagingScheme());
2549
+ return this._paging !== null && this._paging.isPaged();
2550
+ }
2551
+ // This handle's pager, over a store known to be paged. A page is filled in
2552
+ // creation order, so the index is what locates a record; it is refreshed
2553
+ // against the map here, and every entry point that resolves a record reads a
2554
+ // current one.
2555
+ _sessionPager(m) {
2556
+ this._pager ??= new SessionPager(this._paging);
2557
+ this._pager.ensureIndex(m);
2558
+ return this._pager;
2559
+ }
2560
+ // The plain object this handle's key holds, when it holds a value rather
2561
+ // than a container. A record body above the paging threshold is stored that
2562
+ // way, so a scoped handle onto one must read it rather than report the key
2563
+ // empty; the alternative is a handle that answers `{}` over data the parent
2564
+ // map returns in full.
2565
+ _plainBody() {
2566
+ const v = this._anchor.plain?.();
2567
+ return isPlainObject(v) ? v : undefined;
803
2568
  }
804
2569
  read() {
2570
+ this._owner.assertReadable();
805
2571
  const m = this._anchor.resolve();
806
2572
  if (!m)
807
- return {};
2573
+ return (this._plainBody() ?? {});
808
2574
  // A paged map flattens back to the flat lookup, so a read is identical
809
2575
  // paged or not.
810
2576
  if (this._isPaged())
811
- return flattenPagedMap(m);
2577
+ return this._readPaged(m);
812
2578
  return m.toJSON();
813
2579
  }
2580
+ // The whole-store read, which decodes every page. That walk answers the same
2581
+ // question a keyed read asks (which page holds which record), so the index
2582
+ // takes its answers from it and a `get` after a read costs one page rather
2583
+ // than the store.
2584
+ _readPaged(m) {
2585
+ this._pager ??= new SessionPager(this._paging);
2586
+ const sink = this._pager.beginHarvest();
2587
+ if (sink === null)
2588
+ return flattenPagedMap(m);
2589
+ const flat = flattenPagedMap(m, sink);
2590
+ this._pager.endHarvest();
2591
+ return flat;
2592
+ }
814
2593
  get(entryKey) {
2594
+ this._owner.assertReadable();
815
2595
  const m = this._anchor.resolve();
816
2596
  if (!m)
817
- return undefined;
2597
+ return this._plainBody()?.[entryKey];
818
2598
  if (this._isPaged()) {
819
- // A newer-scheme store hashes keys differently, so a per-page lookup
820
- // would miss present records; flatten instead (see `_pageHashMatchesStore`).
821
- if (!this._pageHashMatchesStore()) {
822
- return flattenPagedMap(m)[entryKey];
823
- }
824
- // Decode only the key's page, so a per-key `get` stays proportional to
825
- // one page.
826
- return recordFromPage(m, entryKey, this._pageCount());
2599
+ const pager = this._sessionPager(m);
2600
+ return recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, pager.patches(m));
827
2601
  }
828
- const v = m.get(entryKey);
829
- if (v === undefined)
830
- return undefined;
831
- if (v instanceof LoroMap)
832
- return v.toJSON();
833
- if (v instanceof LoroList)
834
- return v.toJSON();
835
- return v;
2602
+ return plainEntry(m, entryKey);
836
2603
  }
837
2604
  keys() {
2605
+ return [...this.liveKeySet()];
2606
+ }
2607
+ /**
2608
+ * The keys this map holds, as a set, without materialising a record.
2609
+ *
2610
+ * A paged store answers from the page index, which is built from each page's
2611
+ * key list and never decodes a body, so asking which keys exist costs a
2612
+ * fraction of asking what they hold. Everything that needs membership rather
2613
+ * than content goes through here.
2614
+ */
2615
+ liveKeySet() {
2616
+ this._owner.assertReadable();
838
2617
  const m = this._anchor.resolve();
839
2618
  if (!m)
840
- return [];
2619
+ return new Set(Object.keys(this._plainBody() ?? {}));
841
2620
  if (this._isPaged())
842
- return Object.keys(flattenPagedMap(m));
843
- return m.keys();
2621
+ return this._sessionPager(m).ensureIndex(m).liveKeys();
2622
+ return new Set(m.keys());
844
2623
  }
845
2624
  has(entryKey) {
2625
+ this._owner.assertReadable();
846
2626
  const m = this._anchor.resolve();
847
- if (!m)
848
- return false;
2627
+ if (!m) {
2628
+ const body = this._plainBody();
2629
+ return body !== undefined && Object.prototype.hasOwnProperty.call(body, entryKey);
2630
+ }
849
2631
  if (this._isPaged())
850
2632
  return this.get(entryKey) !== undefined;
851
- return m.get(entryKey) !== undefined;
2633
+ return entryExists(m, entryKey);
852
2634
  }
853
2635
  mutate(fn) {
854
2636
  this._owner.assertWritable();
@@ -865,46 +2647,338 @@ class MapHandleImpl {
865
2647
  // edit land in ONE commit (one undo step).
866
2648
  _runMutate(fn, batcher) {
867
2649
  const m = this._anchor.resolveOrCreate();
2650
+ this._pageIfOutgrown(m);
868
2651
  if (this._isPaged()) {
869
- // A store written under a paging scheme this build does not implement
870
- // would be mis-hashed by `pageKeyFor`, so an in-place edit would write a
871
- // record into the wrong page. Refuse loudly rather than corrupt it; the
872
- // read path stays best-effort (only a persisted edit can corrupt).
873
- if (this._pagingScheme && !isPagingSchemeEditable(this._pagingScheme())) {
874
- throw new BrassError('This document was saved in a newer storage format. Update this app to the latest @brass-build/client to edit it.', { status: 426 });
875
- }
876
- mutatePaged(m, this._pageCount(), fn);
2652
+ const pager = this._sessionPager(m);
2653
+ mutatePaged(m, fn, pager, batcher);
877
2654
  return;
878
2655
  }
879
- const proxy = wrapLoroMapProxy(m, batcher);
2656
+ const divert = this._paging ? this._pagingDivert(m, batcher) : null;
2657
+ const proxy = wrapLoroMapProxy(m, batcher, divert?.hooks, this._paging !== null);
880
2658
  fn(proxy);
2659
+ divert?.finish();
2660
+ }
2661
+ // Route this mutation's ADDITIONS into pages from the moment the map
2662
+ // outgrows the threshold, rather than only from the next mutation.
2663
+ //
2664
+ // One `mutate` is allowed to be as large as an app likes (it commits as it
2665
+ // goes past a size budget), so the mutation that fills a map is routinely
2666
+ // the same one that pushes it past the threshold, and a map checked only on
2667
+ // the way in would never page at all for an app that writes in one call.
2668
+ //
2669
+ // Only additions divert. A key the map already stores is an edit of a record
2670
+ // this writer does not own, and moving it would mean deleting a container a
2671
+ // peer may be writing to at that moment.
2672
+ _pagingDivert(m, batcher) {
2673
+ // Additions waiting to be sealed into a page, in creation order. A page
2674
+ // packs whatever value a record is, and `writeRecordContainer` writes an
2675
+ // object as fields and anything else as the value it is, so a buffer here
2676
+ // carries the app's value through whichever of the two it ends up taking.
2677
+ const held = new Map();
2678
+ // Additions taken before this mutation is known to be bulk. They are held
2679
+ // rather than written as a container each, because a container written now
2680
+ // is one this write can never take back: nothing already stored moves, so
2681
+ // the first `UNPAGED_MAX_RECORDS` records of a bulk write would stay
2682
+ // containers for the life of the document. Holding them costs their size
2683
+ // until the decision and lets the whole write be paged.
2684
+ //
2685
+ // Holding is not the fold that is unsafe. A record created inside a
2686
+ // mutation that is still running has never been visible to a peer, so
2687
+ // nothing is deleted and nothing is raced; the choice is only which shape
2688
+ // to write it in.
2689
+ const pending = new Map();
2690
+ // Additions that must take a container of their own instead: a key this
2691
+ // mutation deleted out of a sealed page keeps its tombstone (the page
2692
+ // cannot be rewritten to drop the old copy), and the container is what
2693
+ // outranks both. Paging it again would leave two pages claiming the key.
2694
+ const contained = new Map();
2695
+ // Keys the mutation deleted that a sealed page still holds. A page cannot
2696
+ // be rewritten to drop one, so each becomes a tombstone at the end.
2697
+ const removed = new Set();
2698
+ const memo = new PageMemo();
2699
+ let pager = null;
2700
+ // The record count this mutation will leave behind: what the map holds
2701
+ // plus what is held out of it. Counting only the map would never reach the
2702
+ // threshold once records are held rather than written.
2703
+ const totalRecords = () => m.size + pending.size + held.size;
2704
+ const startPaging = () => {
2705
+ if (pager)
2706
+ return true;
2707
+ if (totalRecords() <= UNPAGED_MAX_RECORDS)
2708
+ return false;
2709
+ this._paging.page();
2710
+ pager = this._sessionPager(m);
2711
+ // Built while the map is still all containers, so this is a walk of the
2712
+ // keys rather than a decode of every page.
2713
+ pager.ensureIndex(m);
2714
+ // Everything held back before the threshold was crossed goes into pages
2715
+ // with the rest, which is the whole point of having held it.
2716
+ for (const [key, record] of pending)
2717
+ held.set(key, record);
2718
+ pending.clear();
2719
+ return true;
2720
+ };
2721
+ // A record this mutation already sealed into a page. Once sealed it is no
2722
+ // longer in `held` and not in the live map either, so without this the
2723
+ // mutation's own later read of it comes back undefined and its own later
2724
+ // delete of it silently does nothing.
2725
+ const sealedRecord = (key) => {
2726
+ const index = pager?.liveIndex;
2727
+ const pageKey = index?.readsFromPage(key);
2728
+ if (pageKey === undefined)
2729
+ return undefined;
2730
+ return memo.resolve(pageKey, plainEntry(m, pageKey)).get(key);
2731
+ };
2732
+ // Seal whole pages out of what is held, leaving any remainder for the next
2733
+ // boundary. Runs at each commit the batcher takes, so a mutation of any
2734
+ // size ships its pages as it goes instead of as one op at the end.
2735
+ const sealPages = () => {
2736
+ if (!pager || held.size === 0)
2737
+ return;
2738
+ const records = [...held];
2739
+ const perPage = recordsPerPage(sampleBodyBytes(records, pagedBodyOf));
2740
+ const written = pager.writePages(m, records, perPage);
2741
+ const sealed = [];
2742
+ for (let i = 0; i < written; i += 1) {
2743
+ sealed.push(records[i][0]);
2744
+ held.delete(records[i][0]);
2745
+ }
2746
+ // Named to the commit this seal runs just ahead of, which is what lets
2747
+ // an ordered map append the order entries for these records alongside
2748
+ // them instead of appending the whole list at the first commit. The
2749
+ // batcher is not passed to `writePages` above, since charging a page
2750
+ // there would take a commit inside a commit.
2751
+ batcher?.noteKeys(sealed);
2752
+ };
2753
+ batcher?.onFlush(sealPages);
2754
+ const hooks = {
2755
+ take: (key, value) => {
2756
+ // A key this mutation is already buffering keeps its buffer, whatever
2757
+ // shape the new value is. Declining it would write to the map behind
2758
+ // the buffer, which `finish` would then overwrite with the value the
2759
+ // buffer still held.
2760
+ if (held.has(key)) {
2761
+ held.set(key, toPlainDeep(value));
2762
+ return true;
2763
+ }
2764
+ if (contained.has(key)) {
2765
+ contained.set(key, toPlainDeep(value));
2766
+ return true;
2767
+ }
2768
+ if (pending.has(key)) {
2769
+ pending.set(key, toPlainDeep(value));
2770
+ return true;
2771
+ }
2772
+ // An existing key is an edit, and an edit keeps whatever shape the
2773
+ // record already has, so it is never held.
2774
+ if (m.get(key) !== undefined)
2775
+ return false;
2776
+ if (!startPaging()) {
2777
+ // Only a RECORD is held. A map entry that is a scalar, a string, an
2778
+ // array, or a container is not what paging packs, and holding one
2779
+ // would hand `finish` a value it can only write as a record: a
2780
+ // boolean comes back as an empty container. Those keep the write
2781
+ // they have always had.
2782
+ if (!isPlainObject(value))
2783
+ return false;
2784
+ // Undecided: hold the addition and re-ask, since this one may be
2785
+ // what crosses the threshold.
2786
+ pending.set(key, toPlainDeep(value));
2787
+ startPaging();
2788
+ return true;
2789
+ }
2790
+ // A key this mutation already sealed into a page, or deleted out of
2791
+ // one, takes a container of its own. The page keeps its copy either
2792
+ // way, so declining the write would leave the record stored twice: the
2793
+ // map's own key beside the page's stale copy, which a keyed read and a
2794
+ // streaming read then answer differently.
2795
+ if (pager.packedInPage(key) || removed.has(key)) {
2796
+ contained.set(key, toPlainDeep(value));
2797
+ return true;
2798
+ }
2799
+ // A container already answers for this key (a record written before
2800
+ // the crossing), so the write goes into it as an ordinary edit.
2801
+ if (pager.holds(key))
2802
+ return false;
2803
+ held.set(key, toPlainDeep(value));
2804
+ return true;
2805
+ },
2806
+ holds: (key) => {
2807
+ if (held.has(key) || contained.has(key) || pending.has(key))
2808
+ return true;
2809
+ if (removed.has(key))
2810
+ return false;
2811
+ return sealedRecord(key) !== undefined;
2812
+ },
2813
+ peek: (key) => {
2814
+ if (pending.has(key))
2815
+ return pending.get(key);
2816
+ if (held.has(key))
2817
+ return held.get(key);
2818
+ if (contained.has(key))
2819
+ return contained.get(key);
2820
+ return removed.has(key) ? undefined : sealedRecord(key);
2821
+ },
2822
+ drop: (key) => {
2823
+ held.delete(key);
2824
+ pending.delete(key);
2825
+ contained.delete(key);
2826
+ if (sealedRecord(key) !== undefined)
2827
+ removed.add(key);
2828
+ },
2829
+ keys: () => {
2830
+ const keys = new Set([
2831
+ ...held.keys(),
2832
+ ...pending.keys(),
2833
+ ...contained.keys(),
2834
+ ]);
2835
+ for (const key of removed) {
2836
+ if (!contained.has(key))
2837
+ keys.delete(key);
2838
+ }
2839
+ return [...keys];
2840
+ },
2841
+ };
2842
+ return {
2843
+ hooks,
2844
+ finish: () => {
2845
+ // The mutation ended below the threshold, so the store stays unpaged
2846
+ // and these take exactly the shape the undiverted write gives them: a
2847
+ // container under their own key, filled by `assignPlain`, which is
2848
+ // what makes a nested object a container a scoped handle can anchor
2849
+ // on. `writeRecordContainer` writes fields as plain values instead,
2850
+ // so a record written through it has no `props` container for
2851
+ // `handle.map('props')` to reach.
2852
+ //
2853
+ // Charged HERE rather than where the draft took them, because holding
2854
+ // is what decides that a record's bytes land in this commit and not an
2855
+ // earlier one: a write charged on the way in commits its neighbours
2856
+ // while its own value is still held, so a mutation that holds
2857
+ // everything would spend its boundaries on empty commits and then put
2858
+ // every record in the one op at the end.
2859
+ for (const [key, record] of pending) {
2860
+ // Only an OBJECT takes a container filled field by field. A buffered
2861
+ // key is re-taken whatever shape the new value is, so a rewrite can
2862
+ // leave a string, a number, or an array here, and `assignPlain`
2863
+ // walks a value's entries into fields: it would store `'hello'` as
2864
+ // one field per character and `5` as an empty container, losing what
2865
+ // the app assigned. `assignEntry` is the same write the map makes
2866
+ // for a key the divert declined, so the two shapes agree.
2867
+ if (isPlainObject(record)) {
2868
+ const existing = m.get(key);
2869
+ const sub = existing instanceof LoroMap
2870
+ ? existing
2871
+ : m.setContainer(key, new LoroMap());
2872
+ assignPlain(sub, record);
2873
+ }
2874
+ else {
2875
+ assignEntry(m, key, record);
2876
+ }
2877
+ batcher?.noteKeys([key]);
2878
+ batcher?.record(record);
2879
+ }
2880
+ pending.clear();
2881
+ sealPages();
2882
+ // Every write below is charged the same way the draft's own
2883
+ // assignments are, because the charge the draft took paid for BUFFERING
2884
+ // the value, not for writing it. These loops run after the last
2885
+ // boundary the mutation took, so a route that only names its keys puts
2886
+ // its whole write in the final commit however much it holds:
2887
+ // `contained` grows with the records a mutation rewrites after sealing
2888
+ // them, and at 50,000 that op reaches 12 MiB, past what the document
2889
+ // can store at all.
2890
+ //
2891
+ // The remainder is taken out of `held` first, so a commit these charges
2892
+ // trigger runs `sealPages` over an empty buffer rather than over the
2893
+ // map this loop is walking.
2894
+ const remainder = [...held];
2895
+ held.clear();
2896
+ // A remainder shorter than a page takes a container each, which is
2897
+ // what it would have had anyway.
2898
+ for (const [key, record] of remainder) {
2899
+ batcher?.record(record);
2900
+ writeRecordContainer(m, key, record);
2901
+ pager.liveIndex?.noteContained(key);
2902
+ batcher?.noteKeys([key]);
2903
+ }
2904
+ for (const key of removed) {
2905
+ if (contained.has(key))
2906
+ continue;
2907
+ batcher?.recordDelete(key);
2908
+ markDeleted(m, key);
2909
+ pager.liveIndex?.noteRemoved(key);
2910
+ batcher?.noteKeys([key]);
2911
+ }
2912
+ removed.clear();
2913
+ for (const [key, record] of contained) {
2914
+ batcher?.record(record);
2915
+ writeRecordContainer(m, key, record);
2916
+ pager.liveIndex?.noteContained(key);
2917
+ batcher?.noteKeys([key]);
2918
+ }
2919
+ contained.clear();
2920
+ },
2921
+ };
2922
+ }
2923
+ // Page this root once it has outgrown the one-container-per-record shape.
2924
+ //
2925
+ // The cost of reading a map back rises faster than its entry count (measured
2926
+ // at ~12us an entry at 12,500 entries and ~145us at 200,000), so a map an app
2927
+ // keeps filling eventually costs minutes to read on the reload after it is
2928
+ // written. Paging is what holds the entry count down, and an app should not
2929
+ // have to ask for it, so the threshold is checked here: on the write that
2930
+ // crosses it, the root is stamped paged and everything added from then on is
2931
+ // written into pages.
2932
+ //
2933
+ // Nothing already stored moves. A record in its own container reads as a
2934
+ // stray, which already outranks a page, so leaving them costs one entry each
2935
+ // and no correctness. Moving them would mean deleting containers a peer may
2936
+ // be editing at that moment, and a delete concurrent with an edit discards
2937
+ // the edit.
2938
+ _pageIfOutgrown(m) {
2939
+ if (!this._paging || this._isPaged())
2940
+ return;
2941
+ if (m.size <= UNPAGED_MAX_RECORDS)
2942
+ return;
2943
+ this._paging.page();
881
2944
  }
882
- // Paged writes defer to `applyOverlay` at the end of the mutation, so an
883
- // intermediate commit there would seal no records (and, on an ordered
884
- // map, order entries for records not yet written). Their ops are bounded
885
- // by page size anyway.
886
- _canBatchMutate() {
887
- return !this._isPaged();
888
- }
889
- // A predicate over the map's CURRENT stored key set, resolved ONCE so a
890
- // caller checking many keys doesn't re-scan per key. The ordered-map
891
- // primitive uses it to decide, per set, whether a key is new (and so must
892
- // be appended to the order list) without paying O(map) per added key.
2945
+ /** @internal How many times this handle's page index has been rebuilt, for a
2946
+ * test to pin that an ordinary edit does not force one. A rebuild decodes
2947
+ * every page, so the count is the difference between an edit costing a
2948
+ * record and one costing the store. */
2949
+ _pageIndexRebuilds() {
2950
+ return this._pager?.indexRebuilds ?? 0;
2951
+ }
2952
+ // A predicate over the key set the map held when this was taken, resolved
2953
+ // ONCE so a caller checking many keys doesn't re-scan per key. The
2954
+ // ordered-map primitive uses it to decide, per set, whether a key is new
2955
+ // (and so must be appended to the order list) without paying O(map) per
2956
+ // added key.
2957
+ //
2958
+ // "When this was taken" is the whole contract, and it is why the unpaged
2959
+ // branch copies the key set rather than asking the live map per call. A
2960
+ // mutation's own writes reach that map as they are made, so a live predicate
2961
+ // reports a key by what this batch has done to it so far: a key deleted and
2962
+ // re-added reads absent and is appended a second time, and a key added
2963
+ // through the paging divert (which holds additions back) reads absent even
2964
+ // after the batch has written it. The caller tracks the batch's own
2965
+ // additions and removals on top of a start-of-batch answer, which is what
2966
+ // the paged branch already gives it.
893
2967
  _existsChecker() {
894
2968
  const m = this._anchor.resolve();
895
2969
  if (!m)
896
2970
  return () => false;
897
2971
  if (this._isPaged()) {
898
- // Newer-scheme store: the per-page hash would miss records, so resolve
899
- // the whole flattened key set once (see `_pageHashMatchesStore`).
900
- if (!this._pageHashMatchesStore()) {
901
- const flat = flattenPagedMap(m);
902
- return (key) => Object.prototype.hasOwnProperty.call(flat, key);
903
- }
904
- const pageCount = this._pageCount();
905
- return (key) => recordFromPage(m, key, pageCount) !== undefined;
2972
+ // The index answers from one walk of the pages' key lists instead of a
2973
+ // page decode per key, which is what keeps a bulk insert through the
2974
+ // ordered map off an O(keys x page) path.
2975
+ const index = this._sessionPager(m).ensureIndex(m);
2976
+ return (key) => index.has(key);
906
2977
  }
907
- return (key) => m.get(key) !== undefined;
2978
+ // One walk of an unpaged map, whose size the paging threshold bounds, and
2979
+ // no wasm handle minted per key the way a per-key read would.
2980
+ const keys = new Set(m.keys());
2981
+ return (key) => keys.has(key);
908
2982
  }
909
2983
  // Fire a subscriber once with the whole map as `change.set`, so a
910
2984
  // subscriber is ONE loop with no initial-vs-update branch and no
@@ -918,46 +2992,108 @@ class MapHandleImpl {
918
2992
  // source swap but are gone after it: a checkout to a version with fewer
919
2993
  // records must tell a delta-reconcile subscriber to drop those keys, or they
920
2994
  // linger as phantom records in the preview. `_resync` computes it.
921
- _fireWhole(cb, deleted = []) {
2995
+ // `snapshot` is the map as a caller has ALREADY read it, so a tracker that
2996
+ // read the entry to decide whether to fire does not read it a second time to
2997
+ // fire it.
2998
+ _fireWhole(cb, deleted = [], snapshot) {
922
2999
  try {
923
- cb({ set: new Map(Object.entries(this.read())), deleted }, undefined, true);
3000
+ cb({
3001
+ set: new Map(Object.entries(snapshot ?? this.read())),
3002
+ deleted,
3003
+ }, undefined, true);
924
3004
  }
925
- catch {
926
- // A throwing subscriber must not break registration / resync.
3005
+ catch (err) {
3006
+ reportSubscriberThrow(err);
927
3007
  }
928
3008
  }
929
- // Attach the per-container change subscription for `cb` to the CURRENT
930
- // resolved container, returning its detach. The container instance is
931
- // captured here (Loro's subscribe is per-container), so a source swap
932
- // re-runs this against the new container.
3009
+ // Attach the change subscription for `cb`, returning its detach.
3010
+ //
3011
+ // A ROOT handle binds straight to its container: that container is the
3012
+ // document's for its whole life, so nothing can move out from under the
3013
+ // subscription except a source swap, which `_resync` re-runs this for.
3014
+ //
3015
+ // A COMPOSED handle's entry is not that stable. Which container answers for
3016
+ // it changes as the entry is deleted and written again, and over a paged
3017
+ // record store it can be nothing at all while an edit sits beside the pages,
3018
+ // so its subscription tracks the entry instead of binding once (see
3019
+ // `trackEntryContainer`).
933
3020
  _attachChange(cb) {
934
- const m = this._anchor.resolve();
935
- // If the container doesn't exist yet (a composed handle for a missing
936
- // entry), only the whole-fire happens; the change subscription attaches
937
- // once a later `doc.map(key)` / `mutate` materializes it. Root handles
938
- // are always materialized (`getMap` is get-or-create).
939
- if (!m)
940
- return () => { };
3021
+ if (this._parent === null) {
3022
+ const m = this._anchor.resolve();
3023
+ return m === null ? () => { } : this._subscribeContainer(m, cb);
3024
+ }
3025
+ // The keys the subscriber has been told this entry holds, folded from
3026
+ // every change it has been handed. A re-bind names the ones the container
3027
+ // now answering does not carry: a subscriber folds `set` over `deleted`,
3028
+ // so a key it is never told is gone stays in its model and renders as a
3029
+ // field of a record that no longer has it.
3030
+ const known = new Set(Object.keys(this.read()));
3031
+ const fold = (change, origin, resync) => {
3032
+ for (const key of change.deleted)
3033
+ known.delete(key);
3034
+ for (const key of change.set.keys())
3035
+ known.add(key);
3036
+ cb(change, origin, resync);
3037
+ };
3038
+ return trackEntryContainer(this._chainRoot(), {
3039
+ resolve: () => this._anchor.resolve(),
3040
+ read: () => this.read(),
3041
+ print: (value) => JSON.stringify(value),
3042
+ catchUp: (value) => {
3043
+ const deleted = [...known].filter((key) => !(key in value));
3044
+ this._fireWhole(fold, deleted, value);
3045
+ },
3046
+ attach: (m) => this._subscribeContainer(m, fold),
3047
+ });
3048
+ }
3049
+ // Subscribe `cb` to one container's own changes, returning its detach. The
3050
+ // instance is captured here (Loro's subscribe is per-container), so
3051
+ // whatever re-binds calls this again against the container it re-binds to.
3052
+ _subscribeContainer(m, cb) {
941
3053
  // Paged delta needs prior page state to spot deleted records, so index
942
3054
  // the pages once now; the index advances on every fire or its next delete
943
3055
  // diff is wrong. Unpaged maps read their delta off the Loro diff.
944
- const pageIndex = this._isPaged() ? buildPageIndex(m) : null;
945
- const rootKey = rootKeyOf(m);
3056
+ let index = this._isPaged() ? buildPagedDeltaIndex(m) : null;
3057
+ const depth = containerDepth(m);
946
3058
  const sub = m.subscribe((event) => {
947
- const change = (pageIndex
948
- ? pagedMapDelta(m, event, pageIndex)
949
- : unpagedMapDelta(m, rootKey, event));
3059
+ // Which reading the delta takes is decided per BATCH, not once at
3060
+ // attach: a store crosses the paging threshold while its subscriptions
3061
+ // are live, and one attached before the crossing would otherwise keep
3062
+ // reporting the map's raw keys, naming pages and overlay slots as if
3063
+ // they were records and never naming a record added after it.
3064
+ //
3065
+ // The index holds the pages this subscriber has already been told
3066
+ // about, so a subscription that has been reading an unpaged store
3067
+ // starts from none: while the store was unpaged there were no pages,
3068
+ // and the batch that pages it names every page it creates. What the
3069
+ // store answers for from outside its pages is read off the map here,
3070
+ // because a store that paged remotely can arrive already carrying edits
3071
+ // and deletes this subscription has never seen a key for.
3072
+ if (index === null && this._isPaged()) {
3073
+ index = {
3074
+ pages: new Map(),
3075
+ shadowed: shadowedRecords(m),
3076
+ };
3077
+ }
3078
+ const change = (index
3079
+ ? pagedMapDelta(m, event, index, (key) => this.get(key))
3080
+ : unpagedMapDelta(m, depth, event));
950
3081
  const origin = { source: this._owner.originOf(event) };
951
3082
  try {
952
3083
  cb(change, origin, false);
953
3084
  }
954
- catch {
955
- // A throwing subscriber must not affect the others.
3085
+ catch (err) {
3086
+ reportSubscriberThrow(err);
956
3087
  }
957
3088
  });
958
3089
  return () => sub();
959
3090
  }
3091
+ onSettled(cb) {
3092
+ this._owner.assertReadable();
3093
+ return this._registry.onSettled(cb);
3094
+ }
960
3095
  subscribe(cb) {
3096
+ this._owner.assertReadable();
961
3097
  // App subscribers see `(change, origin?)`; the internal re-sync flag is
962
3098
  // dropped here so it never leaks into the public surface.
963
3099
  return this._subscribeInternal((change, origin) => cb(change, origin));
@@ -981,11 +3117,21 @@ class MapHandleImpl {
981
3117
  this._anchor.rebind(next);
982
3118
  }
983
3119
  // Capture the current key set of this handle and its composed child maps,
984
- // read against the CURRENT (pre-rebind) source, so `_resync` can diff it
985
- // against the post-rebind source to find removed keys. Runs before the
986
- // anchor moves; child maps resolve through this anchor, so they must be read
987
- // now too.
3120
+ // read against the source the handle currently describes, so `_resync` can
3121
+ // diff it against the post-rebind source to find removed keys. Child maps
3122
+ // resolve through this anchor, so they must be read now too.
3123
+ //
3124
+ // Reading keys depends on whether the store is paged, and a handle answers
3125
+ // that from the Document's ACTIVE source rather than from its own anchor, so
3126
+ // a correct snapshot needs both to still name the old source. The two move
3127
+ // separately across a checkout (the Document repoints the active source, then
3128
+ // rebinds the anchors), which is why `_snapshotRootHandleKeys` takes the
3129
+ // snapshot before EITHER moves and this is idempotent: the `_rebindAnchor`
3130
+ // call is the backstop for a rebind that arrives without that phase, and must
3131
+ // not overwrite a snapshot already taken against the right source.
988
3132
  _snapshotPriorKeys() {
3133
+ if (this._priorKeys !== null)
3134
+ return;
989
3135
  this._priorKeys = new Set(Object.keys(this.read()));
990
3136
  for (const child of this._childMaps.values())
991
3137
  child._snapshotPriorKeys();
@@ -1002,6 +3148,9 @@ class MapHandleImpl {
1002
3148
  // container, and after a source swap that cache points at the old source.
1003
3149
  // Prior keys were snapshotted pre-rebind, so this cannot lose them.
1004
3150
  this._anchor.invalidate();
3151
+ // The index describes the container set of ONE source, and a checkout /
3152
+ // return-to-head swaps it.
3153
+ this._pager = null;
1005
3154
  const prior = this._priorKeys;
1006
3155
  this._priorKeys = null;
1007
3156
  const currentKeys = new Set(Object.keys(this.read()));
@@ -1029,8 +3178,7 @@ class MapHandleImpl {
1029
3178
  map(entryKey) {
1030
3179
  let h = this._childMaps.get(entryKey);
1031
3180
  if (!h) {
1032
- const anchor = childAnchor(this._anchor, entryKey, LoroMap);
1033
- h = new MapHandleImpl(anchor, this._owner);
3181
+ h = new MapHandleImpl(this._entryAnchor(entryKey, LoroMap), this._owner, null, this);
1034
3182
  this._childMaps.set(entryKey, h);
1035
3183
  }
1036
3184
  return h;
@@ -1038,70 +3186,298 @@ class MapHandleImpl {
1038
3186
  list(entryKey) {
1039
3187
  let h = this._childLists.get(entryKey);
1040
3188
  if (!h) {
1041
- const anchor = childAnchor(this._anchor, entryKey, LoroList);
1042
- h = new ListHandleImpl(anchor, this._owner);
3189
+ h = new ListHandleImpl(this._entryAnchor(entryKey, LoroMovableList), this._owner, this);
1043
3190
  this._childLists.set(entryKey, h);
1044
3191
  }
1045
3192
  return h;
1046
3193
  }
3194
+ /**
3195
+ * The anchor a scoped handle onto one ENTRY of this map reads and writes
3196
+ * through.
3197
+ *
3198
+ * On a root that can be a record store the entry is a RECORD, and where a
3199
+ * record is stored is a property of the store's size: above the paging
3200
+ * threshold it is packed into a page rather than held under its own key. An
3201
+ * anchor that resolves `parent[entryKey]` therefore finds nothing on a large
3202
+ * store, and creating a container there mints an EMPTY record that outranks
3203
+ * the page's copy, so `records.map(id)` destroys the record it was asked
3204
+ * for. The paged anchor below resolves through the same presentation every
3205
+ * other reader uses, and writes by taking the record over into a container
3206
+ * of its own beside the pages.
3207
+ *
3208
+ * A composed child (a record's `params`) holds fields rather than records
3209
+ * and takes the plain anchor: `_paging` is null there, so nothing below a
3210
+ * root ever reaches the paged path.
3211
+ */
3212
+ _entryAnchor(entryKey, ctor) {
3213
+ // Where a record written before the store paged still sits, and where a
3214
+ // record taken over by an edit goes. Both cache their resolved container,
3215
+ // so a per-container subscription keeps a stable instance across reads.
3216
+ const direct = childAnchor(this._anchor, entryKey, ctor);
3217
+ if (this._paging === null)
3218
+ return direct;
3219
+ assertUsableRecordKey(entryKey);
3220
+ const overlay = childAnchor(this._anchor, overlayKeyFor(entryKey), ctor);
3221
+ // Whether an edit written beside the pages is waiting to be folded over
3222
+ // this record. Asked only once a container has been found, since a record
3223
+ // with no container of its own is answered from the flattened value
3224
+ // either way. The patch index is a walk of the map's key list, so this
3225
+ // takes the pager WITHOUT the page index: learning which page holds each
3226
+ // record reads every page's key list, which no answer here needs.
3227
+ const patched = (m) => {
3228
+ this._pager ??= new SessionPager(this._paging);
3229
+ const keys = this._pager.patches(m).keysFor(entryKey);
3230
+ return keys !== undefined && keys.length > 0;
3231
+ };
3232
+ return {
3233
+ resolve: () => {
3234
+ if (!this._isPaged())
3235
+ return direct.resolve();
3236
+ const m = this._anchor.resolve();
3237
+ if (!m)
3238
+ return null;
3239
+ // A delete outranks every copy of the record, so a container a page
3240
+ // still shadows reads as absent here as it does everywhere else.
3241
+ if (entryExists(m, tombstoneKeyFor(entryKey)))
3242
+ return null;
3243
+ // Same precedence as `recordBeforePatches`: the edit beside the pages,
3244
+ // then the record under its own key.
3245
+ const own = overlay.resolve() ?? direct.resolve();
3246
+ if (own === null)
3247
+ return null;
3248
+ // A patch is folded over the record's own copy on every read, so while
3249
+ // one exists the container alone is not the record. Answering from it
3250
+ // would report a record the parent map does not.
3251
+ return patched(m) ? null : own;
3252
+ },
3253
+ resolveOrCreate: () => {
3254
+ if (!this._isPaged())
3255
+ return direct.resolveOrCreate();
3256
+ const m = this._anchor.resolveOrCreate();
3257
+ const pager = this._sessionPager(m);
3258
+ const patchIndex = pager.patches(m);
3259
+ const patchKeys = patchIndex.keysFor(entryKey);
3260
+ const hadPatches = patchKeys !== undefined && patchKeys.length > 0;
3261
+ // The record as every other reader resolves it, taken before this
3262
+ // handle takes it over.
3263
+ const record = recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, patchIndex);
3264
+ // From here the container IS the record, so the same bookkeeping a
3265
+ // whole-record write settles is settled here: a patch left behind
3266
+ // applies over the container on the next read and would undo whatever
3267
+ // this handle writes, and a delete marker left behind would hide it.
3268
+ dropPatches(m, patchIndex, entryKey);
3269
+ clearTombstone(m, entryKey);
3270
+ const existing = overlay.resolve() ?? direct.resolve();
3271
+ // A container that already holds the whole record is edited in place.
3272
+ // Seeding it again would rewrite every field, which costs the snapshot
3273
+ // an edit per field and hands a peer's concurrent field edit a
3274
+ // conflict this write has no reason to raise.
3275
+ if (existing !== null && !hadPatches) {
3276
+ pager.liveIndex?.noteContained(entryKey);
3277
+ return existing;
3278
+ }
3279
+ const target = existing ?? overlay.resolveOrCreate();
3280
+ if (target instanceof LoroMap) {
3281
+ if (isPlainObject(record))
3282
+ assignPlain(target, record);
3283
+ }
3284
+ else if (Array.isArray(record)) {
3285
+ assignList(target, record);
3286
+ }
3287
+ pager.liveIndex?.noteContained(entryKey);
3288
+ return target;
3289
+ },
3290
+ // The record as the parent map reports it, for the reads that run when
3291
+ // no container answers: packed in a page, or held as one plain value.
3292
+ plain: () => {
3293
+ if (!this._isPaged())
3294
+ return direct.plain();
3295
+ const m = this._anchor.resolve();
3296
+ if (!m)
3297
+ return undefined;
3298
+ const pager = this._sessionPager(m);
3299
+ return recordFromPage(m, entryKey, this._pageMemo, pager.liveIndex, pager.patches(m));
3300
+ },
3301
+ invalidate: () => {
3302
+ direct.invalidate();
3303
+ overlay.invalidate();
3304
+ },
3305
+ };
3306
+ }
3307
+ }
3308
+ /**
3309
+ * Loro's list diff as absolute splices.
3310
+ *
3311
+ * The diff is a retain / insert / delete stream over a moving cursor, which is
3312
+ * the same information in a shape a caller has to walk statefully. Splices
3313
+ * carry the position each edit lands at, so an app applies them in order and
3314
+ * is done; nothing about the cursor reaches it.
3315
+ */
3316
+ function splicesFromListDiff(diff) {
3317
+ const out = [];
3318
+ let at = 0;
3319
+ for (const part of diff) {
3320
+ if (part.retain !== undefined) {
3321
+ at += part.retain;
3322
+ continue;
3323
+ }
3324
+ if (part.delete !== undefined) {
3325
+ out.push({ at, removed: part.delete, inserted: [] });
3326
+ continue;
3327
+ }
3328
+ if (part.insert !== undefined) {
3329
+ const inserted = part.insert.map(plainDiffItem);
3330
+ out.push({ at, removed: 0, inserted });
3331
+ at += inserted.length;
3332
+ }
3333
+ }
3334
+ return out;
3335
+ }
3336
+ /**
3337
+ * One inserted element as plain data.
3338
+ *
3339
+ * An object or array element is stored as a child container, and the diff
3340
+ * names it as the raw engine handle. A subscriber applies its splices into
3341
+ * whatever it is rendering from, so handing it that handle both takes the app
3342
+ * onto the CRDT engine's own surface (a field read off it answers `undefined`,
3343
+ * while `JSON.stringify` still prints the record, so the failure is silent)
3344
+ * and hands it a pointer that outlives nothing: it is freed with the document.
3345
+ * The whole-container fire converts through `read()`; this is the same
3346
+ * conversion for the delta.
3347
+ */
3348
+ function plainDiffItem(item) {
3349
+ return isContainer(item) ? item.toJSON() : item;
1047
3350
  }
1048
3351
  class ListHandleImpl {
3352
+ // How many items each subscriber currently believes it holds, so a whole
3353
+ // fire can say what it replaces. Tracked per subscriber rather than read off
3354
+ // the container, because a resync swaps the source and the container's own
3355
+ // length says nothing about what this subscriber was handed before it.
3356
+ _held = new Map();
1049
3357
  _anchor;
1050
3358
  _owner;
1051
3359
  // Active subscriptions, tracked so a version checkout can re-fire and
1052
3360
  // re-attach each one against the new container.
1053
3361
  _registry = new SubscriptionRegistry();
1054
- constructor(anchor, owner) {
3362
+ // See `MapHandleImpl._parent`.
3363
+ _parent;
3364
+ constructor(anchor, owner, parent = null) {
1055
3365
  this._anchor = anchor;
1056
3366
  this._owner = owner;
3367
+ this._parent = parent;
3368
+ hideOwnFields(this);
3369
+ }
3370
+ // The plain array this handle's key holds, when it holds a value rather
3371
+ // than a container. See `MapHandleImpl._plainBody`.
3372
+ _plainBody() {
3373
+ const v = this._anchor.plain?.();
3374
+ return Array.isArray(v) ? v : undefined;
1057
3375
  }
1058
3376
  read() {
3377
+ this._owner.assertReadable();
1059
3378
  const l = this._anchor.resolve();
1060
3379
  if (!l)
1061
- return [];
3380
+ return (this._plainBody() ?? []);
1062
3381
  return l.toJSON();
1063
3382
  }
1064
3383
  length() {
3384
+ this._owner.assertReadable();
1065
3385
  const l = this._anchor.resolve();
1066
- return l ? l.length : 0;
3386
+ return l ? l.length : (this._plainBody()?.length ?? 0);
1067
3387
  }
1068
3388
  mutate(fn) {
1069
3389
  this._owner.assertWritable();
1070
3390
  this._owner.ensureLocalUpdatesSubscribed();
1071
- this._runMutate(fn);
3391
+ // Commits as it goes past a size budget; see `mutate-batching.ts`.
3392
+ this._runMutate(fn, new MutateBatcher(() => {
3393
+ this._owner.commitMutate();
3394
+ }));
1072
3395
  this._owner.commitMutate();
1073
3396
  }
1074
3397
  // Run a list mutate without the commit pipeline; see
1075
3398
  // `MapHandleImpl._runMutate`. The ordered-map primitive drives this to
1076
- // reconcile the order list in the same commit as the map edit.
1077
- _runMutate(fn) {
3399
+ // reconcile the order list in the same commit as the map edit, and passes
3400
+ // no batcher: that call already runs at one of the map's batch boundaries
3401
+ // and reconciles only the keys that boundary carries, so its own size is
3402
+ // bounded by the budget the map is spending.
3403
+ _runMutate(fn, batcher) {
1078
3404
  const l = this._anchor.resolveOrCreate();
1079
- const proxy = wrapLoroListProxy(l);
3405
+ const proxy = wrapLoroListProxy(l, batcher);
1080
3406
  fn(proxy);
1081
3407
  }
1082
- // Fire once (positional lists have no per-key delta, so a fire is a signal
1083
- // to re-read). Used for registration and for a checkout / return resync.
3408
+ // Fire the whole list as ONE splice replacing everything the subscriber was
3409
+ // last told. Used for registration and for a checkout / return resync, which
3410
+ // describe no authored transition and so carry no origin.
3411
+ //
3412
+ // `removed` is what this subscriber currently believes it holds, which the
3413
+ // registry tracks per subscriber rather than reading off the container: a
3414
+ // resync swaps the source, so the container's own length says nothing about
3415
+ // what the subscriber was handed before it. That is what lets an app apply
3416
+ // every fire the same way instead of recognising a catch-up.
1084
3417
  _fireWhole(cb) {
3418
+ const inserted = this.read();
3419
+ const held = this._held.get(cb) ?? 0;
3420
+ this._held.set(cb, inserted.length);
1085
3421
  try {
1086
- cb();
3422
+ cb({ splices: [{ at: 0, removed: held, inserted }] });
1087
3423
  }
1088
- catch {
1089
- // A throwing subscriber must not break registration / resync.
3424
+ catch (err) {
3425
+ reportSubscriberThrow(err);
1090
3426
  }
1091
3427
  }
1092
- // Attach the per-container change subscription for `cb` to the CURRENT
1093
- // resolved container, returning its detach.
3428
+ // Attach the change subscription for `cb`, returning its detach. A root
3429
+ // list binds straight to its container; a composed one tracks the entry as
3430
+ // it is deleted and written again (see `MapHandleImpl._attachChange`). A
3431
+ // list fire carries no delta, so the tracker needs no key bookkeeping: a
3432
+ // re-bind is a re-read signal like any other fire.
1094
3433
  _attachChange(cb) {
1095
- // Same caveat as MapHandleImpl: the change subscription needs the
1096
- // container to exist. Root lists are materialized by `doc.list(key)`;
1097
- // nested lists need a prior mutate.
1098
- const l = this._anchor.resolve();
1099
- if (!l)
1100
- return () => { };
1101
- const sub = l.subscribe(() => this._fireWhole(cb));
3434
+ if (this._parent === null) {
3435
+ const l = this._anchor.resolve();
3436
+ return l === null ? () => { } : this._subscribeContainer(l, cb);
3437
+ }
3438
+ return trackEntryContainer(this._parent._chainRoot(), {
3439
+ resolve: () => this._anchor.resolve(),
3440
+ read: () => this.read(),
3441
+ print: (value) => JSON.stringify(value),
3442
+ catchUp: () => this._fireWhole(cb),
3443
+ attach: (l) => this._subscribeContainer(l, cb),
3444
+ });
3445
+ }
3446
+ // Subscribe `cb` to one container's own changes; see
3447
+ // `MapHandleImpl._subscribeContainer`.
3448
+ _subscribeContainer(l, cb) {
3449
+ const sub = l.subscribe((event) => {
3450
+ const diff = event.events[0]?.diff.type === 'list'
3451
+ ? event.events[0].diff.diff
3452
+ : [];
3453
+ const splices = splicesFromListDiff(diff);
3454
+ let held = this._held.get(cb) ?? 0;
3455
+ for (const splice of splices)
3456
+ held += splice.inserted.length - splice.removed;
3457
+ this._held.set(cb, held);
3458
+ try {
3459
+ cb({ splices }, { source: this._owner.originOf(event) });
3460
+ }
3461
+ catch (err) {
3462
+ reportSubscriberThrow(err);
3463
+ }
3464
+ });
1102
3465
  return () => sub();
1103
3466
  }
3467
+ onSettled(cb) {
3468
+ this._owner.assertReadable();
3469
+ return this._registry.onSettled(cb);
3470
+ }
1104
3471
  subscribe(cb) {
3472
+ this._owner.assertReadable();
3473
+ // App subscribers see the change; the internal origin is dropped here so
3474
+ // it never leaks into the public surface.
3475
+ return this._subscribeInternal((change) => cb(change));
3476
+ }
3477
+ // Internal: like `subscribe`, but the callback also receives what produced
3478
+ // the change. Only the ordered-map wrapper uses it, to stamp an `order`
3479
+ // frame the order list's own move produced.
3480
+ _subscribeInternal(cb) {
1105
3481
  return this._registry.subscribe(cb, (c) => this._fireWhole(c), (c) => this._attachChange(c));
1106
3482
  }
1107
3483
  // Re-point this ROOT list handle to `next` without firing (see
@@ -1128,6 +3504,69 @@ class ListHandleImpl {
1128
3504
  // lockstep. It reuses the concrete impls (not the interfaces) to drive their
1129
3505
  // no-commit `_runMutate` seams and the map's `_existsChecker`, so both halves
1130
3506
  // of a structural edit commit once. Built over the SAME handles
3507
+ /**
3508
+ * Records the map holds that the order list does not name, in the map's own
3509
+ * order, which both peers of a merged document read identically.
3510
+ *
3511
+ * The record map and the order list are separate containers and resolve a
3512
+ * conflict independently, so they can disagree in either direction. One peer
3513
+ * deleting a record while another edits it merges to the edit surviving in the
3514
+ * map and the entry gone from the list, which leaves the record present and
3515
+ * unnamed. A reader that walked the order alone would not report it, and the
3516
+ * walk that re-emits a file would drop it, so an ordered read ends with
3517
+ * whatever the order missed rather than losing it.
3518
+ */
3519
+ function unorderedKeys(live, named) {
3520
+ const out = [];
3521
+ for (const key of live) {
3522
+ if (!named.has(key))
3523
+ out.push(key);
3524
+ }
3525
+ return out;
3526
+ }
3527
+ /**
3528
+ * The keys an ordered map presents, from the map's records and the order
3529
+ * list's raw contents.
3530
+ *
3531
+ * Filter to keys still in the map: the primitive keeps the two in step, but a
3532
+ * hand-written or stale order list must not surface a key the map no longer
3533
+ * has. The caller passes `records` and `raw` in rather than re-reading them,
3534
+ * because a subscriber needs the two reads it was derived from to tell one
3535
+ * commit's fires apart, and reading the map is the expensive half (on a paged
3536
+ * store it decodes every page).
3537
+ */
3538
+ /**
3539
+ * The sequence an ordered map presents, from the order list and the map's LIVE
3540
+ * KEYS.
3541
+ *
3542
+ * Keys rather than records, because that is all the answer depends on: an
3543
+ * entry in the order list appears when the map still holds it, and a record
3544
+ * the list never names is appended. Deriving it from the records instead
3545
+ * charges a whole-store read for a question about membership, which on a
3546
+ * paged store decodes every page, and it runs on every add and every delete.
3547
+ */
3548
+ function sequenceOf(live, raw) {
3549
+ const seen = new Set();
3550
+ const out = [];
3551
+ for (const key of raw) {
3552
+ if (!live.has(key))
3553
+ continue;
3554
+ // Each key once, at its first position. The order list is a list CRDT and
3555
+ // keeps every insertion, so two peers concurrently adding the SAME key
3556
+ // resolve to one record and two entries naming it; a reader that surfaced
3557
+ // both would report a record this map holds once as present twice.
3558
+ if (seen.has(key))
3559
+ continue;
3560
+ seen.add(key);
3561
+ out.push(key);
3562
+ }
3563
+ for (const key of unorderedKeys(live, seen))
3564
+ out.push(key);
3565
+ return out;
3566
+ }
3567
+ function sameSequence(a, b) {
3568
+ return a.length === b.length && a.every((key, i) => key === b[i]);
3569
+ }
1131
3570
  // `doc.map(mapKey)` / `doc.list(orderKey)` return, so subscriptions stay
1132
3571
  // consistent.
1133
3572
  class OrderedMapHandleImpl {
@@ -1138,6 +3577,7 @@ class OrderedMapHandleImpl {
1138
3577
  this._map = _map;
1139
3578
  this._order = _order;
1140
3579
  this._owner = _owner;
3580
+ hideOwnFields(this);
1141
3581
  }
1142
3582
  read() {
1143
3583
  return this._map.read();
@@ -1149,30 +3589,140 @@ class OrderedMapHandleImpl {
1149
3589
  return this._map.has(entryKey);
1150
3590
  }
1151
3591
  keys() {
1152
- // Filter to keys still in the map: the primitive keeps the two in step,
1153
- // but a hand-written or stale order list must not surface a key the map
1154
- // no longer has. Read the map ONCE (its bulk snapshot) and test
1155
- // membership against it, the same shape `ordered()` uses. A per-key
1156
- // `_existsChecker` predicate would re-decode a paged store's page once
1157
- // per record it holds (one gunzip per key, ~page-size times per page),
1158
- // and `subscribe` calls this on every structural move, so a single edit
1159
- // on a large paged doc would cost O(records) page decodes instead of
1160
- // O(pages).
1161
- const records = this._map.read();
1162
- return this._order
1163
- .read()
1164
- .filter((k) => Object.prototype.hasOwnProperty.call(records, k));
3592
+ return sequenceOf(this._map.liveKeySet(), this._order.read());
1165
3593
  }
1166
3594
  ordered() {
1167
3595
  const records = this._map.read();
3596
+ const seen = new Set();
1168
3597
  const out = [];
1169
3598
  for (const key of this._order.read()) {
1170
3599
  if (Object.prototype.hasOwnProperty.call(records, key)) {
3600
+ if (seen.has(key))
3601
+ continue;
3602
+ seen.add(key);
1171
3603
  out.push({ key, value: records[key] });
1172
3604
  }
1173
3605
  }
3606
+ for (const key of unorderedKeys(Object.keys(records), seen)) {
3607
+ out.push({ key, value: records[key] });
3608
+ }
1174
3609
  return out;
1175
3610
  }
3611
+ insert(entryKey, value, at) {
3612
+ this._owner.assertWritable();
3613
+ this._owner.ensureLocalUpdatesSubscribed();
3614
+ // Resolved before the map write, so the destination is read off the
3615
+ // collection the caller was looking at rather than one this call has
3616
+ // already added a key to.
3617
+ const raw = this._order.read();
3618
+ const target = this._insertIndex(raw, entryKey, at);
3619
+ this._map._runMutate((inner) => {
3620
+ inner[entryKey] = value;
3621
+ });
3622
+ this._placeKey(raw, entryKey, target);
3623
+ this._owner.commitMutate();
3624
+ }
3625
+ move(entryKey, to) {
3626
+ this._owner.assertWritable();
3627
+ this._owner.ensureLocalUpdatesSubscribed();
3628
+ // A key the map does not hold gets no order entry: the list names what
3629
+ // the map holds, and a peer's delete of this key is the case that puts a
3630
+ // reader here, so writing the entry would resurrect a name for a record
3631
+ // that is gone.
3632
+ if (!this._map._existsChecker()(entryKey))
3633
+ return;
3634
+ const raw = this._order.read();
3635
+ if (!this._placeKey(raw, entryKey, this._insertIndex(raw, entryKey, to)))
3636
+ return;
3637
+ this._owner.commitMutate();
3638
+ }
3639
+ /**
3640
+ * The order-list index a position resolves to.
3641
+ *
3642
+ * Resolved against the RAW list rather than the sequence a reader sees, so
3643
+ * the key lands beside the anchor's own entry. The two disagree only where
3644
+ * the list names a key the map no longer holds, and a dead neighbour still
3645
+ * names the right neighbourhood.
3646
+ */
3647
+ _insertIndex(raw, entryKey, at) {
3648
+ if ('index' in at) {
3649
+ const want = Math.trunc(Number(at.index));
3650
+ if (!Number.isFinite(want))
3651
+ return raw.length;
3652
+ if (want <= 0)
3653
+ return 0;
3654
+ // An index counts the positions a reader is shown, and the raw list
3655
+ // holds entries no reader sees (a key the map lost, a duplicate the
3656
+ // sequence reports once), so the index names whichever key stands
3657
+ // there and the answer is that key's own entry.
3658
+ //
3659
+ // Counted with this key taken out, so an index means the position the
3660
+ // key ENDS at either way. Left in, a key moving down the collection
3661
+ // lands one short of the index named, because lifting it out shifts
3662
+ // everything above it.
3663
+ const seq = sequenceOf(this._map.liveKeySet(), raw).filter((key) => key !== entryKey);
3664
+ if (want >= seq.length)
3665
+ return raw.length;
3666
+ const found = raw.indexOf(seq[want]);
3667
+ return found === -1 ? raw.length : found;
3668
+ }
3669
+ const anchor = 'before' in at ? at.before : at.after;
3670
+ const found = raw.indexOf(anchor);
3671
+ if (found === -1)
3672
+ return raw.length;
3673
+ return 'before' in at ? found : found + 1;
3674
+ }
3675
+ /**
3676
+ * Put `entryKey` at `target` in the order list, reporting whether that
3677
+ * wrote anything.
3678
+ *
3679
+ * A key the list already names is REPOSITIONED (`move`), never re-inserted:
3680
+ * the list stores as a `LoroMovableList`, so a move names the entry it
3681
+ * carries and two peers repositioning the same key converge on a list
3682
+ * naming it once, where a delete plus an insert commutes into a list naming
3683
+ * it twice.
3684
+ *
3685
+ * The plan is computed on the snapshot the caller already read, so the
3686
+ * draft takes the writes and no scan.
3687
+ */
3688
+ _placeKey(raw, entryKey, target) {
3689
+ const from = raw.indexOf(entryKey);
3690
+ if (from === -1) {
3691
+ this._order._runMutate((arr) => {
3692
+ arr.splice(target, 0, entryKey);
3693
+ });
3694
+ return true;
3695
+ }
3696
+ // Entries beyond the first naming this key. Two peers adding one key
3697
+ // leave the list naming it twice and a reader takes the first, so a
3698
+ // reposition that left the rest standing would put the key where the
3699
+ // reader is not looking. Dropping them names the key once and loses
3700
+ // nothing: the entry this call keeps carries it.
3701
+ const extras = [];
3702
+ for (let i = from + 1; i < raw.length; i += 1) {
3703
+ if (raw[i] === entryKey)
3704
+ extras.push(i);
3705
+ }
3706
+ // Positions after the drops: `from` is below every extra, and an
3707
+ // insertion index loses the extras below it. The move destination then
3708
+ // counts positions with the entry lifted out, which is one less than the
3709
+ // insertion index whenever the entry stands before it.
3710
+ let want = target;
3711
+ for (const extra of extras)
3712
+ if (extra < target)
3713
+ want -= 1;
3714
+ const dest = want > from ? want - 1 : want;
3715
+ if (dest === from && extras.length === 0)
3716
+ return false;
3717
+ this._order._runMutate((arr) => {
3718
+ for (let i = extras.length - 1; i >= 0; i -= 1) {
3719
+ arr.splice(extras[i], 1);
3720
+ }
3721
+ if (dest !== from)
3722
+ arr.move(from, dest);
3723
+ });
3724
+ return true;
3725
+ }
1176
3726
  mutate(fn) {
1177
3727
  this._owner.assertWritable();
1178
3728
  this._owner.ensureLocalUpdatesSubscribed();
@@ -1187,39 +3737,63 @@ class OrderedMapHandleImpl {
1187
3737
  // the order entries for those same records. Left to the end instead,
1188
3738
  // the final commit carries every key at once: measured at 229 KB for a
1189
3739
  // 20,000-record write, an oversized op of its own.
1190
- const reconcileOrder = () => {
1191
- if (added.size === 0 && removed.size === 0)
3740
+ //
3741
+ // `carried` names the records whose writes the commit about to be taken
3742
+ // covers, and the boundary reconciles those alone. A paged store assembles
3743
+ // its whole overlay before it writes any of it, so by the first boundary
3744
+ // every key of the mutation is already in `added`, and appending all of
3745
+ // them there would put the whole order list in that first op and name
3746
+ // records the store does not hold yet. The final call passes nothing and
3747
+ // lands whatever is left.
3748
+ const reconcileOrder = (carried) => {
3749
+ const covers = carried === undefined ? null : new Set(carried);
3750
+ const appends = [...added].filter((k) => covers === null || covers.has(k));
3751
+ const drops = [...removed].filter((k) => covers === null || covers.has(k));
3752
+ if (appends.length === 0 && drops.length === 0)
1192
3753
  return;
3754
+ const dropped = new Set(drops);
1193
3755
  this._order._runMutate((arr) => {
1194
- if (removed.size > 0) {
1195
- const kept = arr.filter((k) => !removed.has(k));
1196
- arr.length = 0;
1197
- for (const k of kept)
1198
- arr.push(k);
3756
+ if (dropped.size > 0) {
3757
+ // Remove exactly the entries that went, in place. Rebuilding the
3758
+ // list instead (truncate, then re-push what survived) makes the
3759
+ // whole list one write, and two peers each removing a different
3760
+ // record then merge to a list holding both re-insertions plus the
3761
+ // entries they each meant to drop. Walking down keeps the indices
3762
+ // below the cursor valid, and a run of adjacent removals goes in one
3763
+ // splice so a bulk delete costs runs rather than entries.
3764
+ const current = [...arr];
3765
+ let runEnd = -1;
3766
+ for (let i = current.length - 1; i >= -1; i -= 1) {
3767
+ const drop = i >= 0 && dropped.has(current[i]);
3768
+ if (drop && runEnd === -1)
3769
+ runEnd = i;
3770
+ if (!drop && runEnd !== -1) {
3771
+ arr.splice(i + 1, runEnd - i);
3772
+ runEnd = -1;
3773
+ }
3774
+ }
1199
3775
  }
1200
- for (const k of added)
3776
+ for (const k of appends)
1201
3777
  arr.push(k);
1202
3778
  });
1203
- added.clear();
1204
- removed.clear();
3779
+ for (const k of appends)
3780
+ added.delete(k);
3781
+ for (const k of drops)
3782
+ removed.delete(k);
1205
3783
  };
1206
- const batcher = this._map._canBatchMutate()
1207
- ? new MutateBatcher(() => {
1208
- reconcileOrder();
1209
- this._owner.commitMutate();
1210
- })
1211
- : null;
3784
+ const batcher = new MutateBatcher((carried) => {
3785
+ reconcileOrder(carried);
3786
+ this._owner.commitMutate();
3787
+ });
1212
3788
  this._map._runMutate((inner) => {
1213
3789
  // "is this key new" (and so must be appended to the order list) is O(1)
1214
3790
  // per set; a repeat set of the same new key is guarded by `added`.
1215
- // `exists` reflects the key set at the start of the batch for a paged
1216
- // store (its writes are deferred to `applyOverlay`), but an UNPAGED
1217
- // proxy writes through to the live map immediately, so a key deleted
1218
- // earlier in this same batch reads as absent from `exists`. `removed`
1219
- // records exactly those keys, so "present before the batch" is
1220
- // `exists(k) || removed.has(k)`; consulting both keeps a delete-then-
1221
- // re-add of a pre-existing key from being mis-seen as a fresh add and
1222
- // appended to the order list a second time.
3791
+ // `exists` answers over the key set the map held at the START of this
3792
+ // batch, so it is the whole test for "present before the batch" and this
3793
+ // batch's own writes cannot move it. `added` and `removed` carry what
3794
+ // the batch has done on top of it, which is what makes a delete followed
3795
+ // by any number of re-sets append to the order list zero times rather
3796
+ // than once per set.
1223
3797
  const exists = this._map._existsChecker();
1224
3798
  const tracking = new Proxy({}, {
1225
3799
  get: (_t, key) => typeof key === 'symbol'
@@ -1229,9 +3803,8 @@ class OrderedMapHandleImpl {
1229
3803
  if (typeof key === 'symbol')
1230
3804
  return false;
1231
3805
  const k = key;
1232
- if (!added.has(k) && !exists(k) && !removed.has(k)) {
3806
+ if (!added.has(k) && !exists(k))
1233
3807
  added.add(k);
1234
- }
1235
3808
  // A re-add after a delete in the same batch cancels the delete.
1236
3809
  removed.delete(k);
1237
3810
  inner[k] = value;
@@ -1247,17 +3820,11 @@ class OrderedMapHandleImpl {
1247
3820
  added.delete(k);
1248
3821
  }
1249
3822
  else if (exists(k)) {
1250
- // Only track a removal that actually drops a key present in the
1251
- // order list before this batch. `exists(k)` reads the key set at
1252
- // batch start (paged defers writes; an unpaged key untouched so
1253
- // far this batch reads its start state), so it is true only for a
1254
- // genuinely-present key. Deleting a key that was already absent
1255
- // must NOT enter `removed`: a later re-`set` of that key in the
1256
- // same batch consults `removed.has(k)` to decide "re-add of a
1257
- // pre-existing key" (skip the order append) vs "fresh add"
1258
- // (append). A spurious `removed` entry makes the fresh add look
1259
- // like a re-add, so the key lands in the record map but never in
1260
- // `instance_order`, a silent divergence between the two.
3823
+ // Only a key the order list already named can be dropped from
3824
+ // it, and `exists` is exactly that set, so `removed` stays a
3825
+ // subset of it. Deleting a key that was already absent records
3826
+ // nothing, which leaves a later re-`set` of it in the same batch
3827
+ // reading as the fresh add it is.
1261
3828
  removed.add(k);
1262
3829
  }
1263
3830
  delete inner[k];
@@ -1270,53 +3837,92 @@ class OrderedMapHandleImpl {
1270
3837
  getOwnPropertyDescriptor: (_t, key) => Object.getOwnPropertyDescriptor(inner, key),
1271
3838
  });
1272
3839
  fn(tracking);
1273
- }, batcher ?? undefined);
3840
+ }, batcher);
1274
3841
  reconcileOrder();
1275
3842
  this._owner.commitMutate();
1276
3843
  }
3844
+ /** Settles with its map. The order list is delivered in the same breath, so
3845
+ * one signal covers both halves and an app is never told the records are in
3846
+ * while their sequence is still arriving. */
3847
+ onSettled(cb) {
3848
+ return this._map.onSettled(cb);
3849
+ }
1277
3850
  subscribe(cb) {
1278
- // Content (`set` / `deleted`) rides the map subscription. `order` is
1279
- // attached ONLY when the order moved; a field edit must not pay for a
1280
- // re-sequence. A move is detected in O(changed) by testing changed keys
1281
- // against a cached membership set; the ordered keys (O(order)) are read
1282
- // only on a move. `known` starts empty so the initial fire reads as an
1283
- // all-new move and carries the starting `order`.
3851
+ this._owner.assertReadable();
3852
+ // Content (`set` / `deleted`) rides the map subscription. `order` rides
3853
+ // whichever of the two containers moved it, and reaches the subscriber at
3854
+ // most once per commit: `lastOrder` is the sequence it was last handed, so
3855
+ // the second fire of a commit that changed both containers has nothing to
3856
+ // add.
3857
+ //
3858
+ // The map's fire tests the key set against `known`, which is O(changed)
3859
+ // and catches the ordinary add / delete without walking the order; a field
3860
+ // edit must not pay for a re-sequence. `known` starts empty so the initial
3861
+ // fire reads as an all-new move and carries the starting `order`.
1284
3862
  //
1285
- // A `mutate` / remote delta only moves the order by changing the key set,
1286
- // which the cheap membership test catches. The exception is a
1287
- // whole-container re-sync (registration, or a checkout / return / hydrate
1288
- // swapping the source): it can present the SAME key set in a different
1289
- // `instance_order`, which the membership test cannot see. The fire tells
1290
- // us which it is (`resync`) directly, so on a re-sync we read the actual
1291
- // order (O(order), and these fires are rare) and treat only a genuine
1292
- // reorder as a move rather than every re-sync (else an empty registration
1293
- // and an unchanged-order checkout would both deliver a redundant `order`).
3863
+ // What that test cannot see reaches the ORDER LIST's own fire: a raw edit
3864
+ // of the order list moves the sequence while the map stands still, and a
3865
+ // merge can resequence keys whose membership never changed (two peers
3866
+ // adding the same key leave one record and two entries naming it). Both
3867
+ // are silent on the map, so without that subscription the app's rendered
3868
+ // sequence disagrees with `keys()` and no later content change corrects
3869
+ // it.
3870
+ //
3871
+ // A whole-container re-sync (registration, or a checkout / return /
3872
+ // hydrate swapping the source) can present the SAME key set in a different
3873
+ // order, which the membership test cannot see either. The fire says which
3874
+ // it is (`resync`), so those read the sequence directly.
1294
3875
  const known = new Set();
1295
3876
  let lastOrder = [];
1296
- const sameOrder = (a, b) => a.length === b.length && a.every((k, i) => k === b[i]);
1297
- return this._map._subscribeInternal((change, origin, resync) => {
1298
- let moved = change.deleted.length > 0;
1299
- for (const key of change.set.keys())
3877
+ // The order list's raw contents the last delivered sequence was derived
3878
+ // from. The order fire compares against it FIRST, so the fire that follows
3879
+ // the map's own for one commit costs a list read rather than a re-read of
3880
+ // the map, which on a paged store decodes every page.
3881
+ let lastRaw = [];
3882
+ // The sequence as it stands, or null when the subscriber already has it.
3883
+ //
3884
+ // Derived from `known`, which the map fire has already brought up to date
3885
+ // and which IS this map's live key set: every fire adds what it delivered
3886
+ // and drops what it removed, a resync included. The alternative, asking
3887
+ // the map, answers the same question by materialising every record, which
3888
+ // on a paged store decodes every page on every add and every delete.
3889
+ const moved = () => {
3890
+ lastRaw = this._order.read();
3891
+ const order = sequenceOf(known, lastRaw);
3892
+ if (sameSequence(order, lastOrder))
3893
+ return null;
3894
+ lastOrder = order;
3895
+ return order;
3896
+ };
3897
+ const detachMap = this._map._subscribeInternal((change, origin, resync) => {
3898
+ let structural = change.deleted.length > 0;
3899
+ for (const key of change.set.keys()) {
1300
3900
  if (!known.has(key))
1301
- moved = true;
1302
- let orderKeys = null;
1303
- if (!moved && resync) {
1304
- orderKeys = this.keys();
1305
- if (!sameOrder(orderKeys, lastOrder))
1306
- moved = true;
1307
- }
1308
- if (!moved) {
1309
- cb(change, origin);
1310
- return;
3901
+ structural = true;
1311
3902
  }
1312
3903
  for (const key of change.deleted)
1313
3904
  known.delete(key);
1314
3905
  for (const key of change.set.keys())
1315
3906
  known.add(key);
1316
- const order = orderKeys ?? this.keys();
1317
- lastOrder = order;
3907
+ const order = structural || resync ? moved() : null;
3908
+ if (order === null) {
3909
+ cb(change, origin);
3910
+ return;
3911
+ }
1318
3912
  cb({ set: change.set, deleted: change.deleted, order }, origin);
1319
3913
  });
3914
+ const detachOrder = this._order._subscribeInternal((_change, origin) => {
3915
+ if (sameSequence(this._order.read(), lastRaw))
3916
+ return;
3917
+ const order = moved();
3918
+ if (order === null)
3919
+ return;
3920
+ cb({ set: new Map(), deleted: [], order }, origin);
3921
+ });
3922
+ return () => {
3923
+ detachMap();
3924
+ detachOrder();
3925
+ };
1320
3926
  }
1321
3927
  }
1322
3928
  // Factory functions called by the Document. App code reaches handles via
@@ -1325,8 +3931,8 @@ class OrderedMapHandleImpl {
1325
3931
  // transparently as a flat record map. The Document supplies it for every root
1326
3932
  // map; a non-paged map never consults it, so passing it to non-record roots
1327
3933
  // is harmless. Omitted means the handle only serves the plain unpaged shape.
1328
- export function createRootMapHandle(root, owner, pageCount, pagingScheme) {
1329
- return new MapHandleImpl(rootAnchor(root), owner, pageCount ?? null, pagingScheme ?? null);
3934
+ export function createRootMapHandle(root, owner, paging) {
3935
+ return new MapHandleImpl(rootAnchor(root), owner, paging ?? null);
1330
3936
  }
1331
3937
  export function createRootListHandle(root, owner) {
1332
3938
  return new ListHandleImpl(rootAnchor(root), owner);
@@ -1351,6 +3957,21 @@ export function rebindRootMapAnchor(handle, next) {
1351
3957
  export function rebindRootListAnchor(handle, next) {
1352
3958
  handle._rebindAnchor(next);
1353
3959
  }
3960
+ // The first step of the swap: capture a map handle's key set while the handle still
3961
+ // resolves the outgoing source in BOTH respects, its container and the paging
3962
+ // state it reads that container under. The Document runs this over every root
3963
+ // map handle before it moves its active-source pointer, because the paging
3964
+ // state follows that pointer while the container follows the anchor: between
3965
+ // the two moves a handle reads the outgoing container under the incoming
3966
+ // source's paging state, which yields the store's `brass:` bookkeeping keys
3967
+ // rather than its records, and the resync then reports those as the keys the
3968
+ // swap removed while every record it actually removed goes unreported.
3969
+ //
3970
+ // Lists need no counterpart: they carry no per-key delta, so a list rebind has
3971
+ // nothing to diff.
3972
+ export function snapshotRootMapKeys(handle) {
3973
+ handle._snapshotPriorKeys();
3974
+ }
1354
3975
  // Re-fire + re-attach a root handle's subscribers against its current
1355
3976
  // container. Both concrete impls expose `_resync`, so one generic reaches
1356
3977
  // either kind; the cast is safe because this module is the only producer of