@brass-build/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
@@ -2,22 +2,31 @@
2
2
  // `instances` map into page containers, and how to undo that back to a flat
3
3
  // `key -> { type, params }` lookup.
4
4
  //
5
- // It lives in the SDK (not in `formats/core`, where the record store lives)
6
- // because the published `@brass-build/client` ships unbundled and can only
7
- // import published packages and its own relative files; `formats/core` is
8
- // neither. The substrate imports these helpers back from here, so the codec is
9
- // defined ONCE and the SDK's paged read / edit cannot drift from how the store
10
- // is written. Deliberately loro-free (fflate, a stable hash, JSON), so it
11
- // stays a pure value codec on both sides.
5
+ // It lives here because the published `@brass-build/client` ships unbundled
6
+ // and can only import published packages and its own relative files. The
7
+ // platform's own record-store writer imports these helpers back from here, so
8
+ // the codec is defined ONCE and the SDK's paged read / edit cannot drift from
9
+ // how the store is written. Deliberately loro-free (fflate, JSON), so it stays
10
+ // a pure value codec on both sides.
12
11
  //
13
- // Wire recap (full rationale in `formats/core/src/record-store.ts`): the root
12
+ // Wire recap: the root
14
13
  // `instances` LoroMap is EITHER unpaged (key -> `{ type, params }`, params a
15
14
  // plain value) or, above `UNPAGED_MAX_RECORDS` records, PAGED: keyed
16
- // `p0,p1,...` -> a `{ type: '__page__', params: <string> }` container whose
17
- // params is a gzipped latin1 string of a JSON array of `[key, tag, body]`
18
- // triples. `meta.page_count` carries the count; `instance_order` lists record
19
- // keys in order in both shapes. A record's page is a hash of its key, so it is
20
- // resolved with no stored index.
15
+ // `brass:p:<peer>:<n>` -> a
16
+ // `{ type: '__page__', params: <string>, keys: <string> }` container. `params`
17
+ // is a gzipped latin1 string of a JSON array of `[tag, body]` pairs and `keys`
18
+ // the same encoding of the record keys those pairs belong to, in the same
19
+ // order. A `meta` key per root (`paged:<root>`) marks that root paged;
20
+ // `instance_order` lists record keys in order in both shapes. A page is filled
21
+ // in creation order, so a reader locates a record through the index it builds
22
+ // from the pages' `keys` fields.
23
+ //
24
+ // Every entry the store writes for its own bookkeeping (a page, a record's own
25
+ // container, a delete marker, a patch) lives under `RESERVED_KEY_PREFIX`, and
26
+ // the map handle refuses an app key inside it. That is what keeps the two key
27
+ // spaces apart: a record and the bookkeeping for a record share one map, so a
28
+ // reader deciding which is which by anything but the key would be deciding it
29
+ // from data an app controls.
21
30
  import { gzipSync, gunzipSync, strToU8, strFromU8 } from 'fflate';
22
31
  let nativeZlib;
