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