@brass-build/client 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
@@ -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,429 @@ 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 typed record arrives already split, and its tag is a
236
+ * string, so the two forms cannot be confused whatever the tag says. */
237
+ export function packWholeValue<B>(key: string, value: B): PackedRecord<B> {
238
+ return [key, null, value];
239
+ }
240
+
241
+ /** Re-pack `value` in the same triple shape `packed` already uses, so a page
242
+ * rewritten in place keeps the form its readers expect: a typed record stays
243
+ * a tag beside a body, and an app's stays one whole value. A streaming
244
+ * read visits a page's triples without unpacking them, so a rewrite that
245
+ * changed the shape would hand it the wrong half of the record. */
246
+ export function repackRecord(
247
+ packed: PackedRecord<unknown>,
248
+ value: unknown,
249
+ ): PackedRecord<unknown> {
250
+ if (packed[1] === null) return packWholeValue(packed[0], value);
251
+ const record = (value ?? {}) as Record<string, unknown>;
252
+ const tag = record[TAG_FIELD];
253
+ return [
254
+ packed[0],
255
+ typeof tag === 'string' ? tag : packed[1],
256
+ record[BODY_FIELD],
257
+ ];
258
+ }
259
+
260
+ /** The stored value a page triple carries: the value itself for a whole-value
261
+ * record, else the `{ type, params }` pair a typed one was split into. */
262
+ export function unpackRecord(packed: PackedRecord<unknown>): unknown {
263
+ if (packed[1] === null) return packed[2];
264
+ return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
265
+ }
266
+
267
+ /** The tag + body a page triple names, for a reader that walks the two
268
+ * separately, or null when the triple packs a whole value that is not a
269
+ * record.
270
+ *
271
+ * A page holds both forms: a typed record is packed as the tag and body it
272
+ * was already split into, and a map an app writes packs whatever the app
273
+ * assigned as one whole value. Both forms occur in ONE store, because an
274
+ * app's bulk add to a store of typed records seals its own pages, so a reader
275
+ * that took `packed[1]` for the tag would report every record those pages
276
+ * hold as having no type at all. */
277
+ export function unpackRecordPair(
278
+ packed: PackedRecord<unknown>,
279
+ ): { tag: string; body: unknown } | null {
280
+ if (packed[1] !== null) return { tag: packed[1], body: packed[2] };
281
+ return decodeRecordValue(packed[2]);
282
+ }
283
+
284
+ /** The container key a record is written under, beside the pages. */
285
+ export function overlayKeyFor(recordKey: string): string {
286
+ return `${OVERLAY_PREFIX}${recordKey}`;
287
+ }
288
+
289
+ /** The marker key standing for a record deleted out of a paged store. */
290
+ export function tombstoneKeyFor(recordKey: string): string {
291
+ return `${TOMBSTONE_PREFIX}${recordKey}`;
292
+ }
293
+
294
+ /** The prefix on a page written by ONE editing session, which fills its pages
295
+ * in creation order.
296
+ *
297
+ * An app fills a map by appending, so a rule that scattered one mutation's
298
+ * records across every page would add a record or two to each of thousands of
299
+ * pages and rewrite every one of them again on the next mutation, which costs
300
+ * more than the per-record containers it replaces. Filling in creation order
301
+ * packs each mutation's records into its own dense pages, written complete
302
+ * and never rewritten.
303
+ *
304
+ * The token makes the key space exclusive to one writer. A page body is a
305
+ * single value, so two writers appending to the same page container resolve
306
+ * last-writer-wins and one writer's records are lost; with a per-session
307
+ * token no two writers ever address the same page. It is also what makes
308
+ * filling in creation order safe on reopen: a session never inherits, and so
309
+ * never rewrites, a page an earlier session sealed. */
310
+ export const SESSION_PAGE_PREFIX = `${RESERVED_KEY_PREFIX}p:`;
311
+
312
+ /** The container key for page `index` of the session identified by `token`. */
313
+ export function sessionPageKey(token: string, index: number): string {
314
+ return `${SESSION_PAGE_PREFIX}${token}:${index}`;
315
+ }
316
+
317
+ /** The `{ token, index }` a session page key names, or null for any other key.
318
+ * Used to seed a session's next page index above what the store already
319
+ * holds, so a reopened session cannot overwrite its own sealed pages. */
320
+ export function parseSessionPageKey(
146
321
  key: string,
147
- tag: string,
148
- body: B,
149
- ): string {
150
- return JSON.stringify([key, tag, body] as PackedRecord<B>);
322
+ ): { token: string; index: number } | null {
323
+ if (!key.startsWith(SESSION_PAGE_PREFIX)) return null;
324
+ const rest = key.slice(SESSION_PAGE_PREFIX.length);
325
+ const split = rest.lastIndexOf(':');
326
+ if (split <= 0) return null;
327
+ const index = Number(rest.slice(split + 1));
328
+ if (!Number.isInteger(index) || index < 0) return null;
329
+ return { token: rest.slice(0, split), index };
330
+ }
331
+
332
+ /** A record packed inside a page: `[key, tag, body]`, where a `null` tag means
333
+ * the body is the record's WHOLE stored value (`packWholeValue`). The tuple
334
+ * form keeps a paged snapshot compact. It is how a reader HOLDS a page's
335
+ * records; a stored page splits the key off into its own field
336
+ * (`PAGE_KEYS_FIELD`). */
337
+ export type PackedRecord<B> = [key: string, tag: string | null, body: B];
338
+
339
+ /** Field on a page container holding the keys of the records its body packs,
340
+ * in the same order, so the two are zipped by index.
341
+ *
342
+ * The keys live apart from the bodies because finding a record in a page
343
+ * filled in creation order (`SESSION_PAGE_PREFIX`) means learning which page
344
+ * holds which key, and that question is answered by the keys alone. Packed
345
+ * beside the bodies, answering it costs decompressing and parsing every
346
+ * record in the store: measured at 250,000 records, 1.85 s against 0.24 s for
347
+ * the keys on their own. Splitting them costs no bytes, since the keys are
348
+ * stored once either way and compress better grouped than interleaved with
349
+ * the bodies. */
350
+ export const PAGE_KEYS_FIELD = 'keys';
351
+
352
+ /** A page's two stored strings: the record bodies and the keys they belong
353
+ * to. */
354
+ export interface EncodedPage {
355
+ body: string;
356
+ keys: string;
357
+ /** The body's size BEFORE gzip, so a writer can charge a page in the same
358
+ * unit an ordinary map assignment is charged in. Taken here because the
359
+ * uncompressed string is built here and nothing else holds it. */
360
+ chars: number;
151
361
  }
