@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
package/src/handles.ts ADDED
@@ -0,0 +1,1731 @@
1
+ // Typed container handles. A handle is a scoped reference to a container in
2
+ // the CRDT doc; its `read` / `mutate` / `subscribe` only see (and only pay
3
+ // for) that container.
4
+ //
5
+ // Engine-neutral: Loro events are translated to a minimal `Change` shape at
6
+ // the handle boundary, so handle-only app code does not pin the SDK to Loro.
7
+
8
+ import {
9
+ LoroList,
10
+ LoroMap,
11
+ type LoroEventBatch,
12
+ isContainer,
13
+ } from 'loro-crdt';
14
+
15
+ import {
16
+ PAGE_TAG,
17
+ type PackedRecord,
18
+ decodePageBody,
19
+ decodeRecordValue,
20
+ encodePageBody,
21
+ flattenPagedLookup,
22
+ isPagingSchemeEditable,
23
+ pageKeyFor,
24
+ peekRecordTag,
25
+ } from './record-paging.js';
26
+ import { BrassError } from './errors.js';
27
+ import { MutateBatcher } from './mutate-batching.js';
28
+
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
+ }
182
+
183
+ // ---- Plain-JS-proxy machinery, scoped per-container --------------------
184
+
185
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
186
+ return (
187
+ v !== null &&
188
+ typeof v === 'object' &&
189
+ !Array.isArray(v) &&
190
+ (v as { constructor?: unknown }).constructor === Object
191
+ );
192
+ }
193
+
194
+ // A value being assigned may be a live Loro container handle (a nested list
195
+ // read back through `m[key].listField`, or a nested-map proxy). Re-inserting
196
+ // an already-attached handle as a value corrupts it: it lands as an opaque
197
+ // reference and is lost on the next snapshot. Materialize it to a deep plain
198
+ // value first, so the assign path rebuilds it as a fresh container.
199
+ function materializeContainers(v: unknown): unknown {
200
+ return isContainer(v) ? (v as { toJSON(): unknown }).toJSON() : v;
201
+ }
202
+
203
+ // Deep-plain conversion for structures stored as plain Loro VALUES: any
204
+ // nested container handle or wrapped proxy materialises to pure JSON, and
205
+ // `undefined` entries drop (the documented JSON-serialise semantics of
206
+ // `mutate`).
207
+ function toPlainDeep(v: unknown): unknown {
208
+ if (isContainer(v)) return (v as { toJSON(): unknown }).toJSON();
209
+ if (Array.isArray(v)) return v.map(toPlainDeep);
210
+ if (v !== null && typeof v === 'object') {
211
+ const out: Record<string, unknown> = {};
212
+ for (const [k, x] of Object.entries(v)) {
213
+ if (x !== undefined) out[k] = toPlainDeep(x);
214
+ }
215
+ return out;
216
+ }
217
+ return v;
218
+ }
219
+
220
+ function assignPlain(target: LoroMap, value: Record<string, unknown>): void {
221
+ // Recursively materialize nested objects / arrays as nested LoroMap /
222
+ // LoroList containers so the CRDT mirrors the JS shape. Clear keys not in
223
+ // `value` so assignment is a replace, not a merge (property-level patching
224
+ // uses `d.entities[id].x = ...`).
225
+ for (const k of target.keys()) {
226
+ if (!(k in value)) target.delete(k);
227
+ }
228
+ 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
+ }
266
+ }
267
+ }
268
+
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.
273
+ const oldLen = target.length;
274
+ for (let i = 0; i < value.length; i++) {
275
+ 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
+ }
293
+ }
294
+ }
295
+ if (value.length < oldLen) {
296
+ target.delete(value.length, oldLen - value.length);
297
+ }
298
+ }
299
+
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;
305
+ const item = materializeContainers(rawItem);
306
+ if (isPlainObject(item)) {
307
+ const child = target.insertContainer(idx, new LoroMap());
308
+ assignPlain(child, item);
309
+ } else if (Array.isArray(item)) {
310
+ const child = target.insertContainer(idx, new LoroList());
311
+ assignList(child, item);
312
+ } else {
313
+ target.insert(idx, item as never);
314
+ }
315
+ }
316
+
317
+ // Exported because `Document.mutate` reuses the same proxy for its
318
+ // `entities` root. Load-bearing: `getOwnPropertyDescriptor` does a single
319
+ // `loroMap.get(key)` (O(1) per key, so `Object.keys()` is O(N)). Iterating
320
+ // `loroMap.keys()` in the descriptor trap makes `Object.keys()` O(N^2) at
321
+ // 50k entities (measured at >5 min of CPU before giving up).
322
+ export function wrapLoroMapProxy(
323
+ loroMap: LoroMap,
324
+ // Threaded into nested record proxies too, so a field edit counts. A
325
+ // mutation can hold thousands of them (`m[id].params = ...` in a loop),
326
+ // which reaches the same size a bulk write does while touching no
327
+ // top-level key.
328
+ batcher?: MutateBatcher,
329
+ ): Record<string, unknown> {
330
+ const handler: ProxyHandler<object> = {
331
+ get(_t, key) {
332
+ if (typeof key === 'symbol') return undefined;
333
+ const v = loroMap.get(key);
334
+ if (isContainer(v)) {
335
+ // Present an engine-neutral view, never the raw container, so app
336
+ // code can't take a hard dependency on the CRDT engine. A LoroMap
337
+ // recurses into another proxy; a LoroList reads back via `toJSON`
338
+ // (not `toArray`, which is shallow and hands back live element
339
+ // handles that corrupt on reassignment; list editing goes through
340
+ // `parent.list(key).mutate(...)`). Other container types are outside
341
+ // the v1 portable surface, so project them via toJSON().
342
+ if (v instanceof LoroMap) return wrapLoroMapProxy(v, batcher);
343
+ if (v instanceof LoroList) return v.toJSON();
344
+ return (v as { toJSON(): unknown }).toJSON();
345
+ }
346
+ return v;
347
+ },
348
+ set(_t, key, value) {
349
+ if (typeof key === 'symbol') return false;
350
+ // Before the write, so a commit it triggers seals the preceding
351
+ // batch rather than splitting this record out of it.
352
+ 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
+ }
390
+ return true;
391
+ },
392
+ deleteProperty(_t, key) {
393
+ if (typeof key === 'symbol') return false;
394
+ batcher?.recordDelete();
395
+ loroMap.delete(key);
396
+ return true;
397
+ },
398
+ has(_t, key) {
399
+ if (typeof key === 'symbol') return false;
400
+ return loroMap.get(key) !== undefined;
401
+ },
402
+ ownKeys() {
403
+ return loroMap.keys();
404
+ },
405
+ getOwnPropertyDescriptor(_t, key) {
406
+ if (typeof key === 'symbol') return undefined;
407
+ // Single get per key. Do NOT iterate `loroMap.keys()` here: it makes
408
+ // Object.keys() O(N^2) at 50k entities.
409
+ const v = loroMap.get(key);
410
+ if (v === undefined) return undefined;
411
+ return {
412
+ enumerable: true,
413
+ configurable: true,
414
+ writable: true,
415
+ value: v,
416
+ };
417
+ },
418
+ };
419
+ return new Proxy({}, handler) as Record<string, unknown>;
420
+ }
421
+
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.
426
+ // `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',
433
+ 'sort',
434
+ 'reverse',
435
+ 'fill',
436
+ 'copyWithin',
437
+ ]);
438
+
439
+ // An array-proxy for list mutators: index assignments, `.push` / `.splice`,
440
+ // and length-set route into the underlying LoroList so the user writes
441
+ // idiomatic JS. Reads delegate to a fresh snapshot per access so a sequence
442
+ // of reads inside the mutator sees each write.
443
+ function wrapLoroListProxy(loroList: LoroList): unknown[] {
444
+ const handler: ProxyHandler<unknown[]> = {
445
+ get(_t, key) {
446
+ if (key === 'length') return loroList.length;
447
+ if (typeof key === 'symbol') {
448
+ // `toJSON` (deep-plain), not `toArray` (shallow), so iterating the
449
+ // draft (`for (const c of l)`, `[...l]`) yields plain objects an app
450
+ // can read fields off, matching the array-method snapshot below.
451
+ return (loroList.toJSON() as unknown as Record<symbol, unknown>)[key];
452
+ }
453
+ if (typeof key === 'string' && /^\d+$/.test(key)) {
454
+ const idx = Number(key);
455
+ const v = loroList.get(idx);
456
+ if (isContainer(v)) {
457
+ // 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();
460
+ return (v as { toJSON(): unknown }).toJSON();
461
+ }
462
+ return v;
463
+ }
464
+ // `push` is the hot path (the ordered-map primitive appends every new
465
+ // key through it; apps bulk-build lists with it), so append natively.
466
+ // The toArray + replay below is O(list) PER call, turning N appends
467
+ // into O(N^2) and faulting Loro at a few thousand keys; a tail insert
468
+ // is O(1).
469
+ if (key === 'push') {
470
+ return (...items: unknown[]) => {
471
+ for (const item of items) insertListTail(loroList, item);
472
+ return loroList.length;
473
+ };
474
+ }
475
+ // Remaining array methods: bind to a deep-plain `toJSON` snapshot so a
476
+ // callback reading an element's fields (`findIndex((c) => c.id === x)`,
477
+ // `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`.
483
+ const arr = loroList.toJSON() as unknown[];
484
+ const val = (arr as unknown as Record<string | symbol, unknown>)[key];
485
+ if (typeof val === 'function') {
486
+ return (...args: unknown[]) => {
487
+ const fn = val as (...a: unknown[]) => unknown;
488
+ const result = fn.apply(arr, args);
489
+ if (MUTATING_ARRAY_METHODS.has(key)) assignList(loroList, arr);
490
+ return result;
491
+ };
492
+ }
493
+ return val;
494
+ },
495
+ set(_t, key, value) {
496
+ if (key === 'length') {
497
+ const newLen = Number(value);
498
+ const oldLen = loroList.length;
499
+ if (newLen < oldLen) loroList.delete(newLen, oldLen - newLen);
500
+ return true;
501
+ }
502
+ if (typeof key === 'string' && /^\d+$/.test(key)) {
503
+ 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
+ }
517
+ return true;
518
+ }
519
+ return false;
520
+ },
521
+ };
522
+ return new Proxy([] as unknown[], handler);
523
+ }
524
+
525
+ // ---- Paged record-store presentation -----------------------------------
526
+ //
527
+ // 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`).
530
+ // These helpers let `MapHandleImpl` present that paged map as the SAME flat
531
+ // `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.
534
+
535
+ // A flattened record: the same `{ type, params }` shape an unpaged store
536
+ // stores per record.
537
+ type FlatRecord = Record<string, unknown>;
538
+
539
+ const TAG_FIELD = 'type';
540
+ const BODY_FIELD = 'params';
541
+
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 {
546
+ const v = m.get(key);
547
+ if (v instanceof LoroMap) return v.toJSON();
548
+ return v;
549
+ }
550
+
551
+ // Decode a live paged `instances` map to the flat `key -> { type, params }`
552
+ // lookup. `toJSON` materialises the page containers to the plain shape the
553
+ // shared `flattenPagedLookup` consumes, so page flattening lives in one place.
554
+ // 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
556
+ // re-deriving it from a tag scan.
557
+ function flattenPagedMap(m: LoroMap): Record<string, FlatRecord> {
558
+ return flattenPagedLookup(
559
+ m.toJSON() as Record<string, unknown>,
560
+ true,
561
+ ) as Record<string, FlatRecord>;
562
+ }
563
+
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.
567
+ function recordFromPage(
568
+ m: LoroMap,
569
+ recordKey: string,
570
+ pageCount: number,
571
+ ): 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] };
577
+ }
578
+ }
579
+ return undefined;
580
+ }
581
+
582
+ // ---- Change deltas (`{ delta: true }` map subscribers) ----------------
583
+ //
584
+ // A delta subscriber reports which keys changed since the previous fire so a
585
+ // render loop patches O(changed) rather than re-reading the container.
586
+ //
587
+ // Unpaged: read it off Loro's diff. A value reassign / add / delete shows on
588
+ // the map's own diff (`target === m.id`); a field edit fires on the record's
589
+ // nested container, so the record's key is the first path segment under root.
590
+ //
591
+ // Paged: a record edit rewrites the whole page container, so the diff names
592
+ // the changed PAGE. Recover record granularity with a per-subscription
593
+ // page -> keys index, diffing a changed page's current keys against it; a
594
+ // deleted record was in the prior key set and absent from the rewritten page.
595
+
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;
609
+ }
610
+
611
+ // Decode one page to its `key -> { type, params }` records in a single pass,
612
+ // so a paged delta gets the changed records' VALUES from the same decode it
613
+ // uses to find the changed keys (a second per-key decode would make a large
614
+ // initial build O(records x page-size)).
615
+ function pageRecords(m: LoroMap, pageKey: string): Map<string, FlatRecord> | null {
616
+ const page = pageValue(m, pageKey);
617
+ if (peekRecordTag(page) !== PAGE_TAG) return null;
618
+ 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
+ }
623
+ }
624
+ return records;
625
+ }
626
+
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;
634
+ }
635
+
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);
641
+ }
642
+ return index;
643
+ }
644
+
645
+ function unpagedMapDelta(
646
+ m: LoroMap,
647
+ rootKey: string | null,
648
+ batch: LoroEventBatch,
649
+ ): Change<unknown> {
650
+ const setKeys = new Set<string>();
651
+ const deleted = new Set<string>();
652
+ for (const ev of batch.events) {
653
+ if (ev.target === m.id) {
654
+ if (ev.diff.type !== 'map') continue;
655
+ for (const [key, value] of Object.entries(ev.diff.updated)) {
656
+ if (value === undefined) deleted.add(key);
657
+ else setKeys.add(key);
658
+ }
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];
664
+ if (typeof key === 'string') setKeys.add(key);
665
+ }
666
+ }
667
+ for (const key of deleted) setKeys.delete(key);
668
+ const set = new Map<string, unknown>();
669
+ for (const key of setKeys) set.set(key, entryValue(m, key));
670
+ return { set, deleted: [...deleted] };
671
+ }
672
+
673
+ function pagedMapDelta(
674
+ m: LoroMap,
675
+ batch: LoroEventBatch,
676
+ pageIndex: Map<string, Set<string>>,
677
+ ): Change<unknown> {
678
+ const set = new Map<string, unknown>();
679
+ const deleted = new Set<string>();
680
+ 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);
690
+ }
691
+ if (before) {
692
+ for (const key of before) {
693
+ if (!after || !after.has(key)) deleted.add(key);
694
+ }
695
+ }
696
+ }
697
+ }
698
+ for (const key of deleted) set.delete(key);
699
+ return { set, deleted: [...deleted] };
700
+ }
701
+
702
+ // Run a flat-draft `mutate` against a PAGED store. The draft proxies an
703
+ // OVERLAY of in-batch changes (key -> record, or a tombstone for a delete);
704
+ // reads consult the overlay first then the stored page, so an edit is
705
+ // 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).
710
+ function mutatePaged<T>(
711
+ m: LoroMap,
712
+ pageCount: number,
713
+ fn: (draft: Record<string, T>) => void,
714
+ ): 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
+ const TOMBSTONE = Symbol('deleted');
724
+ // In-batch changes: key -> new record, or TOMBSTONE when deleted. Absent
725
+ // means "unchanged, read from the page".
726
+ const overlay = new Map<string, FlatRecord | typeof TOMBSTONE>();
727
+
728
+ // The record a draft read should see: the overlay value if touched this
729
+ // batch, else the stored record (one page decode).
730
+ const currentRecord = (key: string): FlatRecord | undefined => {
731
+ if (overlay.has(key)) {
732
+ const v = overlay.get(key)!;
733
+ return v === TOMBSTONE ? undefined : v;
734
+ }
735
+ return recordFromPage(m, key, pageCount);
736
+ };
737
+
738
+ // Per-record sub-proxy: a field assignment writes through to the overlay
739
+ // record so a later read sees it and the page rewrite picks it up. Field
740
+ // reads return the deep-plain stored value, so spreading `{ ...rec.params }`
741
+ // never leaks a live handle.
742
+ const recordProxy = (key: string): Record<string, unknown> => {
743
+ return new Proxy(
744
+ {},
745
+ {
746
+ get(_t, field) {
747
+ if (typeof field === 'symbol') return undefined;
748
+ const rec = currentRecord(key);
749
+ return rec ? rec[field] : undefined;
750
+ },
751
+ set(_t, field, value) {
752
+ if (typeof field === 'symbol') return false;
753
+ const base: FlatRecord = { ...currentRecord(key) };
754
+ base[field] = toPlainDeep(value);
755
+ overlay.set(key, base);
756
+ return true;
757
+ },
758
+ },
759
+ ) as Record<string, unknown>;
760
+ };
761
+
762
+ const draft = new Proxy(
763
+ {},
764
+ {
765
+ get(_t, key) {
766
+ if (typeof key === 'symbol') return undefined;
767
+ if (currentRecord(key as string) === undefined) return undefined;
768
+ return recordProxy(key as string);
769
+ },
770
+ set(_t, key, value) {
771
+ if (typeof key === 'symbol') return false;
772
+ overlay.set(
773
+ key as string,
774
+ toPlainDeep(value) as FlatRecord,
775
+ );
776
+ return true;
777
+ },
778
+ deleteProperty(_t, key) {
779
+ if (typeof key === 'symbol') return false;
780
+ overlay.set(key as string, TOMBSTONE);
781
+ return true;
782
+ },
783
+ has(_t, key) {
784
+ if (typeof key === 'symbol') return false;
785
+ return currentRecord(key as string) !== undefined;
786
+ },
787
+ ownKeys() {
788
+ // Stored keys plus added, minus deleted.
789
+ const keys = new Set(Object.keys(flattenPagedMap(m)));
790
+ for (const [k, v] of overlay) {
791
+ if (v === TOMBSTONE) keys.delete(k);
792
+ else keys.add(k);
793
+ }
794
+ return [...keys];
795
+ },
796
+ getOwnPropertyDescriptor(_t, key) {
797
+ if (typeof key === 'symbol') return undefined;
798
+ if (currentRecord(key as string) === undefined) return undefined;
799
+ return { enumerable: true, configurable: true, writable: true };
800
+ },
801
+ },
802
+ ) as Record<string, T>;
803
+
804
+ fn(draft);
805
+ if (overlay.size === 0) return;
806
+
807
+ applyOverlay(m, pageCount, overlay, TOMBSTONE);
808
+ }
809
+
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);
836
+ }
837
+ }
838
+ }
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;
849
+ }
850
+ const page = m.setContainer(pageKey, new LoroMap());
851
+ page.set(TAG_FIELD, PAGE_TAG);
852
+ page.set(BODY_FIELD, encodePageBody(triples));
853
+ }
854
+ }
855
+
856
+ // ---- Concrete handle implementations ----------------------------------
857
+ //
858
+ // Both handles wrap an "anchor": a root LoroMap/LoroList directly, or a
859
+ // (parent LoroMap, key) pair. The anchor gives the handle stable identity
860
+ // even before the container exists (a `doc.map('foo').map('bar')` chain
861
+ // returns a working handle for `foo.bar`, created on the first mutate).
862
+
863
+ interface MapAnchor {
864
+ // The current LoroMap, or null if it doesn't exist yet. MUST return the
865
+ // SAME instance across calls once created; Loro container identity is what
866
+ // its per-container subscribe attaches to.
867
+ resolve(): LoroMap | null;
868
+ // The LoroMap, creating it if necessary. Only called inside a mutate
869
+ // context (the caller has entered the commit pipeline).
870
+ resolveOrCreate(): LoroMap;
871
+ // Drop any memoized container so the next `resolve` re-derives from the
872
+ // parent. A child anchor memoizes its resolved container for stable
873
+ // subscribe identity; on a source swap (version checkout / return-to-head)
874
+ // the parent rebinds but the child would keep returning the old source's
875
+ // container, so `_resync` invalidates it. A no-op for root anchors, which
876
+ // re-point via `rebind`.
877
+ invalidate(): void;
878
+ }
879
+
880
+ interface ListAnchor {
881
+ resolve(): LoroList | null;
882
+ resolveOrCreate(): LoroList;
883
+ invalidate(): void;
884
+ }
885
+
886
+ // A root anchor is re-pointable: a version checkout swaps the Document's
887
+ // active source from the live doc to a materialized preview doc (a DIFFERENT
888
+ // LoroDoc), and the handles the app already holds must follow. `rebind` moves
889
+ // the anchor to the new container; every read (`read` / `get` / `keys` /
890
+ // `has`) already routes through `resolve()`, so it reflects the new source
891
+ // with no further change. Between rebinds `resolve()` returns the same
892
+ // instance, satisfying the per-container subscribe contract.
893
+ interface RootMapAnchor extends MapAnchor {
894
+ rebind(next: LoroMap): void;
895
+ }
896
+ interface RootListAnchor extends ListAnchor {
897
+ rebind(next: LoroList): void;
898
+ }
899
+
900
+ // A root anchor holds one container that a version checkout swaps in place
901
+ // (`rebind`). Between rebinds `resolve()` returns the same instance, so the
902
+ // 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>(
905
+ root: C,
906
+ ): {
907
+ resolve(): C;
908
+ resolveOrCreate(): C;
909
+ invalidate(): void;
910
+ rebind(next: C): void;
911
+ } {
912
+ let current = root;
913
+ return {
914
+ resolve: () => current,
915
+ resolveOrCreate: () => current,
916
+ invalidate: () => {},
917
+ rebind: (next) => {
918
+ current = next;
919
+ },
920
+ };
921
+ }
922
+
923
+ // A child anchor resolves `parent[key]` lazily, then caches the container so
924
+ // per-container subscribers attach to a stable instance. `ctor` selects the
925
+ // container kind: a value of the wrong kind at the key reads as absent, and
926
+ // `resolveOrCreate` mints one only inside a mutate context.
927
+ function childAnchor<C extends LoroMap | LoroList>(
928
+ parent: MapAnchor,
929
+ key: string,
930
+ ctor: new () => C,
931
+ ): { resolve(): C | null; resolveOrCreate(): C; invalidate(): void } {
932
+ // `invalidate` clears the cache on a source swap so the next resolve
933
+ // re-derives through the (rebound) parent.
934
+ let cached: C | null = null;
935
+ // A cached container is stale once it has been detached from the document
936
+ // (deleted from `parent[key]`), which happens on a delete-then-recreate of
937
+ // the key: the recreate mints a NEW container, so a cache holding the old
938
+ // one would read and subscribe against a dead container. `isDeleted()` is
939
+ // the cheap per-resolve check that keeps the cache without going stale;
940
+ // `invalidate()` still handles the source swap (checkout / resync).
941
+ const live = (c: C | null): c is C => c !== null && !c.isDeleted();
942
+ return {
943
+ resolve: () => {
944
+ if (live(cached)) return cached;
945
+ cached = null;
946
+ const p = parent.resolve();
947
+ if (!p) return null;
948
+ const v = p.get(key);
949
+ if (v instanceof ctor) {
950
+ cached = v as C;
951
+ return cached;
952
+ }
953
+ return null;
954
+ },
955
+ resolveOrCreate: () => {
956
+ if (live(cached)) return cached;
957
+ cached = null;
958
+ const p = parent.resolveOrCreate();
959
+ const v = p.get(key);
960
+ if (v instanceof ctor) {
961
+ cached = v as C;
962
+ return cached;
963
+ }
964
+ const created = p.setContainer(key, new ctor());
965
+ cached = created;
966
+ return created;
967
+ },
968
+ invalidate: () => {
969
+ cached = null;
970
+ },
971
+ };
972
+ }
973
+
974
+ // Shared per-container subscription bookkeeping for both handle kinds. Holds
975
+ // the active { callback, detach } records so a version checkout can re-fire
976
+ // and re-attach each against the swapped source, and a suspend / close can
977
+ // detach them all. Each kind passes in its own whole-container fire and its
978
+ // per-container change-attach; this owns only the record set and its
979
+ // lifecycle. (The `createEmitter` primitive is a value-fanout with retain /
980
+ // dedup, a different shape: it models no per-listener external resource, and
981
+ // the Loro subscription each record must re-create on rebind is exactly
982
+ // that.)
983
+ class SubscriptionRegistry<Cb> {
984
+ private readonly _subs = new Set<{ cb: Cb; detach: () => void }>();
985
+
986
+ // Fire the whole-container catch-up once, attach the per-container change
987
+ // subscription, track the record, and return an idempotent unsubscribe.
988
+ subscribe(
989
+ cb: Cb,
990
+ fireWhole: (cb: Cb) => void,
991
+ attach: (cb: Cb) => () => void,
992
+ ): HandleUnsubscribe {
993
+ fireWhole(cb);
994
+ const rec = { cb, detach: attach(cb) };
995
+ this._subs.add(rec);
996
+ return () => {
997
+ rec.detach();
998
+ this._subs.delete(rec);
999
+ };
1000
+ }
1001
+
1002
+ // Re-fire every active subscriber with the whole current container and
1003
+ // re-attach its change subscription to it (checkout / return-to-head).
1004
+ 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);
1009
+ }
1010
+ }
1011
+
1012
+ // Detach every active subscription and forget it (suspend / close).
1013
+ teardown(): void {
1014
+ for (const rec of this._subs) rec.detach();
1015
+ this._subs.clear();
1016
+ }
1017
+ }
1018
+
1019
+ class MapHandleImpl<T> implements MapHandle<T> {
1020
+ private readonly _anchor: MapAnchor;
1021
+ 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;
1030
+ private readonly _childMaps = new Map<string, MapHandleImpl<unknown>>();
1031
+ private readonly _childLists = new Map<string, ListHandleImpl<unknown>>();
1032
+ // Active subscriptions, tracked so a version checkout (which swaps the
1033
+ // active source doc) can re-fire and re-attach each one against the new
1034
+ // container.
1035
+ private readonly _registry = new SubscriptionRegistry<
1036
+ InternalMapSubscriber<T>
1037
+ >();
1038
+ // Keys captured just before a rebind (`_rebindAnchor`), consumed by the
1039
+ // matching `_resync` to report the keys the source swap removed. Null
1040
+ // outside a rebind cycle, so a bare `_resync` reports no removals.
1041
+ private _priorKeys: Set<string> | null = null;
1042
+
1043
+ constructor(
1044
+ anchor: MapAnchor,
1045
+ owner: HandleOwner,
1046
+ pageCount: (() => number) | null = null,
1047
+ pagingScheme: (() => number) | null = null,
1048
+ ) {
1049
+ this._anchor = anchor;
1050
+ this._owner = owner;
1051
+ this._pageCount = pageCount;
1052
+ this._pagingScheme = pagingScheme;
1053
+ }
1054
+
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).
1060
+ private _isPaged(): boolean {
1061
+ return this._pageCount !== null && this._pageCount() > 0;
1062
+ }
1063
+
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());
1075
+ }
1076
+
1077
+ read(): Record<string, T> {
1078
+ const m = this._anchor.resolve();
1079
+ if (!m) return {};
1080
+ // A paged map flattens back to the flat lookup, so a read is identical
1081
+ // paged or not.
1082
+ if (this._isPaged()) return flattenPagedMap(m) as Record<string, T>;
1083
+ return m.toJSON() as Record<string, T>;
1084
+ }
1085
+
1086
+ get(entryKey: string): T | undefined {
1087
+ const m = this._anchor.resolve();
1088
+ if (!m) return undefined;
1089
+ 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;
1098
+ }
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;
1104
+ }
1105
+
1106
+ keys(): string[] {
1107
+ const m = this._anchor.resolve();
1108
+ if (!m) return [];
1109
+ if (this._isPaged()) return Object.keys(flattenPagedMap(m));
1110
+ return m.keys();
1111
+ }
1112
+
1113
+ has(entryKey: string): boolean {
1114
+ const m = this._anchor.resolve();
1115
+ if (!m) return false;
1116
+ if (this._isPaged()) return this.get(entryKey) !== undefined;
1117
+ return m.get(entryKey) !== undefined;
1118
+ }
1119
+
1120
+ mutate(fn: (draft: Record<string, T>) => void): void {
1121
+ this._owner.assertWritable();
1122
+ this._owner.ensureLocalUpdatesSubscribed();
1123
+ // Commits as it goes past a size budget; see `mutate-batching.ts`.
1124
+ this._runMutate(
1125
+ fn,
1126
+ new MutateBatcher(() => {
1127
+ this._owner.commitMutate();
1128
+ }),
1129
+ );
1130
+ this._owner.commitMutate();
1131
+ }
1132
+
1133
+ // Run a mutate WITHOUT the commit pipeline (the caller owns
1134
+ // assertWritable / ensureLocalUpdatesSubscribed / commit). The seam the
1135
+ // ordered-map primitive drives so a map edit and its sibling order-list
1136
+ // edit land in ONE commit (one undo step).
1137
+ _runMutate(
1138
+ fn: (draft: Record<string, T>) => void,
1139
+ batcher?: MutateBatcher,
1140
+ ): void {
1141
+ const m = this._anchor.resolveOrCreate();
1142
+ 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);
1154
+ return;
1155
+ }
1156
+ const proxy = wrapLoroMapProxy(m, batcher) as Record<string, T>;
1157
+ fn(proxy);
1158
+ }
1159
+
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();
1166
+ }
1167
+
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.
1172
+ _existsChecker(): (key: string) => boolean {
1173
+ const m = this._anchor.resolve();
1174
+ if (!m) return () => false;
1175
+ 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;
1184
+ }
1185
+ return (key) => m.get(key) !== undefined;
1186
+ }
1187
+
1188
+ // Fire a subscriber once with the whole map as `change.set`, so a
1189
+ // subscriber is ONE loop with no initial-vs-update branch and no
1190
+ // `if (value)` guard (the footgun that left a view stale after an undo /
1191
+ // remote delete emptied the container). Flagged `resync: true` and carries
1192
+ // no `origin`: this is a whole-container catch-up (registration, or a
1193
+ // checkout / return swapping the source), not an authored transition.
1194
+ //
1195
+ // `deleted` is empty for a registration fire (the subscriber holds no prior
1196
+ // state). On a resync it carries the keys that were present before the
1197
+ // source swap but are gone after it: a checkout to a version with fewer
1198
+ // records must tell a delta-reconcile subscriber to drop those keys, or they
1199
+ // linger as phantom records in the preview. `_resync` computes it.
1200
+ private _fireWhole(
1201
+ cb: InternalMapSubscriber<T>,
1202
+ deleted: string[] = [],
1203
+ ): void {
1204
+ try {
1205
+ cb(
1206
+ { set: new Map<string, T>(Object.entries(this.read())), deleted },
1207
+ undefined,
1208
+ true,
1209
+ );
1210
+ } catch {
1211
+ // A throwing subscriber must not break registration / resync.
1212
+ }
1213
+ }
1214
+
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.
1219
+ 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 () => {};
1226
+ // Paged delta needs prior page state to spot deleted records, so index
1227
+ // the pages once now; the index advances on every fire or its next delete
1228
+ // 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);
1231
+ const sub = m.subscribe((event) => {
1232
+ const change = (
1233
+ pageIndex
1234
+ ? pagedMapDelta(m, event, pageIndex)
1235
+ : unpagedMapDelta(m, rootKey, event)
1236
+ ) as Change<T>;
1237
+ const origin: ChangeOrigin = { source: this._owner.originOf(event) };
1238
+ try {
1239
+ cb(change, origin, false);
1240
+ } catch {
1241
+ // A throwing subscriber must not affect the others.
1242
+ }
1243
+ });
1244
+ return () => sub();
1245
+ }
1246
+
1247
+ subscribe(cb: MapSubscriber<T>): HandleUnsubscribe {
1248
+ // App subscribers see `(change, origin?)`; the internal re-sync flag is
1249
+ // dropped here so it never leaks into the public surface.
1250
+ return this._subscribeInternal((change, origin) => cb(change, origin));
1251
+ }
1252
+
1253
+ // Internal: like `subscribe`, but the callback also receives whether the
1254
+ // fire is a whole-container re-sync. Only the ordered-map wrapper uses it,
1255
+ // to re-check `instance_order` on a source swap without inspecting `origin`.
1256
+ _subscribeInternal(cb: InternalMapSubscriber<T>): HandleUnsubscribe {
1257
+ return this._registry.subscribe(
1258
+ cb,
1259
+ (c) => this._fireWhole(c),
1260
+ (c) => this._attachChange(c),
1261
+ );
1262
+ }
1263
+
1264
+ // Re-point this ROOT handle to `next` (a container in the newly-active
1265
+ // source doc) WITHOUT firing yet. Called by the Document on a version
1266
+ // checkout / return-to-head. Snapshots the subtree's current keys first,
1267
+ // before the anchor moves, so the subsequent `_resync` can report which keys
1268
+ // the swap removed. Rebind and resync are two phases (see
1269
+ // `_rebindRootHandles`) because the ordered map reads its sibling order list
1270
+ // while firing its map: both anchors must already point at the new source
1271
+ // when either fires, or the map redraw reads a half-swapped order list.
1272
+ _rebindAnchor(next: LoroMap): void {
1273
+ this._snapshotPriorKeys();
1274
+ (this._anchor as RootMapAnchor).rebind(next);
1275
+ }
1276
+
1277
+ // 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 {
1283
+ this._priorKeys = new Set(Object.keys(this.read()));
1284
+ for (const child of this._childMaps.values()) child._snapshotPriorKeys();
1285
+ }
1286
+
1287
+ // Re-fire every active subscriber with the whole current container (plus the
1288
+ // keys the source swap removed, from the pre-rebind snapshot) and re-attach
1289
+ // its change subscription to it, then recurse into composed child handles
1290
+ // (their anchors resolve through this one, so they already point at the new
1291
+ // source; their subscriptions still need re-attaching). Idempotent and safe
1292
+ // when there are no subscribers.
1293
+ _resync(): void {
1294
+ // Re-derive this handle's container from the (already-rebound) parent
1295
+ // before reading or re-attaching: a child anchor caches its resolved
1296
+ // container, and after a source swap that cache points at the old source.
1297
+ // Prior keys were snapshotted pre-rebind, so this cannot lose them.
1298
+ this._anchor.invalidate();
1299
+ const prior = this._priorKeys;
1300
+ this._priorKeys = null;
1301
+ const currentKeys = new Set(Object.keys(this.read()));
1302
+ const removed = prior
1303
+ ? [...prior].filter((k) => !currentKeys.has(k))
1304
+ : [];
1305
+ this._registry.resync(
1306
+ (c) => this._fireWhole(c, removed),
1307
+ (c) => this._attachChange(c),
1308
+ );
1309
+ for (const child of this._childMaps.values()) child._resync();
1310
+ for (const child of this._childLists.values()) child._resync();
1311
+ }
1312
+
1313
+ // Detach every active per-container subscription (this handle's and its
1314
+ // composed children's) and forget them. The Document calls this when it
1315
+ // suspends (parks) or closes: a parked doc keeps applying remote ops to its
1316
+ // live containers, so a Loro subscription an app registered before `close()`
1317
+ // would otherwise keep firing the app's callback on a doc it has closed.
1318
+ _teardownSubs(): void {
1319
+ this._registry.teardown();
1320
+ for (const child of this._childMaps.values()) child._teardownSubs();
1321
+ for (const child of this._childLists.values()) child._teardownSubs();
1322
+ }
1323
+
1324
+ map<U>(entryKey: string): MapHandle<U> {
1325
+ let h = this._childMaps.get(entryKey);
1326
+ if (!h) {
1327
+ const anchor = childAnchor(this._anchor, entryKey, LoroMap);
1328
+ h = new MapHandleImpl<unknown>(anchor, this._owner);
1329
+ this._childMaps.set(entryKey, h);
1330
+ }
1331
+ return h as unknown as MapHandle<U>;
1332
+ }
1333
+
1334
+ list<U>(entryKey: string): ListHandle<U> {
1335
+ let h = this._childLists.get(entryKey);
1336
+ if (!h) {
1337
+ const anchor = childAnchor(this._anchor, entryKey, LoroList);
1338
+ h = new ListHandleImpl<unknown>(anchor, this._owner);
1339
+ this._childLists.set(entryKey, h);
1340
+ }
1341
+ return h as unknown as ListHandle<U>;
1342
+ }
1343
+ }
1344
+
1345
+ class ListHandleImpl<T> implements ListHandle<T> {
1346
+ private readonly _anchor: ListAnchor;
1347
+ private readonly _owner: HandleOwner;
1348
+ // Active subscriptions, tracked so a version checkout can re-fire and
1349
+ // re-attach each one against the new container.
1350
+ private readonly _registry = new SubscriptionRegistry<ListSubscriber>();
1351
+
1352
+ constructor(anchor: ListAnchor, owner: HandleOwner) {
1353
+ this._anchor = anchor;
1354
+ this._owner = owner;
1355
+ }
1356
+
1357
+ read(): T[] {
1358
+ const l = this._anchor.resolve();
1359
+ if (!l) return [];
1360
+ return l.toJSON() as T[];
1361
+ }
1362
+
1363
+ length(): number {
1364
+ const l = this._anchor.resolve();
1365
+ return l ? l.length : 0;
1366
+ }
1367
+
1368
+ mutate(fn: (draft: T[]) => void): void {
1369
+ this._owner.assertWritable();
1370
+ this._owner.ensureLocalUpdatesSubscribed();
1371
+ this._runMutate(fn);
1372
+ this._owner.commitMutate();
1373
+ }
1374
+
1375
+ // Run a list mutate without the commit pipeline; see
1376
+ // `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 {
1379
+ const l = this._anchor.resolveOrCreate();
1380
+ const proxy = wrapLoroListProxy(l) as T[];
1381
+ fn(proxy);
1382
+ }
1383
+
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 {
1387
+ try {
1388
+ cb();
1389
+ } catch {
1390
+ // A throwing subscriber must not break registration / resync.
1391
+ }
1392
+ }
1393
+
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));
1403
+ return () => sub();
1404
+ }
1405
+
1406
+ subscribe(cb: ListSubscriber): HandleUnsubscribe {
1407
+ return this._registry.subscribe(
1408
+ cb,
1409
+ (c) => this._fireWhole(c),
1410
+ (c) => this._attachChange(c),
1411
+ );
1412
+ }
1413
+
1414
+ // Re-point this ROOT list handle to `next` without firing (see
1415
+ // MapHandleImpl._rebindAnchor). Lists carry no per-key delta, so there is
1416
+ // no prior-key snapshot to take; the resync fire is a re-read signal.
1417
+ _rebindAnchor(next: LoroList): void {
1418
+ (this._anchor as RootListAnchor).rebind(next);
1419
+ }
1420
+
1421
+ // Re-fire + re-attach every active subscriber against the current
1422
+ // container (see MapHandleImpl._resync). Lists have no composed children,
1423
+ // but a child list handle (`doc.map(k).list(j)`) has a cached anchor that a
1424
+ // source swap leaves pointing at the old source, so invalidate it first.
1425
+ _resync(): void {
1426
+ this._anchor.invalidate();
1427
+ this._registry.resync(
1428
+ (c) => this._fireWhole(c),
1429
+ (c) => this._attachChange(c),
1430
+ );
1431
+ }
1432
+
1433
+ // Detach every active subscription and forget it (see
1434
+ // MapHandleImpl._teardownSubs). Lists have no composed children.
1435
+ _teardownSubs(): void {
1436
+ this._registry.teardown();
1437
+ }
1438
+ }
1439
+
1440
+ // The ordered map composes a map handle and a list handle and keeps them in
1441
+ // lockstep. It reuses the concrete impls (not the interfaces) to drive their
1442
+ // no-commit `_runMutate` seams and the map's `_existsChecker`, so both halves
1443
+ // of a structural edit commit once. Built over the SAME handles
1444
+ // `doc.map(mapKey)` / `doc.list(orderKey)` return, so subscriptions stay
1445
+ // consistent.
1446
+ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
1447
+ constructor(
1448
+ private readonly _map: MapHandleImpl<T>,
1449
+ private readonly _order: ListHandleImpl<string>,
1450
+ private readonly _owner: HandleOwner,
1451
+ ) {}
1452
+
1453
+ read(): Record<string, T> {
1454
+ return this._map.read();
1455
+ }
1456
+
1457
+ get(entryKey: string): T | undefined {
1458
+ return this._map.get(entryKey);
1459
+ }
1460
+
1461
+ has(entryKey: string): boolean {
1462
+ return this._map.has(entryKey);
1463
+ }
1464
+
1465
+ 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));
1479
+ }
1480
+
1481
+ ordered(): { key: string; value: T }[] {
1482
+ const records = this._map.read();
1483
+ const out: { key: string; value: T }[] = [];
1484
+ for (const key of this._order.read()) {
1485
+ if (Object.prototype.hasOwnProperty.call(records, key)) {
1486
+ out.push({ key, value: records[key] as T });
1487
+ }
1488
+ }
1489
+ return out;
1490
+ }
1491
+
1492
+ mutate(fn: (draft: Record<string, T>) => void): void {
1493
+ this._owner.assertWritable();
1494
+ this._owner.ensureLocalUpdatesSubscribed();
1495
+
1496
+ // Net structural effect: keys added (`added` iterates in insertion
1497
+ // order, and a delete-then-re-set re-appends, matching the order list's
1498
+ // append semantics) and removed. A field edit routes through the inner
1499
+ // record sub-proxy and never trips the top-level set / delete traps, so
1500
+ // it leaves the order list alone.
1501
+ const added = new Set<string>();
1502
+ const removed = new Set<string>();
1503
+
1504
+ // Run at each batch boundary, so a commit carries the map writes AND
1505
+ // the order entries for those same records. Left to the end instead,
1506
+ // the final commit carries every key at once: measured at 229 KB for a
1507
+ // 20,000-record write, an oversized op of its own.
1508
+ const reconcileOrder = (): void => {
1509
+ if (added.size === 0 && removed.size === 0) return;
1510
+ 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);
1515
+ }
1516
+ for (const k of added) arr.push(k);
1517
+ });
1518
+ added.clear();
1519
+ removed.clear();
1520
+ };
1521
+
1522
+ const batcher = this._map._canBatchMutate()
1523
+ ? new MutateBatcher(() => {
1524
+ reconcileOrder();
1525
+ this._owner.commitMutate();
1526
+ })
1527
+ : null;
1528
+
1529
+ this._map._runMutate((inner) => {
1530
+ // "is this key new" (and so must be appended to the order list) is O(1)
1531
+ // 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.
1540
+ const exists = this._map._existsChecker();
1541
+ const tracking = new Proxy(
1542
+ {},
1543
+ {
1544
+ get: (_t, key) =>
1545
+ typeof key === 'symbol'
1546
+ ? (inner as Record<symbol, unknown>)[key]
1547
+ : (inner as Record<string, unknown>)[key],
1548
+ set: (_t, key, value) => {
1549
+ if (typeof key === 'symbol') return false;
1550
+ const k = key as string;
1551
+ if (!added.has(k) && !exists(k) && !removed.has(k)) {
1552
+ added.add(k);
1553
+ }
1554
+ // A re-add after a delete in the same batch cancels the delete.
1555
+ removed.delete(k);
1556
+ (inner as Record<string, unknown>)[k] = value;
1557
+ return true;
1558
+ },
1559
+ deleteProperty: (_t, key) => {
1560
+ if (typeof key === 'symbol') return false;
1561
+ const k = key as string;
1562
+ if (added.has(k)) {
1563
+ // Added and removed in one batch: it never reached the order
1564
+ // list, so just forget the addition.
1565
+ added.delete(k);
1566
+ } 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.
1578
+ removed.add(k);
1579
+ }
1580
+ delete (inner as Record<string, unknown>)[k];
1581
+ return true;
1582
+ },
1583
+ has: (_t, key) =>
1584
+ typeof key === 'symbol'
1585
+ ? false
1586
+ : key in (inner as Record<string, unknown>),
1587
+ ownKeys: () => Reflect.ownKeys(inner as object),
1588
+ getOwnPropertyDescriptor: (_t, key) =>
1589
+ Object.getOwnPropertyDescriptor(inner as object, key),
1590
+ },
1591
+ ) as Record<string, T>;
1592
+ fn(tracking);
1593
+ }, batcher ?? undefined);
1594
+
1595
+ reconcileOrder();
1596
+ this._owner.commitMutate();
1597
+ }
1598
+
1599
+ 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`.
1606
+ //
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`).
1616
+ const known = new Set<string>();
1617
+ 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;
1627
+ }
1628
+ if (!moved) {
1629
+ cb(change, origin);
1630
+ return;
1631
+ }
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
+ cb({ set: change.set, deleted: change.deleted, order }, origin);
1637
+ });
1638
+ }
1639
+ }
1640
+
1641
+ // Factory functions called by the Document. App code reaches handles via
1642
+ // `doc.map(...)` / `doc.list(...)`.
1643
+
1644
+ // `pageCount`, when supplied, lets the root map present a PAGED record store
1645
+ // transparently as a flat record map. The Document supplies it for every root
1646
+ // map; a non-paged map never consults it, so passing it to non-record roots
1647
+ // is harmless. Omitted means the handle only serves the plain unpaged shape.
1648
+ export function createRootMapHandle<T>(
1649
+ root: LoroMap,
1650
+ owner: HandleOwner,
1651
+ pageCount?: () => number,
1652
+ pagingScheme?: () => number,
1653
+ ): MapHandle<T> {
1654
+ return new MapHandleImpl<T>(
1655
+ rootAnchor(root),
1656
+ owner,
1657
+ pageCount ?? null,
1658
+ pagingScheme ?? null,
1659
+ );
1660
+ }
1661
+
1662
+ export function createRootListHandle<T>(
1663
+ root: LoroList,
1664
+ owner: HandleOwner,
1665
+ ): ListHandle<T> {
1666
+ return new ListHandleImpl<T>(rootAnchor(root), owner);
1667
+ }
1668
+
1669
+ // Re-point a ROOT handle to a container in a newly-active source doc and
1670
+ // resync its subscribers, in two phases. The Document calls these on a version
1671
+ // checkout / return-to-head so the handles an app already holds (its
1672
+ // `doc.map(key)` / `doc.list(key)` / `doc.orderedMap(...)` references) follow
1673
+ // the source swap and redraw, instead of being discarded and silently
1674
+ // detached. The cast is safe because this module is the only producer of these
1675
+ // interfaces.
1676
+ //
1677
+ // The two phases must be run across ALL root handles as two passes (rebind
1678
+ // every anchor, THEN resync every handle), not handle-by-handle, because a
1679
+ // composed handle can read a SIBLING root while firing: the ordered map reads
1680
+ // its order list during its map redraw. If the order list's anchor were still
1681
+ // on the old source when the map fired, the redraw would read a half-swapped
1682
+ // pair and drop the keys the order list had not caught up on.
1683
+ export function rebindRootMapAnchor(
1684
+ handle: MapHandle<unknown>,
1685
+ next: LoroMap,
1686
+ ): void {
1687
+ (handle as unknown as MapHandleImpl<unknown>)._rebindAnchor(next);
1688
+ }
1689
+
1690
+ export function rebindRootListAnchor(
1691
+ handle: ListHandle<unknown>,
1692
+ next: LoroList,
1693
+ ): void {
1694
+ (handle as unknown as ListHandleImpl<unknown>)._rebindAnchor(next);
1695
+ }
1696
+
1697
+ // Re-fire + re-attach a root handle's subscribers against its current
1698
+ // container. Both concrete impls expose `_resync`, so one generic reaches
1699
+ // either kind; the cast is safe because this module is the only producer of
1700
+ // these handles.
1701
+ export function resyncRootHandle(
1702
+ handle: MapHandle<unknown> | ListHandle<unknown>,
1703
+ ): void {
1704
+ (handle as unknown as { _resync(): void })._resync();
1705
+ }
1706
+
1707
+ // Detach a handle's live subscriptions (its own and its children's). The
1708
+ // Document calls this when it suspends (parks) or closes, so a subscription
1709
+ // registered before `close()` stops firing against the still-live containers
1710
+ // a parked doc keeps applying remote ops to. As with `resyncRootHandle`, one
1711
+ // generic reaches either kind.
1712
+ export function teardownRootHandle(
1713
+ handle: MapHandle<unknown> | ListHandle<unknown>,
1714
+ ): void {
1715
+ (handle as unknown as { _teardownSubs(): void })._teardownSubs();
1716
+ }
1717
+
1718
+ // Bind a map handle and a list handle into an ordered map. The cast to the
1719
+ // concrete impls is safe because this module is the only producer of those
1720
+ // interfaces, and it lets the ordered map drive their no-commit seams.
1721
+ export function createOrderedMapHandle<T>(
1722
+ map: MapHandle<T>,
1723
+ order: ListHandle<string>,
1724
+ owner: HandleOwner,
1725
+ ): OrderedMapHandle<T> {
1726
+ return new OrderedMapHandleImpl<T>(
1727
+ map as MapHandleImpl<T>,
1728
+ order as ListHandleImpl<string>,
1729
+ owner,
1730
+ );
1731
+ }