23
32
  function zlibBackend() {
@@ -50,110 +59,375 @@ export const BODY_FIELD = 'params';
50
59
  export const META_KEY = 'meta';
51
60
  export const RECORDS_KEY = 'instances';
52
61
  export const ORDER_KEY = 'instance_order';
53
- /** `meta` key holding a paged store's page count; absent (or 0) means
54
- * unpaged. The read path detects pages by tag; only an in-place edit needs
55
- * the count, to hash a key to its page. */
56
- export const PAGE_COUNT_KEY = 'page_count';
57
- /** `meta` key holding the paging codec's scheme number, stamped on a paged
58
- * write. The scheme fixes the page-key hash (`pageKeyFor`), the `p<n>` key
59
- * format, and the packed-record layout together: a reader that hashes a key
60
- * with a different scheme than the store was written under targets the wrong
61
- * page on an in-place edit, silently writing a record into a page it does not
62
- * belong to. The stamp lets a pinned reader detect a store written under a
63
- * scheme it does not implement and refuse the edit instead of corrupting it.
64
- * Absent means the current scheme (paged stores written before the stamp
65
- * existed used scheme 1's hash and layout). */
66
- export const PAGING_SCHEME_KEY = 'paging_scheme';
67
- /** The paging scheme this build writes and can edit. Bump ONLY alongside a
68
- * change to `pageKeyFor`'s hash, the `p<n>` key format, or the packed-record
69
- * layout, so an older pinned reader trips `isPagingSchemeEditable` and fails
70
- * loud rather than editing a store it would mis-hash. */
71
- export const CURRENT_PAGING_SCHEME = 1;
72
- /** Whether a store stamped with `scheme` can be edited in place by this build.
73
- * Absent / non-numeric / `0` is a scheme-1 store (the stamp postdates it), so
74
- * editable; any scheme at or below the current one is editable; a higher
75
- * scheme is not (this reader would hash into the wrong page). Reads stay
76
- * best-effort and are not gated here; only an in-place edit can persist the
77
- * mis-hash, so the edit path is where the guard belongs. */
78
- export function isPagingSchemeEditable(scheme) {
79
- if (typeof scheme !== 'number' || !Number.isFinite(scheme) || scheme <= 0) {
80
- return true;
81
- }
82
- return scheme <= CURRENT_PAGING_SCHEME;
62
+ /** Prefix on the `meta` key marking ONE root map paged (`meta` holds
63
+ * `paged:instances` -> `true`). A root whose key is absent, or holds anything
64
+ * but `true`, is unpaged; a document with no paged root carries no such key.
65
+ *
66
+ * Per root rather than one flag for the document, so paging is a property of
67
+ * a collection rather than of a reserved name. One flag would force the read
68
+ * side to hardcode WHICH root it described, since a document-wide answer
69
+ * handed to every root would make `meta` and `header` read as paged the
70
+ * moment the records did. Keyed by root, a root with no key is simply
71
+ * unpaged, which is the answer those roots want anyway.
72
+ *
73
+ * A key per root rather than one entry naming them all. A map entry is
74
+ * written whole, so an entry holding every paged root is read-modify-write:
75
+ * two peers crossing the threshold on two different roots at once each write
76
+ * the whole entry, the two resolve last-writer-wins, and the root the losing
77
+ * write named is left unmarked. Nothing recovers it. Every reader then takes
78
+ * the unpaged path over a map holding nothing but pages, so the store's own
79
+ * bookkeeping keys read back as the records and the records reach nobody,
80
+ * while the raw entry count sits far below the threshold that would page the
81
+ * root again. A key per root means two roots are two keys, which merge.
82
+ *
83
+ * A flag rather than a page count. Pages are keyed by writing session and
84
+ * index (`brass:p:<peer>:<n>`) and a reader locates a record through the index
85
+ * it builds from the pages' `keys` fields, so no reader needs to know how many
86
+ * pages there are; a stored count would be a number that drifts as the store
87
+ * grows and that nothing checks.
88
+ *
89
+ * A plain value rather than a container: it is read on the hot path
90
+ * (`_isPaged` on every keyed read and edit), where one `get` of a scalar is
91
+ * the cheapest answer available. */
92
+ export const PAGED_ROOT_PREFIX = 'paged:';
93
+ /** The `meta` key marking `rootKey` paged. */
94
+ export function pagedRootKey(rootKey) {
95
+ return `${PAGED_ROOT_PREFIX}${rootKey}`;
96
+ }
97
+ /** Whether a value read from `meta` at `pagedRootKey(root)` marks that root
98
+ * paged. The one place the marker's stored form is interpreted, so the SDK's
99
+ * handle path and the store readers cannot disagree about what marks a root. */
100
+ export function isPagedMarker(marker) {
101
+ return marker === true;
83
102
  }
84
103
  /** Above this many records the store is written paged; at or below it it keeps
85
- * the one-container-per-record shape. */
86
- export const UNPAGED_MAX_RECORDS = 50_000;
87
- // Target records per page and a page-count ceiling so even the 2M-record cap
88
- // stays out of the slow zone. Mirrored from the substrate's write path so
89
- // `pageCountFor` agrees with how the store was written.
104
+ * the one-container-per-record shape.
105
+ *
106
+ * Set where paging starts paying for itself on every axis but one. Measured
107
+ * on the two shapes at equal record counts (`scripts/bench/paging-threshold.mts`),
108
+ * the paged store writes ~3x faster, opens ~5x faster, and stores ~1.7x
109
+ * smaller here, against ~1.4x on a single edit, and that last penalty
110
+ * inverts by 25,000 records. Below a few hundred records the paged store is
111
+ * actually LARGER, since per-page gzip overhead outweighs what it saves. */
112
+ export const UNPAGED_MAX_RECORDS = 5_000;
113
+ // A page is capped by BOTH a record count and a byte budget, and takes
114
+ // whichever is smaller. The count alone made a page's size whatever that
115
+ // format's records happen to weigh, and since every keyed read gunzips and
116
+ // parses a whole page to answer for one record, a wide-record store paid for
117
+ // that width on each read. The byte budget can only ever reduce records per
118
+ // page: raising it for narrow records would pack thousands of vertices into
119
+ // one page and make their reads worse. What it costs is page count, and so
120
+ // container count and compression ratio.
121
+ //
122
+ // Write-side constants: a reader locates a record through the index it builds
123
+ // from the pages' key lists, so moving these changes how the next write packs
124
+ // and nothing about how an already-stored document reads.
90
125
  const TARGET_PAGE_SIZE = 64;
91
- const MAX_PAGES = 50_000;
92
- /** The tag a page container carries in place of a record tag, so a reader
93
- * tells a page (body = record triples) from a single record (body = one
94
- * plain value). */
126
+ const TARGET_PAGE_BYTES = 64 * 1024;
127
+ /** The tag a page container carries in place of a record tag, which labels it
128
+ * in a raw dump of the stored document. A READER never decides from it: what
129
+ * makes an entry a page is its key (`SESSION_PAGE_PREFIX`), so a record whose
130
+ * own tag happens to be this string is still a record. */
95
131
  export const PAGE_TAG = '__page__';
96
- /** Encode a page's triples as its stored body: JSON-serialised, gzipped, held
97
- * as a latin1 string. A STRING body avoids loro's superlinear per-value
98
- * conversion of a deeply nested triple array; gzipping keeps a heavy file in
99
- * memory + export budget. (Full rationale in the substrate's `encodePageBody`.) */
100
- export function encodePageBody(triples) {
101
- return encodePageBodyFromEncoded(triples.map((t) => JSON.stringify(t)));
102
- }
103
- /** Encode one record's `[key, tag, body]` triple as the JSON string it
104
- * occupies inside a page body. Split from page assembly so a large write can
132
+ /** Prefix on every key the record store writes for its own bookkeeping: the
133
+ * pages, a record's own container, a delete marker, a patch.
134
+ *
135
+ * A record and the bookkeeping about it share one map, so the two key spaces
136
+ * have to be told apart by something an app cannot produce. The map handle
137
+ * refuses a write under this prefix (`assertUsableRecordKey`), which makes the
138
+ * space exclusive: every key outside it is a record key, and every key inside
139
+ * it is the store's own. Nothing here inspects an app's VALUE to make that
140
+ * call, so no record can be mistaken for a page or a tombstone whatever it
141
+ * holds. */
142
+ export const RESERVED_KEY_PREFIX = 'brass:';
143
+ /** Whether `key` names one of the store's own entries rather than a record. */
144
+ export function isReservedKey(key) {
145
+ return key.startsWith(RESERVED_KEY_PREFIX);
146
+ }
147
+ /** The one key a reader cannot carry as an ordinary object property.
148
+ *
149
+ * Every reader of a record store collects records into a JS object keyed by
150
+ * record key, and assigning `__proto__` on one sets its PROTOTYPE instead of
151
+ * adding an entry: the record disappears from `read()` and `keys()`, and the
152
+ * object handed to the app inherits from a value whoever wrote the store
153
+ * chose. Reading it back safely would mean an own-property assign on every
154
+ * key of every read, on the hot path, and would still leave the two shapes
155
+ * disagreeing, since an unpaged read comes straight out of loro's own
156
+ * `toJSON`. Refusing the key once, at the write, is what makes every reader
157
+ * correct without spending anything. */
158
+ const UNCARRIABLE_RECORD_KEY = '__proto__';
159
+ /** Refuse a record key the store cannot carry, at the moment an app writes it:
160
+ * one inside the reserved space, and `__proto__`.
161
+ *
162
+ * Rejecting the reserved space is what lets every reader decide what an entry
163
+ * is from the key alone. The alternative, escaping, would put the escape on
164
+ * the read of every key of every map, paged or not, and a store that pages
165
+ * later cannot escape what it already holds. */
166
+ export function assertUsableRecordKey(key) {
167
+ if (key === UNCARRIABLE_RECORD_KEY) {
168
+ throw new Error(`Key "${key}" is reserved: a record key cannot be ` +
169
+ `"${UNCARRIABLE_RECORD_KEY}", which names an object's prototype ` +
170
+ `rather than one of its entries.`);
171
+ }
172
+ if (!isReservedKey(key))
173
+ return;
174
+ throw new Error(`Key "${key}" is reserved: a record key cannot start with ` +
175
+ `"${RESERVED_KEY_PREFIX}", which names the record store's own pages ` +
176
+ `and edit bookkeeping.`);
177
+ }
178
+ /** The prefix on a record written as its OWN container in a paged store,
179
+ * which shadows the page's copy on read. A page body is one value, so an
180
+ * edit that rewrote its page would make the page the merge unit; a record in
181
+ * its own container merges on its own. */
182
+ export const OVERLAY_PREFIX = `${RESERVED_KEY_PREFIX}r:`;
183
+ /** The prefix on the marker standing for a record DELETED out of a paged
184
+ * store. A page cannot be rewritten to drop the record, so the delete is
185
+ * stated as an entry of its own and every read drops the key.
186
+ *
187
+ * A key of its own rather than a tag on the record's container: a tag is a
188
+ * field of the value, and the value is the app's record, so a record that
189
+ * happened to carry that tag would read as its own deletion. The marker
190
+ * carries no information beyond existing, so it is a plain `true` rather than
191
+ * a container. */
192
+ const TOMBSTONE_PREFIX = `${RESERVED_KEY_PREFIX}x:`;
193
+ /** Pack one stored value into a page triple. A `null` tag is what says the
194
+ * body IS the record: an app's map holds whatever the app assigned, which has
195
+ * no tag to split off. A codec's records arrive already split, and a codec
196
+ * tag is a string, so the two forms cannot be confused however a codec names
197
+ * its records. */
198
+ export function packWholeValue(key, value) {
199
+ return [key, null, value];
200
+ }
201
+ /** Re-pack `value` in the same triple shape `packed` already uses, so a page
202
+ * rewritten in place keeps the form its readers expect: a codec's records
203
+ * stay a tag beside a body, and an app's stay one whole value. A streaming
204
+ * read visits a page's triples without unpacking them, so a rewrite that
205
+ * changed the shape would hand it the wrong half of the record. */
206
+ export function repackRecord(packed, value) {
207
+ if (packed[1] === null)
208
+ return packWholeValue(packed[0], value);
209
+ const record = (value ?? {});
210
+ const tag = record[TAG_FIELD];
211
+ return [
212
+ packed[0],
213
+ typeof tag === 'string' ? tag : packed[1],
214
+ record[BODY_FIELD],
215
+ ];
216
+ }
217
+ /** The stored value a page triple carries: the value itself for a whole-value
218
+ * record, else the `{ type, params }` pair a codec split it into. */
219
+ export function unpackRecord(packed) {
220
+ if (packed[1] === null)
221
+ return packed[2];
222
+ return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
223
+ }
224
+ /** The tag + body a page triple names, for a reader that walks the two
225
+ * separately, or null when the triple packs a whole value that is not a
226
+ * record.
227
+ *
228
+ * A page holds both forms: a codec packs a record it has already split into a
229
+ * tag and a body, and a map an app writes packs whatever the app assigned as
230
+ * one whole value. Both forms occur in ONE store, because an app's bulk add
231
+ * to a codec's record map seals its own pages, so a reader that took
232
+ * `packed[1]` for the tag would report every record those pages hold as
233
+ * having no type at all. */
234
+ export function unpackRecordPair(packed) {
235
+ if (packed[1] !== null)
236
+ return { tag: packed[1], body: packed[2] };
237
+ return decodeRecordValue(packed[2]);
238
+ }
239
+ /** The container key a record is written under, beside the pages. */
240
+ export function overlayKeyFor(recordKey) {
241
+ return `${OVERLAY_PREFIX}${recordKey}`;
242
+ }
243
+ /** The marker key standing for a record deleted out of a paged store. */
244
+ export function tombstoneKeyFor(recordKey) {
245
+ return `${TOMBSTONE_PREFIX}${recordKey}`;
246
+ }
247
+ /** The prefix on a page written by ONE editing session, which fills its pages
248
+ * in creation order.
249
+ *
250
+ * An app fills a map by appending, so a rule that scattered one mutation's
251
+ * records across every page would add a record or two to each of thousands of
252
+ * pages and rewrite every one of them again on the next mutation, which costs
253
+ * more than the per-record containers it replaces. Filling in creation order
254
+ * packs each mutation's records into its own dense pages, written complete
255
+ * and never rewritten.
256
+ *
257
+ * The token makes the key space exclusive to one writer. A page body is a
258
+ * single value, so two writers appending to the same page container resolve
259
+ * last-writer-wins and one writer's records are lost; with a per-session
260
+ * token no two writers ever address the same page. It is also what makes
261
+ * filling in creation order safe on reopen: a session never inherits, and so
262
+ * never rewrites, a page an earlier session sealed. */
263
+ export const SESSION_PAGE_PREFIX = `${RESERVED_KEY_PREFIX}p:`;
264
+ /** The container key for page `index` of the session identified by `token`. */
265
+ export function sessionPageKey(token, index) {
266
+ return `${SESSION_PAGE_PREFIX}${token}:${index}`;
267
+ }
268
+ /** The `{ token, index }` a session page key names, or null for any other key.
269
+ * Used to seed a session's next page index above what the store already
270
+ * holds, so a reopened session cannot overwrite its own sealed pages. */
271
+ export function parseSessionPageKey(key) {
272
+ if (!key.startsWith(SESSION_PAGE_PREFIX))
273
+ return null;
274
+ const rest = key.slice(SESSION_PAGE_PREFIX.length);
275
+ const split = rest.lastIndexOf(':');
276
+ if (split <= 0)
277
+ return null;
278
+ const index = Number(rest.slice(split + 1));
279
+ if (!Number.isInteger(index) || index < 0)
280
+ return null;
281
+ return { token: rest.slice(0, split), index };
282
+ }
283
+ /** Field on a page container holding the keys of the records its body packs,
284
+ * in the same order, so the two are zipped by index.
285
+ *
286
+ * The keys live apart from the bodies because finding a record in a page
287
+ * filled in creation order (`SESSION_PAGE_PREFIX`) means learning which page
288
+ * holds which key, and that question is answered by the keys alone. Packed
289
+ * beside the bodies, answering it costs decompressing and parsing every
290
+ * record in the store: measured at 250,000 records, 1.85 s against 0.24 s for
291
+ * the keys on their own. Splitting them costs no bytes, since the keys are
292
+ * stored once either way and compress better grouped than interleaved with
293
+ * the bodies. */
294
+ export const PAGE_KEYS_FIELD = 'keys';
295
+ /** Encode a page's records as the two strings it stores. Each is JSON,
296
+ * gzipped, held as a latin1 string. A STRING avoids loro's superlinear
297
+ * per-value conversion of a nested array; gzipping keeps a heavy file in
298
+ * memory + export budget. (Full rationale in the substrate's page notes.) */
299
+ export function encodePage(records) {
300
+ return encodePageFromEncoded(records.map((r) => encodeRecordForPage(r[1], r[2])), records.map((r) => r[0]));
301
+ }
302
+ /** Encode one record's `[tag, body]` pair as the JSON string it occupies
303
+ * inside a page body. Split from page assembly so a large write can
105
304
  * stringify each record as it buckets and release the parsed body before the
106
- * gzip loop, rather than holding the whole parsed model live through it (see
107
- * `writeRecordStoreConsuming` for why that release keeps the encode fast). */
108
- export function encodeRecordForPage(key, tag, body) {
109
- return JSON.stringify([key, tag, body]);
110
- }
111
- /** Assemble a page body from records already JSON-encoded by
112
- * `encodeRecordForPage`: wrap in a JSON array, gzip, hold as a latin1 string.
113
- * Byte-identical to `encodePageBody` over the same triples, so the wire shape
114
- * is unchanged; the split exists only so the writer frees each parsed body
305
+ * gzip loop: holding a whole parsed model live through that loop makes GC
306
+ * marking scale with the live set and turns the encode into a wall. */
307
+ export function encodeRecordForPage(tag, body) {
308
+ return JSON.stringify([tag, body]);
309
+ }
310
+ /** Assemble a page from bodies already JSON-encoded by `encodeRecordForPage`
311
+ * and the keys they belong to. Byte-identical to `encodePage` over the same
312
+ * records; the split exists only so the writer frees each parsed body
115
313
  * first. */
116
- export function encodePageBodyFromEncoded(encoded) {
117
- return strFromU8(gzip(strToU8('[' + encoded.join(',') + ']')), true);
118
- }
119
- /** Decode a page body back to its record triples. Returns [] for anything that
120
- * does not decompress to a JSON array, so a reader tolerates a corrupt or
121
- * stray page rather than throwing. */
122
- export function decodePageBody(body) {
123
- if (typeof body !== 'string')
124
- return [];
314
+ export function encodePageFromEncoded(encoded, keys) {
315
+ const body = '[' + encoded.join(',') + ']';
316
+ return {
317
+ body: gzipJson(body),
318
+ keys: gzipJson(JSON.stringify(keys)),
319
+ chars: body.length,
320
+ };
321
+ }
322
+ function gzipJson(json) {
323
+ return strFromU8(gzip(strToU8(json)), true);
324
+ }
325
+ /** Parse one of a page's stored strings. Returns null for anything that does
326
+ * not decompress to a JSON array, so a reader tolerates a corrupt or stray
327
+ * page rather than throwing. */
328
+ function decodePageField(value) {
329
+ if (typeof value !== 'string')
330
+ return null;
125
331
  try {
126
- const json = strFromU8(gunzip(strToU8(body, true)));
127
- const parsed = JSON.parse(json);
128
- return Array.isArray(parsed) ? parsed : [];
332
+ const parsed = JSON.parse(strFromU8(gunzip(strToU8(value, true))));
333
+ return Array.isArray(parsed) ? parsed : null;
129
334
  }
130
335
  catch {
336
+ return null;
337
+ }
338
+ }
339
+ /** The record keys a page holds, from the page's own `keys` string. Reading
340
+ * this field alone is what makes learning a store's key locations cost the
341
+ * keys rather than every record body. */
342
+ export function decodePageKeys(keysField) {
343
+ const parsed = decodePageField(keysField);
344
+ if (parsed === null)
345
+ return [];
346
+ return parsed.filter((k) => typeof k === 'string');
347
+ }
348
+ /** A page's records, zipped back into `[key, tag, body]` triples from the two
349
+ * strings the page stores. A key with no body (or a body with no key) is
350
+ * dropped: the two arrays are written together and index each other, so a
351
+ * length mismatch means a page nothing can read as records. */
352
+ export function decodePageRecords(page) {
353
+ if (!page || typeof page !== 'object' || Array.isArray(page))
131
354
  return [];
355
+ const fields = page;
356
+ const keys = decodePageKeys(fields[PAGE_KEYS_FIELD]);
357
+ if (keys.length === 0)
358
+ return [];
359
+ const pairs = decodePageField(fields[BODY_FIELD]);
360
+ if (pairs === null)
361
+ return [];
362
+ const out = [];
363
+ for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
364
+ const pair = pairs[i];
365
+ if (!Array.isArray(pair))
366
+ continue;
367
+ if (typeof pair[0] !== 'string' && pair[0] !== null)
368
+ continue;
369
+ out.push([keys[i], pair[0], pair[1]]);
132
370
  }
371
+ return out;
133
372
  }
134
- /** The page count for a record population: 0 (unpaged) at or below
135
- * `UNPAGED_MAX_RECORDS`, else ~`TARGET_PAGE_SIZE` records per page, capped at
136
- * `MAX_PAGES`. */
137
- export function pageCountFor(recordCount) {
138
- if (recordCount <= UNPAGED_MAX_RECORDS)
373
+ // How many bodies to weigh when estimating a population's record width. A
374
+ // stride across the whole population rather than a prefix: a population is
375
+ // routinely ordered so that its wide records cluster (an import arrives
376
+ // grouped by entity type, an app's map fills in creation order), and a prefix
377
+ // sample then sizes every page from whatever happens to sit at the front.
378
+ const WIDTH_SAMPLE = 256;
379
+ /** Mean JSON bytes of a record body, over a stride sample of at most
380
+ * `WIDTH_SAMPLE` records, which is what `recordsPerPage` sizes a page
381
+ * against. `bodyOf` returns the value the caller will pack as the triple's
382
+ * body: a codec's records carry a tag beside a body, and an app's map packs
383
+ * its whole stored value (`packWholeValue`), so each caller weighs the bytes
384
+ * its own pages will hold.
385
+ *
386
+ * Returns 0, which keeps `recordsPerPage`'s plain record-count rule, for an
387
+ * empty population and for a body `JSON.stringify` refuses. Such a body fails
388
+ * the write regardless (a paged write stringifies each record, and loro
389
+ * rejects a non-JSON value), so the catch is not there to rescue it; it is
390
+ * there so that a page-size ESTIMATE is never what decides a write fails, and
391
+ * the failure stays with the code that owns the body. */
392
+ export function sampleBodyBytes(records, bodyOf) {
393
+ if (records.length === 0)
394
+ return 0;
395
+ const stride = Math.max(1, Math.floor(records.length / WIDTH_SAMPLE));
396
+ let total = 0;
397
+ let seen = 0;
398
+ try {
399
+ for (let i = 0; i < records.length && seen < WIDTH_SAMPLE; i += stride) {
400
+ total += JSON.stringify(bodyOf(records[i]) ?? null).length;
401
+ seen += 1;
402
+ }
403
+ }
404
+ catch {
139
405
  return 0;
140
- return Math.min(Math.ceil(recordCount / TARGET_PAGE_SIZE), MAX_PAGES);
406
+ }
407
+ return seen === 0 ? 0 : total / seen;
141
408
  }
142
- /** FNV-1a (32-bit) assigning a record key to a page. Stable and
143
- * engine-independent: the same key resolves to the same page on write, read,
144
- * and edit across processes, which is what lets the page be found with no
145
- * stored index. */
146
- function hashKey(key) {
147
- let h = 0x811c9dc5;
148
- for (let i = 0; i < key.length; i += 1) {
149
- h ^= key.charCodeAt(i);
150
- h = Math.imul(h, 0x01000193);
409
+ /** Records one page holds: `TARGET_PAGE_SIZE`, or fewer when that many bodies
410
+ * would exceed `TARGET_PAGE_BYTES`. `avgBodyBytes` of 0 or absent (a caller
411
+ * with no estimate) keeps the count rule. */
412
+ export function recordsPerPage(avgBodyBytes) {
413
+ if (!avgBodyBytes || !Number.isFinite(avgBodyBytes) || avgBodyBytes <= 0) {
414
+ return TARGET_PAGE_SIZE;
151
415
  }
152
- return h >>> 0;
416
+ const byBytes = Math.floor(TARGET_PAGE_BYTES / avgBodyBytes);
417
+ return Math.max(1, Math.min(TARGET_PAGE_SIZE, byBytes));
153
418
  }
154
- /** The page key a record key lives in, for a store of `pageCount` pages. */
155
- export function pageKeyFor(key, pageCount) {
156
- return `p${hashKey(key) % pageCount}`;
419
+ /** How many pages a record population occupies: 0 (unpaged) at or below
420
+ * `UNPAGED_MAX_RECORDS`, else one page per `recordsPerPage(avgBodyBytes)`
421
+ * records.
422
+ *
423
+ * A projection, not a decision the write path takes. Pages are filled in
424
+ * creation order and allocated as a write needs them, so this is what
425
+ * `scripts/bench` uses to price container counts and what the hash-page
426
+ * fixtures use as their bucket modulus. */
427
+ export function pageCountFor(recordCount, avgBodyBytes) {
428
+ if (recordCount <= UNPAGED_MAX_RECORDS)
429
+ return 0;
430
+ return Math.ceil(recordCount / recordsPerPage(avgBodyBytes));
157
431
  }
158
432
  /** A stored container's tag (`type`), or null for any value not in the
159
433
  * `{ type, params }` shape, so a reader skips a corrupt entry. A page carries
@@ -175,46 +449,236 @@ export function decodeRecordValue(value) {
175
449
  return null;
176
450
  return { tag, body: o[BODY_FIELD] };
177
451
  }
178
- /** Whether a `meta.page_count` value denotes a PAGED store. The writer sets
179
- * it to a positive count only when it pages, and never on an unpaged write,
180
- * so a positive finite number is the authoritative "is paged" signal. */
181
- export function isPagedByPageCount(pageCount) {
182
- return typeof pageCount === 'number' && Number.isFinite(pageCount) && pageCount > 0;
452
+ /** Prefix of a RECORD PATCH: the fields one edit changed, applied over the
453
+ * record's page copy on read.
454
+ *
455
+ * Giving an edited record a container of its own instead restores the
456
+ * per-container cost paging exists to remove, so a store whose records are
457
+ * all eventually edited ends up slower and larger than one that never paged.
458
+ * A patch keeps the record packed and costs one entry per edit rather than a
459
+ * container per edited record.
460
+ *
461
+ * The key carries the writing peer and a counter. The peer is what lets the
462
+ * patches be FOLDED back into their pages later without a lock: a CRDT delete
463
+ * only defeats a CONCURRENT write to the same key, and no two writers can
464
+ * name the same key while the peer is in it, so a peer editing during
465
+ * another's fold writes a key that fold never saw. A writer that re-mints a
466
+ * key it folded away itself is ordered after its own delete, so that write is
467
+ * not the race either.
468
+ *
469
+ * The counter orders the patches on ONE record, and is minted one above every
470
+ * counter that record already carries. That is what makes the fold causal: a
471
+ * patch written by someone who had already merged an earlier patch sorts
472
+ * above it, whoever wrote which. A counter that instead counted the WRITER's
473
+ * edits would put a busy session's first-ever edit of a record above a later
474
+ * session's, and the later edit would vanish as it was made.
475
+ *
476
+ * The record key goes last because it may contain a colon; the peer and
477
+ * counter may not.
478
+ *
479
+ * Each reserved space differs from its siblings at the character after
480
+ * `RESERVED_KEY_PREFIX`, so `classifyStoreKey` decides between them with one
481
+ * test each and no ordering to get right. */
482
+ export const PATCH_PREFIX = `${RESERVED_KEY_PREFIX}q:`;
483
+ /** Width the counter is padded to, so lexical key order is counter order. */
484
+ const PATCH_COUNTER_DIGITS = 12;
485
+ export function patchKeyFor(recordKey, peer, counter) {
486
+ const n = String(counter).padStart(PATCH_COUNTER_DIGITS, '0');
487
+ return `${PATCH_PREFIX}${peer}:${n}:${recordKey}`;
488
+ }
489
+ /** Classify one key of a paged map. The single place the key spaces are told
490
+ * apart, so a keyed read, a whole-store read, a streaming read, and the page
491
+ * index cannot disagree about which entry is which. */
492
+ export function classifyStoreKey(key) {
493
+ if (!isReservedKey(key))
494
+ return { kind: 'record', recordKey: key };
495
+ if (key.startsWith(OVERLAY_PREFIX)) {
496
+ return { kind: 'overlay', recordKey: key.slice(OVERLAY_PREFIX.length) };
497
+ }
498
+ if (key.startsWith(TOMBSTONE_PREFIX)) {
499
+ return { kind: 'tombstone', recordKey: key.slice(TOMBSTONE_PREFIX.length) };
500
+ }
501
+ if (key.startsWith(SESSION_PAGE_PREFIX))
502
+ return { kind: 'page' };
503
+ const parsed = parsePatchKey(key);
504
+ return parsed === null ? { kind: 'unknown' } : { kind: 'patch', parsed };
505
+ }
506
+ /** The parts of a patch key, or null when `key` is not one.
507
+ *
508
+ * The record key runs to the end of the string and may be empty, which is a
509
+ * record key like any other: the two colons after the prefix delimit the peer
510
+ * and the counter, so the tail needs no length of its own to be
511
+ * unambiguous. Reading an empty tail as "not a patch" instead leaves the
512
+ * entry classified `unknown`, which every reader skips, so the edit stays in
513
+ * the document and reaches nobody. */
514
+ export function parsePatchKey(key) {
515
+ if (!key.startsWith(PATCH_PREFIX))
516
+ return null;
517
+ const peerEnd = key.indexOf(':', PATCH_PREFIX.length);
518
+ if (peerEnd === -1)
519
+ return null;
520
+ const counterEnd = key.indexOf(':', peerEnd + 1);
521
+ if (counterEnd === -1)
522
+ return null;
523
+ const recordKey = key.slice(counterEnd + 1);
524
+ return {
525
+ recordKey,
526
+ peer: key.slice(PATCH_PREFIX.length, peerEnd),
527
+ counter: key.slice(peerEnd + 1, counterEnd),
528
+ };
529
+ }
530
+ /** Apply `patches` over `base`, in the order every reader must agree on: by
531
+ * counter, then by peer id. The counter is per record and minted one above
532
+ * what that record already carries, so a patch whose writer had merged an
533
+ * earlier one applies over it. Peer id breaks the tie between two writers who
534
+ * had not seen each other, so both reach the same answer. */
535
+ export function applyRecordPatches(base, patches) {
536
+ if (patches.length === 0)
537
+ return base;
538
+ if (!base || typeof base !== 'object' || Array.isArray(base))
539
+ return base;
540
+ const ordered = patches.length === 1
541
+ ? patches
542
+ : [...patches].sort((a, b) => a.counter === b.counter
543
+ ? a.peer.localeCompare(b.peer)
544
+ : a.counter.localeCompare(b.counter));
545
+ let out = base;
546
+ for (const { fields: patch } of ordered) {
547
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch))
548
+ continue;
549
+ out = { ...out, ...patch };
550
+ }
551
+ return out;
552
+ }
553
+ /**
554
+ * Apply patches to a record a streaming read carries both as its whole stored
555
+ * value and as the `(tag, body)` pair split out of it, and return all three
556
+ * after the fold.
557
+ *
558
+ * A patch names the record's own fields, which for a codec record are
559
+ * `type` and `params` together, so a reader that walks tag and body
560
+ * separately has to re-split the result. Both readers of a paged store
561
+ * (the flattening one and the streaming one) resolve patches through this
562
+ * module, so an edit cannot read one way in the editor and another in a
563
+ * re-emitted file. The fold runs over the whole value for the same reason the
564
+ * streaming read carries one: a field beyond the pair is a field of the record
565
+ * and an edit may name it.
566
+ */
567
+ export function applyPatchesToRecord(record, tag, body, patches) {
568
+ if (patches.length === 0)
569
+ return { tag, body, record };
570
+ const patched = applyRecordPatches(record, patches);
571
+ if (!patched || typeof patched !== 'object' || Array.isArray(patched)) {
572
+ return { tag, body, record: patched };
573
+ }
574
+ const fields = patched;
575
+ const nextTag = fields[TAG_FIELD];
576
+ return {
577
+ tag: typeof nextTag === 'string' ? nextTag : tag,
578
+ body: fields[BODY_FIELD],
579
+ record: patched,
580
+ };
183
581
  }
184
582
  /** Flatten a possibly-PAGED `instances` lookup to the flat
185
583
  * `key -> { type, params }` lookup a reader consumes. An unpaged lookup is
186
584
  * returned unchanged (no allocation). The single place paging is undone on the
187
585
  * read side.
188
586
  *
189
- * `paged` is the AUTHORITATIVE page state, read from `meta.page_count` by the
190
- * caller (`isPagedByPageCount`). Page interpretation MUST be gated on it, not
191
- * on whether some entry is `PAGE_TAG`-tagged: a genuine record whose codec tag
192
- * happens to equal `PAGE_TAG` in an UNPAGED store is a real record, not a
193
- * page. Treating it as a page decodes its plain body as `decodePageBody`
194
- * (which returns `[]` for a non-page body), silently DROPPING the record, and
195
- * a crafted page-shaped body could forge records into the lookup. */
196
- export function flattenPagedLookup(lookup, paged) {
587
+ * `paged` is the AUTHORITATIVE page state, read from the root's own `meta`
588
+ * key by the caller (`pagedRootKey` / `isPagedMarker`). Page interpretation
589
+ * MUST be gated on it: in an UNPAGED store every key is a record key,
590
+ * including one that looks reserved, because nothing refused it before the
591
+ * store was written. */
592
+ export function flattenPagedLookup(lookup, paged, into) {
197
593
  if (!paged)
198
594
  return lookup;
199
595
  const flat = {};
596
+ // Anything written OUTSIDE a page is applied in a later pass, so a record
597
+ // held in its own container always wins over a page's copy of it, whatever
598
+ // order the map hands its keys back in. Resolving either in the page pass
599
+ // would make the winner depend on that order, which is the store's business
600
+ // and not a decision a reader should inherit.
601
+ //
602
+ // Two kinds, in increasing authority. A STRAY is a record under its own key
603
+ // in a paged map: every record written before the map was paged, plus what a
604
+ // client that did not know it was paged leaves behind. Either way it is the
605
+ // newer copy, so it beats the page. An OVERLAY entry is an edit written
606
+ // beside the pages, so it beats both.
607
+ const stray = [];
608
+ const overlay = [];
609
+ // A delete a page still holds is stated as its own entry, so the removals
610
+ // are applied after the whole-record passes.
611
+ const removed = [];
612
+ // A PATCH carries only the fields one edit changed, so it patches whatever
613
+ // the whole-record passes resolved to rather than replacing it. That is what
614
+ // keeps two peers changing different fields of one record from overwriting
615
+ // each other, which a whole-record write cannot do.
616
+ const patches = new Map();
200
617
  for (const [key, value] of Object.entries(lookup)) {
201
- if (peekRecordTag(value) !== PAGE_TAG) {
202
- flat[key] = value; // tolerate a stray non-page entry by its own key
203
- continue;
204
- }
205
- const body = decodePageBody(decodeRecordValue(value)?.body);
206
- for (const packed of body) {
207
- if (Array.isArray(packed) &&
208
- packed.length >= 3 &&
209
- typeof packed[0] === 'string' &&
210
- typeof packed[1] === 'string') {
211
- flat[packed[0]] = {
212
- [TAG_FIELD]: packed[1],
213
- [BODY_FIELD]: packed[2],
618
+ const entry = classifyStoreKey(key);
619
+ switch (entry.kind) {
620
+ case 'record':
621
+ stray.push([key, value]);
622
+ break;
623
+ case 'overlay':
624
+ overlay.push([entry.recordKey, value]);
625
+ break;
626
+ case 'tombstone':
627
+ removed.push(entry.recordKey);
628
+ break;
629
+ case 'patch': {
630
+ const list = patches.get(entry.parsed.recordKey);
631
+ const patch = {
632
+ peer: entry.parsed.peer,
633
+ counter: entry.parsed.counter,
634
+ fields: value,
214
635
  };
636
+ if (list)
637
+ list.push(patch);
638
+ else
639
+ patches.set(entry.parsed.recordKey, [patch]);
640
+ break;
215
641
  }
642
+ case 'page':
643
+ for (const packed of decodePageRecords(value)) {
644
+ flat[packed[0]] = unpackRecord(packed);
645
+ into?.page(packed[0], key);
646
+ }
647
+ break;
648
+ case 'unknown':
649
+ break;
216
650
  }
217
651
  }
652
+ for (const [key, value] of stray) {
653
+ flat[key] = value;
654
+ into?.contained(key);
655
+ }
656
+ for (const [recordKey, value] of overlay) {
657
+ flat[recordKey] = value;
658
+ into?.contained(recordKey);
659
+ }
660
+ // A delete outranks every copy of the record, so a peer's concurrent edit of
661
+ // a record another peer removed reads as removed for both of them.
662
+ for (const recordKey of removed) {
663
+ delete flat[recordKey];
664
+ into?.removed(recordKey);
665
+ }
666
+ // Last, so a patch lands on the record whichever pass produced it. A patch
667
+ // for a record no pass produced is dropped rather than resurrecting a
668
+ // partial one: the record was deleted, and half of it is not a record.
669
+ //
670
+ // "No pass produced it" is an OWN-property question. A record key is any
671
+ // string, so one naming a member of `Object.prototype` (`toString`,
672
+ // `valueOf`) reads back as that member from an object nothing has assigned
673
+ // it on, and a plain `=== undefined` test then takes the inherited function
674
+ // for the record: a record two peers concurrently deleted and patched comes
675
+ // back from the dead as a function, in a store no app wrote anything odd
676
+ // into.
677
+ for (const [recordKey, list] of patches) {
678
+ if (!Object.prototype.hasOwnProperty.call(flat, recordKey))
679
+ continue;
680
+ flat[recordKey] = applyRecordPatches(flat[recordKey], list);
681
+ }
218
682
  return flat;
219
683
  }
220
684
  //# sourceMappingURL=record-paging.js.map