@brass-build/client 0.1.0 → 0.3.0

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