152
362
 
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
363
+ /** Encode a page's records as the two strings it stores. Each is JSON,
364
+ * gzipped, held as a latin1 string. A STRING avoids loro's superlinear
365
+ * per-value conversion of a nested array; gzipping keeps a heavy file in
366
+ * memory + export budget. (Full rationale in the substrate's page notes.) */
367
+ export function encodePage<B>(records: PackedRecord<B>[]): EncodedPage {
368
+ return encodePageFromEncoded(
369
+ records.map((r) => encodeRecordForPage(r[1], r[2])),
370
+ records.map((r) => r[0]),
371
+ );
372
+ }
373
+
374
+ /** Encode one record's `[tag, body]` pair as the JSON string it occupies
375
+ * inside a page body. Split from page assembly so a large write can
376
+ * stringify each record as it buckets and release the parsed body before the
377
+ * gzip loop: holding a whole parsed model live through that loop makes GC
378
+ * marking scale with the live set and turns the encode into a wall. */
379
+ export function encodeRecordForPage<B>(tag: string | null, body: B): string {
380
+ return JSON.stringify([tag, body]);
381
+ }
382
+
383
+ /** Assemble a page from bodies already JSON-encoded by `encodeRecordForPage`
384
+ * and the keys they belong to. Byte-identical to `encodePage` over the same
385
+ * records; the split exists only so the writer frees each parsed body
157
386
  * first. */
