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