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