158
- export function encodePageBodyFromEncoded(encoded: string[]): string {
159
- return strFromU8(gzip(strToU8('[' + encoded.join(',') + ']')), true);
387
+ export function encodePageFromEncoded(
388
+ encoded: string[],
389
+ keys: string[],
390
+ ): EncodedPage {
391
+ const body = '[' + encoded.join(',') + ']';
392
+ return {
393
+ body: gzipJson(body),
394
+ keys: gzipJson(JSON.stringify(keys)),
395
+ chars: body.length,
396
+ };
160
397
  }
161
398
 
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 [];
399
+ function gzipJson(json: string): string {
400
+ return strFromU8(gzip(strToU8(json)), true);
401
+ }
402
+
403
+ /** Parse one of a page's stored strings. Returns null for anything that does
404
+ * not decompress to a JSON array, so a reader tolerates a corrupt or stray
405
+ * page rather than throwing. */
406
+ function decodePageField(value: unknown): unknown[] | null {
407
+ if (typeof value !== 'string') return null;
167
408
  try {
168
- const json = strFromU8(gunzip(strToU8(body, true)));
169
- const parsed = JSON.parse(json);
170
- return Array.isArray(parsed) ? (parsed as PackedRecord<unknown>[]) : [];
409
+ const parsed = JSON.parse(strFromU8(gunzip(strToU8(value, true))));
410
+ return Array.isArray(parsed) ? (parsed as unknown[]) : null;
171
411
  } catch {
172
- return [];
412
+ return null;
173
413
  }
174
414
  }
175
415
 
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);
416
+ /** The record keys a page holds, from the page's own `keys` string. Reading
417
+ * this field alone is what makes learning a store's key locations cost the
418
+ * keys rather than every record body. */
419
+ export function decodePageKeys(keysField: unknown): string[] {
420
+ const parsed = decodePageField(keysField);
421
+ if (parsed === null) return [];
422
+ return parsed.filter((k): k is string => typeof k === 'string');
423
+ }
424
+
425
+ /** A page's records, zipped back into `[key, tag, body]` triples from the two
426
+ * strings the page stores. A key with no body (or a body with no key) is
427
+ * dropped: the two arrays are written together and index each other, so a
428
+ * length mismatch means a page nothing can read as records. */
429
+ export function decodePageRecords(page: unknown): PackedRecord<unknown>[] {
430
+ if (!page || typeof page !== 'object' || Array.isArray(page)) return [];
431
+ const fields = page as Record<string, unknown>;
432
+ const keys = decodePageKeys(fields[PAGE_KEYS_FIELD]);
433
+ if (keys.length === 0) return [];
434
+ const pairs = decodePageField(fields[BODY_FIELD]);
435
+ if (pairs === null) return [];
436
+ const out: PackedRecord<unknown>[] = [];
437
+ for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
438
+ const pair = pairs[i];
439
+ if (!Array.isArray(pair)) continue;
440
+ if (typeof pair[0] !== 'string' && pair[0] !== null) continue;
441
+ out.push([keys[i]!, pair[0], pair[1]]);
442
+ }
443
+ return out;
444
+ }
445
+
446
+ // How many bodies to weigh when estimating a population's record width. A
447
+ // stride across the whole population rather than a prefix: a population is
448
+ // routinely ordered so that its wide records cluster (an import arrives
449
+ // grouped by entity type, an app's map fills in creation order), and a prefix
450
+ // sample then sizes every page from whatever happens to sit at the front.
451
+ const WIDTH_SAMPLE = 256;
452
+
453
+ /** Mean JSON bytes of a record body, over a stride sample of at most
454
+ * `WIDTH_SAMPLE` records, which is what `recordsPerPage` sizes a page
455
+ * against. `bodyOf` returns the value the caller will pack as the triple's
456
+ * body: a typed record carries a tag beside a body, and an app's map packs
457
+ * its whole stored value (`packWholeValue`), so each caller weighs the bytes
458
+ * its own pages will hold.
459
+ *
460
+ * Returns 0, which keeps `recordsPerPage`'s plain record-count rule, for an
461
+ * empty population and for a body `JSON.stringify` refuses. Such a body fails
462
+ * the write regardless (a paged write stringifies each record, and loro
463
+ * rejects a non-JSON value), so the catch is not there to rescue it; it is
464
+ * there so that a page-size ESTIMATE is never what decides a write fails, and
465
+ * the failure stays with the code that owns the body. */
466
+ export function sampleBodyBytes<T>(
467
+ records: readonly T[],
468
+ bodyOf: (record: T) => unknown,
469
+ ): number {
470
+ if (records.length === 0) return 0;
471
+ const stride = Math.max(1, Math.floor(records.length / WIDTH_SAMPLE));
472
+ let total = 0;
473
+ let seen = 0;
474
+ try {
475
+ for (let i = 0; i < records.length && seen < WIDTH_SAMPLE; i += stride) {
476
+ total += JSON.stringify(bodyOf(records[i]!) ?? null).length;
477
+ seen += 1;
478
+ }
479
+ } catch {
480
+ return 0;
193
481
  }
194
- return h >>> 0;
482
+ return seen === 0 ? 0 : total / seen;
195
483
  }
196
484
 
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}`;
485
+ /** Records one page holds: `TARGET_PAGE_SIZE`, or fewer when that many bodies
486
+ * would exceed `TARGET_PAGE_BYTES`. `avgBodyBytes` of 0 or absent (a caller
487
+ * with no estimate) keeps the count rule. */
488
+ export function recordsPerPage(avgBodyBytes?: number): number {
489
+ if (!avgBodyBytes || !Number.isFinite(avgBodyBytes) || avgBodyBytes <= 0) {
490
+ return TARGET_PAGE_SIZE;
491
+ }
492
+ const byBytes = Math.floor(TARGET_PAGE_BYTES / avgBodyBytes);
493
+ return Math.max(1, Math.min(TARGET_PAGE_SIZE, byBytes));
494
+ }
495
+
496
+ /** How many pages a record population occupies: 0 (unpaged) at or below
497
+ * `UNPAGED_MAX_RECORDS`, else one page per `recordsPerPage(avgBodyBytes)`
498
+ * records.
499
+ *
500
+ * A projection, not a decision the write path takes. Pages are filled in
501
+ * creation order and allocated as a write needs them, so this is what
502
+ * `scripts/bench` uses to price container counts and what the hash-page
503
+ * fixtures use as their bucket modulus. */
504
+ export function pageCountFor(
505
+ recordCount: number,
506
+ avgBodyBytes?: number,
507
+ ): number {
508
+ if (recordCount <= UNPAGED_MAX_RECORDS) return 0;
509
+ return Math.ceil(recordCount / recordsPerPage(avgBodyBytes));
200
510
  }
201
511
 
202
512
  /** A stored container's tag (`type`), or null for any value not in the
@@ -220,11 +530,188 @@ export function decodeRecordValue(
220
530
  return { tag, body: o[BODY_FIELD] };
221
531
  }
222
532
 
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;
533
+ /** Prefix of a RECORD PATCH: the fields one edit changed, applied over the
534
+ * record's page copy on read.
535
+ *
536
+ * Giving an edited record a container of its own instead restores the
537
+ * per-container cost paging exists to remove, so a store whose records are
538
+ * all eventually edited ends up slower and larger than one that never paged.
539
+ * A patch keeps the record packed and costs one entry per edit rather than a
540
+ * container per edited record.
541
+ *
542
+ * The key carries the writing peer and a counter. The peer is what lets the
543
+ * patches be FOLDED back into their pages later without a lock: a CRDT delete
544
+ * only defeats a CONCURRENT write to the same key, and no two writers can
545
+ * name the same key while the peer is in it, so a peer editing during
546
+ * another's fold writes a key that fold never saw. A writer that re-mints a
547
+ * key it folded away itself is ordered after its own delete, so that write is
548
+ * not the race either.
549
+ *
550
+ * The counter orders the patches on ONE record, and is minted one above every
551
+ * counter that record already carries. That is what makes the fold causal: a
552
+ * patch written by someone who had already merged an earlier patch sorts
553
+ * above it, whoever wrote which. A counter that instead counted the WRITER's
554
+ * edits would put a busy session's first-ever edit of a record above a later
555
+ * session's, and the later edit would vanish as it was made.
556
+ *
557
+ * The record key goes last because it may contain a colon; the peer and
558
+ * counter may not.
559
+ *
560
+ * Each reserved space differs from its siblings at the character after
561
+ * `RESERVED_KEY_PREFIX`, so `classifyStoreKey` decides between them with one
562
+ * test each and no ordering to get right. */
563
+ export const PATCH_PREFIX = `${RESERVED_KEY_PREFIX}q:`;
564
+
565
+ /** Width the counter is padded to, so lexical key order is counter order. */
566
+ const PATCH_COUNTER_DIGITS = 12;
567
+
568
+ export function patchKeyFor(
569
+ recordKey: string,
570
+ peer: string,
571
+ counter: number,
572
+ ): string {
573
+ const n = String(counter).padStart(PATCH_COUNTER_DIGITS, '0');
574
+ return `${PATCH_PREFIX}${peer}:${n}:${recordKey}`;
575
+ }
576
+
577
+ export interface ParsedPatchKey {
578
+ recordKey: string;
579
+ peer: string;
580
+ counter: string;
581
+ }
582
+
583
+ /** What one entry of a paged map is, decided from its key alone.
584
+ *
585
+ * `record` is the ordinary case: every key outside the reserved space names a
586
+ * record, whatever its value holds. `unknown` is a reserved key this build
587
+ * does not recognise, which a reader skips rather than reporting as a record,
588
+ * so a store written by a later build degrades to missing entries instead of
589
+ * entries named after the store's own bookkeeping. */
590
+ export type StoreEntryKind =
591
+ | { kind: 'record'; recordKey: string }
592
+ | { kind: 'page' }
593
+ | { kind: 'overlay'; recordKey: string }
594
+ | { kind: 'tombstone'; recordKey: string }
595
+ | { kind: 'patch'; parsed: ParsedPatchKey }
596
+ | { kind: 'unknown' };
597
+
598
+ /** Classify one key of a paged map. The single place the key spaces are told
599
+ * apart, so a keyed read, a whole-store read, a streaming read, and the page
600
+ * index cannot disagree about which entry is which. */
601
+ export function classifyStoreKey(key: string): StoreEntryKind {
602
+ if (!isReservedKey(key)) return { kind: 'record', recordKey: key };
603
+ if (key.startsWith(OVERLAY_PREFIX)) {
604
+ return { kind: 'overlay', recordKey: key.slice(OVERLAY_PREFIX.length) };
605
+ }
606
+ if (key.startsWith(TOMBSTONE_PREFIX)) {
607
+ return { kind: 'tombstone', recordKey: key.slice(TOMBSTONE_PREFIX.length) };
608
+ }
609
+ if (key.startsWith(SESSION_PAGE_PREFIX)) return { kind: 'page' };
610
+ const parsed = parsePatchKey(key);
611
+ return parsed === null ? { kind: 'unknown' } : { kind: 'patch', parsed };
612
+ }
613
+
614
+ /** The parts of a patch key, or null when `key` is not one.
615
+ *
616
+ * The record key runs to the end of the string and may be empty, which is a
617
+ * record key like any other: the two colons after the prefix delimit the peer
618
+ * and the counter, so the tail needs no length of its own to be
619
+ * unambiguous. Reading an empty tail as "not a patch" instead leaves the
620
+ * entry classified `unknown`, which every reader skips, so the edit stays in
621
+ * the document and reaches nobody. */
622
+ export function parsePatchKey(key: string): ParsedPatchKey | null {
623
+ if (!key.startsWith(PATCH_PREFIX)) return null;
624
+ const peerEnd = key.indexOf(':', PATCH_PREFIX.length);
625
+ if (peerEnd === -1) return null;
626
+ const counterEnd = key.indexOf(':', peerEnd + 1);
627
+ if (counterEnd === -1) return null;
628
+ const recordKey = key.slice(counterEnd + 1);
629
+ return {
630
+ recordKey,
631
+ peer: key.slice(PATCH_PREFIX.length, peerEnd),
632
+ counter: key.slice(peerEnd + 1, counterEnd),
633
+ };
634
+ }
635
+
636
+ /** One patch: the fields an edit changed, plus the pair that orders it. */
637
+ export interface RecordPatch {
638
+ peer: string;
639
+ counter: string;
640
+ fields: unknown;
641
+ }
642
+
643
+ /** Apply `patches` over `base`, in the order every reader must agree on: by
644
+ * counter, then by peer id. The counter is per record and minted one above
645
+ * what that record already carries, so a patch whose writer had merged an
646
+ * earlier one applies over it. Peer id breaks the tie between two writers who
647
+ * had not seen each other, so both reach the same answer. */
648
+ export function applyRecordPatches(
649
+ base: unknown,
650
+ patches: RecordPatch[],
651
+ ): unknown {
652
+ if (patches.length === 0) return base;
653
+ if (!base || typeof base !== 'object' || Array.isArray(base)) return base;
654
+ const ordered =
655
+ patches.length === 1
656
+ ? patches
657
+ : [...patches].sort((a, b) =>
658
+ a.counter === b.counter
659
+ ? a.peer.localeCompare(b.peer)
660
+ : a.counter.localeCompare(b.counter),
661
+ );
662
+ let out = base as Record<string, unknown>;
663
+ for (const { fields: patch } of ordered) {
664
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) continue;
665
+ out = { ...out, ...(patch as Record<string, unknown>) };
666
+ }
667
+ return out;
668
+ }
669
+
670
+ /**
671
+ * Apply patches to a record a streaming read carries both as its whole stored
672
+ * value and as the `(tag, body)` pair split out of it, and return all three
673
+ * after the fold.
674
+ *
675
+ * A patch names the record's own fields, which for a typed record are
676
+ * `type` and `params` together, so a reader that walks tag and body
677
+ * separately has to re-split the result. Both readers of a paged store
678
+ * (the flattening one and the streaming one) resolve patches through this
679
+ * module, so an edit cannot read one way in the editor and another in a
680
+ * re-emitted file. The fold runs over the whole value for the same reason the
681
+ * streaming read carries one: a field beyond the pair is a field of the record
682
+ * and an edit may name it.
683
+ */
684
+ export function applyPatchesToRecord(
685
+ record: unknown,
686
+ tag: string,
687
+ body: unknown,
688
+ patches: RecordPatch[],
689
+ ): { tag: string; body: unknown; record: unknown } {
690
+ if (patches.length === 0) return { tag, body, record };
691
+ const patched = applyRecordPatches(record, patches);
692
+ if (!patched || typeof patched !== 'object' || Array.isArray(patched)) {
693
+ return { tag, body, record: patched };
694
+ }
695
+ const fields = patched as Record<string, unknown>;
696
+ const nextTag = fields[TAG_FIELD];
697
+ return {
698
+ tag: typeof nextTag === 'string' ? nextTag : tag,
699
+ body: fields[BODY_FIELD],
700
+ record: patched,
701
+ };
702
+ }
703
+
704
+ /** Where a paged store keeps each record, reported as a flatten resolves it.
705
+ * A reader that has to locate records by key (a keyed read over pages filled
706
+ * in creation order) takes its index from the walk that decodes the pages
707
+ * anyway, rather than decoding them a second time of its own. */
708
+ export interface RecordLocationSink {
709
+ /** `recordKey` is packed in the body of page `pageKey`. */
710
+ page(recordKey: string, pageKey: string): void;
711
+ /** A container of its own answers for `recordKey`, outranking any page. */
712
+ contained(recordKey: string): void;
713
+ /** A tombstone removes `recordKey`, whatever a page still holds. */
714
+ removed(recordKey: string): void;
228
715
  }
229
716
 
230
717
  /** Flatten a possibly-PAGED `instances` lookup to the flat
@@ -232,39 +719,101 @@ export function isPagedByPageCount(pageCount: unknown): boolean {
232
719
  * returned unchanged (no allocation). The single place paging is undone on the
233
720
  * read side.
234
721
  *
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. */
722
+ * `paged` is the AUTHORITATIVE page state, read from the root's own `meta`
723
+ * key by the caller (`pagedRootKey` / `isPagedMarker`). Page interpretation
724
+ * MUST be gated on it: in an UNPAGED store every key is a record key,
725
+ * including one that looks reserved, because nothing refused it before the
726
+ * store was written. */
242
727
  export function flattenPagedLookup(
243
728
  lookup: Record<string, unknown>,
244
729
  paged: boolean,
730
+ into?: RecordLocationSink,
245
731
  ): Record<string, unknown> {
246
732
  if (!paged) return lookup;
247
733
 
248
734
  const flat: Record<string, unknown> = {};
735
+ // Anything written OUTSIDE a page is applied in a later pass, so a record
736
+ // held in its own container always wins over a page's copy of it, whatever
737
+ // order the map hands its keys back in. Resolving either in the page pass
738
+ // would make the winner depend on that order, which is the store's business
739
+ // and not a decision a reader should inherit.
740
+ //
741
+ // Two kinds, in increasing authority. A STRAY is a record under its own key
742
+ // in a paged map: every record written before the map was paged, plus what a
743
+ // client that did not know it was paged leaves behind. Either way it is the
744
+ // newer copy, so it beats the page. An OVERLAY entry is an edit written
745
+ // beside the pages, so it beats both.
746
+ const stray: [string, unknown][] = [];
747
+ const overlay: [string, unknown][] = [];
748
+ // A delete a page still holds is stated as its own entry, so the removals
749
+ // are applied after the whole-record passes.
750
+ const removed: string[] = [];
751
+ // A PATCH carries only the fields one edit changed, so it patches whatever
752
+ // the whole-record passes resolved to rather than replacing it. That is what
753
+ // keeps two peers changing different fields of one record from overwriting
754
+ // each other, which a whole-record write cannot do.
755
+ const patches = new Map<string, RecordPatch[]>();
249
756
  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],
757
+ const entry = classifyStoreKey(key);
758
+ switch (entry.kind) {
759
+ case 'record':
760
+ stray.push([key, value]);
761
+ break;
762
+ case 'overlay':
763
+ overlay.push([entry.recordKey, value]);
764
+ break;
765
+ case 'tombstone':
766
+ removed.push(entry.recordKey);
767
+ break;
768
+ case 'patch': {
769
+ const list = patches.get(entry.parsed.recordKey);
770
+ const patch = {
771
+ peer: entry.parsed.peer,
772
+ counter: entry.parsed.counter,
773
+ fields: value,
265
774
  };
775
+ if (list) list.push(patch);
776
+ else patches.set(entry.parsed.recordKey, [patch]);
777
+ break;
266
778
  }
779
+ case 'page':
780
+ for (const packed of decodePageRecords(value)) {
781
+ flat[packed[0]] = unpackRecord(packed);
782
+ into?.page(packed[0], key);
783
+ }
784
+ break;
785
+ case 'unknown':
786
+ break;
267
787
  }
268
788
  }
789
+ for (const [key, value] of stray) {
790
+ flat[key] = value;
791
+ into?.contained(key);
792
+ }
793
+ for (const [recordKey, value] of overlay) {
794
+ flat[recordKey] = value;
795
+ into?.contained(recordKey);
796
+ }
797
+ // A delete outranks every copy of the record, so a peer's concurrent edit of
798
+ // a record another peer removed reads as removed for both of them.
799
+ for (const recordKey of removed) {
800
+ delete flat[recordKey];
801
+ into?.removed(recordKey);
802
+ }
803
+ // Last, so a patch lands on the record whichever pass produced it. A patch
804
+ // for a record no pass produced is dropped rather than resurrecting a
805
+ // partial one: the record was deleted, and half of it is not a record.
806
+ //
807
+ // "No pass produced it" is an OWN-property question. A record key is any
808
+ // string, so one naming a member of `Object.prototype` (`toString`,
809
+ // `valueOf`) reads back as that member from an object nothing has assigned
810
+ // it on, and a plain `=== undefined` test then takes the inherited function
811
+ // for the record: a record two peers concurrently deleted and patched comes
812
+ // back from the dead as a function, in a store no app wrote anything odd
813
+ // into.
814
+ for (const [recordKey, list] of patches) {
815
+ if (!Object.prototype.hasOwnProperty.call(flat, recordKey)) continue;
816
+ flat[recordKey] = applyRecordPatches(flat[recordKey], list);
817
+ }
269
818
  return flat;
270
819
  }