@brass-build/client 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- 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 +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.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-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/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- 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 +253 -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 +1 -1
- package/dist/internal.d.ts.map +1 -1
- 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 +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- 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 +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -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 +226 -75
- 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 +140 -19
- 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/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -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 +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- 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 +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- 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 +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -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 +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -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 +249 -80
- 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 +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
package/src/handles.ts
CHANGED
|
@@ -6,179 +6,76 @@
|
|
|
6
6
|
// the handle boundary, so handle-only app code does not pin the SDK to Loro.
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
LoroMovableList,
|
|
10
10
|
LoroMap,
|
|
11
11
|
type LoroEventBatch,
|
|
12
12
|
isContainer,
|
|
13
|
+
type Delta,
|
|
13
14
|
} from 'loro-crdt';
|
|
14
15
|
|
|
15
16
|
import {
|
|
16
17
|
PAGE_TAG,
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
UNPAGED_MAX_RECORDS,
|
|
19
|
+
PAGE_KEYS_FIELD,
|
|
20
|
+
type EncodedPage,
|
|
21
|
+
type RecordLocationSink,
|
|
22
|
+
assertUsableRecordKey,
|
|
23
|
+
classifyStoreKey,
|
|
24
|
+
decodePageKeys,
|
|
25
|
+
decodePageRecords,
|
|
19
26
|
decodeRecordValue,
|
|
20
|
-
|
|
27
|
+
encodePage,
|
|
21
28
|
flattenPagedLookup,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
isReservedKey,
|
|
30
|
+
overlayKeyFor,
|
|
31
|
+
packWholeValue,
|
|
32
|
+
parseSessionPageKey,
|
|
33
|
+
repackRecord,
|
|
34
|
+
parsePatchKey,
|
|
35
|
+
patchKeyFor,
|
|
36
|
+
type RecordPatch,
|
|
37
|
+
applyRecordPatches,
|
|
38
|
+
recordsPerPage,
|
|
39
|
+
sampleBodyBytes,
|
|
40
|
+
sessionPageKey,
|
|
41
|
+
tombstoneKeyFor,
|
|
42
|
+
unpackRecord,
|
|
25
43
|
} from './record-paging.js';
|
|
26
|
-
import { BrassError } from './errors.js';
|
|
27
44
|
import { MutateBatcher } from './mutate-batching.js';
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// synchronous read right after a write (id allocation, validation) read the
|
|
63
|
-
// handle (`read` / `get`), which reflects the edit at once.
|
|
64
|
-
export type MapSubscriber<T = unknown> = (
|
|
65
|
-
change: Change<T>,
|
|
66
|
-
origin?: ChangeOrigin,
|
|
67
|
-
) => void;
|
|
68
|
-
|
|
69
|
-
// Internal (not exported): a map subscriber that also learns whether a fire is
|
|
70
|
-
// a whole-container catch-up (`resync: true`: registration, or a checkout /
|
|
71
|
-
// return-to-head / hydrate swapping the source) or an incremental delta
|
|
72
|
-
// (`false`). This is the "full re-sync vs incremental" distinction, ORTHOGONAL
|
|
73
|
-
// to `origin` (which is authorship: local / remote / undo / redo, all one path
|
|
74
|
-
// to a render). The ordered map needs it because a source swap can reorder
|
|
75
|
-
// `instance_order` with the same key set, which its key-set move test cannot
|
|
76
|
-
// see; keying off this explicit flag keeps that check gated to the rare
|
|
77
|
-
// re-sync fires without overloading `origin`. The public `subscribe` wraps an
|
|
78
|
-
// app `MapSubscriber` and drops the flag, so it never reaches an app.
|
|
79
|
-
type InternalMapSubscriber<T> = (
|
|
80
|
-
change: Change<T>,
|
|
81
|
-
origin: ChangeOrigin | undefined,
|
|
82
|
-
resync: boolean,
|
|
83
|
-
) => void;
|
|
84
|
-
|
|
85
|
-
// A list subscriber. Lists are positional with no per-key delta, so a fire
|
|
86
|
-
// is a signal to re-read: `subscribe(() => render(list.read()))`. Fires once
|
|
87
|
-
// on registration, then on every change.
|
|
88
|
-
export type ListSubscriber = () => void;
|
|
89
|
-
|
|
90
|
-
export type HandleUnsubscribe = () => void;
|
|
91
|
-
|
|
92
|
-
// Hooks the owning Document provides so handles participate in the SDK's
|
|
93
|
-
// commit + origin machinery. Defined here (not in documents.ts) so handles.ts
|
|
94
|
-
// has no compile-time dependency on the Document class.
|
|
95
|
-
export interface HandleOwner {
|
|
96
|
-
// Throws if the Document is closed, suspended, or read-only.
|
|
97
|
-
assertWritable(): void;
|
|
98
|
-
// Lazily, idempotently attaches the local-updates hook before first mutate.
|
|
99
|
-
ensureLocalUpdatesSubscribed(): void;
|
|
100
|
-
// Stamps the next commit with the mutate origin tag and commits, so the
|
|
101
|
-
// change's `origin.source` reads `'local'`.
|
|
102
|
-
commitMutate(): void;
|
|
103
|
-
// Classify a Loro event by what produced it. Derived from the commit's
|
|
104
|
-
// `by` + origin tag, so it survives microtask-deferred delivery.
|
|
105
|
-
originOf(event: unknown): ChangeSource;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Public handle interfaces. Concrete classes below.
|
|
109
|
-
|
|
110
|
-
export interface MapHandle<T = unknown> {
|
|
111
|
-
// The whole map as a plain JS object. Walks only this map's slice, not the
|
|
112
|
-
// rest of the document; the fast alternative to `doc.toJSON()`.
|
|
113
|
-
read(): Record<string, T>;
|
|
114
|
-
// Per-entry read. Returns undefined when missing.
|
|
115
|
-
get(entryKey: string): T | undefined;
|
|
116
|
-
// Keys currently in the map.
|
|
117
|
-
keys(): string[];
|
|
118
|
-
// Whether an entry exists at `entryKey`.
|
|
119
|
-
has(entryKey: string): boolean;
|
|
120
|
-
// Mutate through a typed JS-proxy, scoped to this container; same
|
|
121
|
-
// ergonomics as `doc.mutate(d => { d.entities[id].field = ... })`.
|
|
122
|
-
mutate(fn: (draft: Record<string, T>) => void): void;
|
|
123
|
-
// ONE channel for every change whatever produced it (local / remote / undo
|
|
124
|
-
// / redo / checkout): the projection reconciles by value. The callback gets
|
|
125
|
-
// `change` and optional `origin` a render ignores. Fires once on
|
|
126
|
-
// registration with the whole map as `change.set`, then on every change
|
|
127
|
-
// (each later fire O(changed)).
|
|
128
|
-
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
|
|
129
|
-
// Child handle for `entries[entryKey]` when that entry is itself a map /
|
|
130
|
-
// list. Reads / subscribes no-op until the container exists; the first
|
|
131
|
-
// mutate creates it.
|
|
132
|
-
map<U>(entryKey: string): MapHandle<U>;
|
|
133
|
-
list<U>(entryKey: string): ListHandle<U>;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export interface ListHandle<T = unknown> {
|
|
137
|
-
read(): T[];
|
|
138
|
-
length(): number;
|
|
139
|
-
mutate(fn: (draft: T[]) => void): void;
|
|
140
|
-
// Positional, no per-key delta, so a fire is a signal to re-read:
|
|
141
|
-
// `subscribe(() => render(list.read()))`. Fires once on registration, then
|
|
142
|
-
// on every change. A keyed collection belongs in a map (or `orderedMap`),
|
|
143
|
-
// which reconciles O(changed); a list is for ordering keys and small
|
|
144
|
-
// positional data.
|
|
145
|
-
subscribe(cb: ListSubscriber): HandleUnsubscribe;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// An ordered map: a keyed map whose key insertion order is mirrored into a
|
|
149
|
-
// sibling list, kept in lockstep. The record store (`instances` +
|
|
150
|
-
// `instance_order`) is the motivating adopter, reached as
|
|
151
|
-
// `doc.orderedMap('instances', 'instance_order')`, but the primitive names no
|
|
152
|
-
// format: two ordinary root containers bound so an app cannot forget to
|
|
153
|
-
// update the order list on add / remove.
|
|
154
|
-
//
|
|
155
|
-
// Add appends to the order list, delete removes from it, both in ONE commit
|
|
156
|
-
// (one undo step). Reassigning a key's value (a `params` edit) leaves the
|
|
157
|
-
// order untouched and stays O(1): only structural add / delete touches it.
|
|
158
|
-
export interface OrderedMapHandle<T = unknown> {
|
|
159
|
-
// The map as a plain JS object. Use `ordered()` for order-list order.
|
|
160
|
-
read(): Record<string, T>;
|
|
161
|
-
// Per-entry read. Returns undefined when missing.
|
|
162
|
-
get(entryKey: string): T | undefined;
|
|
163
|
-
// Whether an entry exists in the map.
|
|
164
|
-
has(entryKey: string): boolean;
|
|
165
|
-
// Keys in order-list order, filtered to keys still present in the map.
|
|
166
|
-
keys(): string[];
|
|
167
|
-
// `[key, value]` records walking the order list, so an app reading in
|
|
168
|
-
// source order doesn't re-walk the order list by hand.
|
|
169
|
-
ordered(): { key: string; value: T }[];
|
|
170
|
-
// Mutate the map; the sibling order list reconciles in the same commit
|
|
171
|
-
// (new keys appended in insertion order, deleted keys pruned). Drive it
|
|
172
|
-
// like `MapHandle.mutate`: `m[key] = record`, `delete m[key]`,
|
|
173
|
-
// `m[key].field = ...`.
|
|
174
|
-
mutate(fn: (draft: Record<string, T>) => void): void;
|
|
175
|
-
// Fires once on registration (the whole map as `change.set` in order),
|
|
176
|
-
// then on every change. A field edit carries only `set` / `deleted`; a
|
|
177
|
-
// change that moves the order also carries `change.order` (live keys in
|
|
178
|
-
// order) so the app re-sequences the views it holds. Content stays
|
|
179
|
-
// O(changed); re-sequencing happens only when the order moves.
|
|
180
|
-
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
|
|
181
|
-
}
|
|
46
|
+
import type {
|
|
47
|
+
ListChange,
|
|
48
|
+
Splice,
|
|
49
|
+
Change,
|
|
50
|
+
ChangeOrigin,
|
|
51
|
+
ChangeSource,
|
|
52
|
+
HandleOwner,
|
|
53
|
+
HandleUnsubscribe,
|
|
54
|
+
InternalListSubscriber,
|
|
55
|
+
InternalMapSubscriber,
|
|
56
|
+
ListDraft,
|
|
57
|
+
ListHandle,
|
|
58
|
+
ListSubscriber,
|
|
59
|
+
MapHandle,
|
|
60
|
+
MapSubscriber,
|
|
61
|
+
OrderedMapHandle,
|
|
62
|
+
OrderedMapPosition,
|
|
63
|
+
} from './handle-types.js';
|
|
64
|
+
|
|
65
|
+
export type {
|
|
66
|
+
Change,
|
|
67
|
+
ChangeOrigin,
|
|
68
|
+
ChangeSource,
|
|
69
|
+
HandleOwner,
|
|
70
|
+
ListDraft,
|
|
71
|
+
HandleUnsubscribe,
|
|
72
|
+
ListHandle,
|
|
73
|
+
ListSubscriber,
|
|
74
|
+
MapHandle,
|
|
75
|
+
MapSubscriber,
|
|
76
|
+
OrderedMapHandle,
|
|
77
|
+
OrderedMapPosition,
|
|
78
|
+
};
|
|
182
79
|
|
|
183
80
|
// ---- Plain-JS-proxy machinery, scoped per-container --------------------
|
|
184
81
|
|
|
@@ -217,103 +114,285 @@ function toPlainDeep(v: unknown): unknown {
|
|
|
217
114
|
return v;
|
|
218
115
|
}
|
|
219
116
|
|
|
117
|
+
/**
|
|
118
|
+
* One live map entry as a plain value: a container through `toJSON`, any other
|
|
119
|
+
* value passed through.
|
|
120
|
+
*
|
|
121
|
+
* Every container kind is materialised, not just the two a record is usually
|
|
122
|
+
* written as: what a key holds is the app's choice, so an array under a record
|
|
123
|
+
* key arrives as a `LoroMovableList`, and a raw handle fails the tests a caller makes
|
|
124
|
+
* of it while the bulk read of the same store hands back a plain value.
|
|
125
|
+
*
|
|
126
|
+
* Returns the handle to wasm once the plain value is out. `m.get` mints a new
|
|
127
|
+
* wasm-bindgen handle every call, finalisation lags allocation, and wasm linear
|
|
128
|
+
* memory never shrinks, so a handle left undropped is held until the tab
|
|
129
|
+
* closes. This runs on every keyed read and every subscriber fire, so the
|
|
130
|
+
* arithmetic reaches tens of MB over a working session.
|
|
131
|
+
*/
|
|
132
|
+
function plainEntry(m: LoroMap, key: string): unknown {
|
|
133
|
+
const v = m.get(key);
|
|
134
|
+
if (!isContainer(v)) return v;
|
|
135
|
+
const container = v as { toJSON(): unknown; free(): void };
|
|
136
|
+
try {
|
|
137
|
+
return container.toJSON();
|
|
138
|
+
} finally {
|
|
139
|
+
container.free();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether a key is set, without keeping the handle the read minted. A
|
|
144
|
+
* presence check discards the value, so the handle is pure waste; it is
|
|
145
|
+
* still a handle, and this runs once per key on an ordered-map write. */
|
|
146
|
+
function entryExists(m: LoroMap, key: string): boolean {
|
|
147
|
+
const v = m.get(key);
|
|
148
|
+
if (v === undefined) return false;
|
|
149
|
+
if (isContainer(v)) (v as { free(): void }).free();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
220
153
|
function assignPlain(target: LoroMap, value: Record<string, unknown>): void {
|
|
221
154
|
// Recursively materialize nested objects / arrays as nested LoroMap /
|
|
222
|
-
//
|
|
155
|
+
// LoroMovableList containers so the CRDT mirrors the JS shape. Clear keys not in
|
|
223
156
|
// `value` so assignment is a replace, not a merge (property-level patching
|
|
224
157
|
// uses `d.entities[id].x = ...`).
|
|
225
158
|
for (const k of target.keys()) {
|
|
226
159
|
if (!(k in value)) target.delete(k);
|
|
227
160
|
}
|
|
228
161
|
for (const [k, rawV] of Object.entries(value)) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
162
|
+
assignEntry(target, k, rawV);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Write one value at one map key, in the shape that key should hold: a nested
|
|
167
|
+
// object / array becomes a child container mirroring the JS shape, anything
|
|
168
|
+
// else a plain value. The single definition of what assigning to a map entry
|
|
169
|
+
// means, shared by `assignPlain` and the mutate proxy's set trap, so the two
|
|
170
|
+
// cannot disagree about the shape a value lands in.
|
|
171
|
+
function assignEntry(target: LoroMap, key: string, rawValue: unknown): void {
|
|
172
|
+
const value = materializeContainers(rawValue);
|
|
173
|
+
const existing = target.get(key);
|
|
174
|
+
// Plain-VALUE preservation: when the key holds a plain (non-container)
|
|
175
|
+
// object or array, the new structure is written as a plain value too, never
|
|
176
|
+
// promoted. This keeps a record body (`rec.params`, one JSON value whose
|
|
177
|
+
// whole-replace is the merge unit) a value across edits; promoting it would
|
|
178
|
+
// change the merge granularity and bloat the snapshot with per-element
|
|
179
|
+
// containers.
|
|
180
|
+
if (
|
|
181
|
+
existing !== undefined &&
|
|
182
|
+
!isContainer(existing) &&
|
|
183
|
+
typeof existing === 'object' &&
|
|
184
|
+
existing !== null &&
|
|
185
|
+
typeof value === 'object' &&
|
|
186
|
+
value !== null
|
|
187
|
+
) {
|
|
188
|
+
target.set(key, toPlainDeep(value) as never);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (isPlainObject(value)) {
|
|
192
|
+
// Reuse an existing LoroMap at this key: a fresh `setContainer` mints a
|
|
193
|
+
// new container ID, which under Loro's UndoManager is a structural
|
|
194
|
+
// destroy+create that does NOT merge within the merge window. In-place
|
|
195
|
+
// field overwrites DO merge, which whole-entity-rewrites-at-drag-cadence
|
|
196
|
+
// expects.
|
|
197
|
+
const sub =
|
|
198
|
+
existing instanceof LoroMap
|
|
199
|
+
? existing
|
|
200
|
+
: target.setContainer(key, new LoroMap());
|
|
201
|
+
assignPlain(sub, value);
|
|
202
|
+
} else if (Array.isArray(value)) {
|
|
203
|
+
const sub =
|
|
204
|
+
existing instanceof LoroMovableList
|
|
205
|
+
? existing
|
|
206
|
+
: target.setContainer(key, new LoroMovableList());
|
|
207
|
+
assignList(sub, value);
|
|
208
|
+
} else {
|
|
209
|
+
target.set(key, value as never);
|
|
266
210
|
}
|
|
267
211
|
}
|
|
268
212
|
|
|
269
|
-
function assignList(target:
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
213
|
+
function assignList(target: LoroMovableList, value: readonly unknown[]): void {
|
|
214
|
+
// Assigning a whole array writes the positions that differ, in place. Two
|
|
215
|
+
// properties come out of that, and the second is why a position is SET
|
|
216
|
+
// rather than deleted and re-inserted.
|
|
217
|
+
//
|
|
218
|
+
// A position already holding the value needs no op at all. Re-assigning an
|
|
219
|
+
// array is how an app states an edit to one of its rows (`rows[1].n = 2;
|
|
220
|
+
// m.rows = rows`), so most positions are unchanged, and an op written for an
|
|
221
|
+
// unchanged one is both a container a peer's concurrent field edit is
|
|
222
|
+
// detached from and one more thing for it to resolve against.
|
|
223
|
+
//
|
|
224
|
+
// A position that DOES differ is set, which is one op naming that position.
|
|
225
|
+
// Deleting and re-inserting states the same intent to a movable list, but
|
|
226
|
+
// the delete and the insert commute with a peer's concurrent write to the
|
|
227
|
+
// same position, so the pair leaves the entry stored twice while `set`
|
|
228
|
+
// leaves it stored once. That is the same reasoning `assignListPosition`
|
|
229
|
+
// carries, and a whole-array assign is the write that reaches it most.
|
|
273
230
|
const oldLen = target.length;
|
|
274
231
|
for (let i = 0; i < value.length; i++) {
|
|
275
232
|
const item = materializeContainers(value[i]);
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
assignPlain(child, item);
|
|
280
|
-
} else if (Array.isArray(item)) {
|
|
281
|
-
// A nested list: rebuild as a child LoroList rather than inserting the
|
|
282
|
-
// array as an opaque value.
|
|
283
|
-
if (i < oldLen) target.delete(i, 1);
|
|
284
|
-
const child = target.insertContainer(i, new LoroList());
|
|
285
|
-
assignList(child, item);
|
|
286
|
-
} else {
|
|
287
|
-
if (i < oldLen) {
|
|
288
|
-
target.delete(i, 1);
|
|
289
|
-
target.insert(i, item as never);
|
|
290
|
-
} else {
|
|
291
|
-
target.insert(i, item as never);
|
|
292
|
-
}
|
|
233
|
+
if (i >= oldLen) {
|
|
234
|
+
insertListAt(target, i, item);
|
|
235
|
+
continue;
|
|
293
236
|
}
|
|
237
|
+
if (samePlainValue(plainListItem(target, i), item)) continue;
|
|
238
|
+
assignListPosition(target, i, item);
|
|
294
239
|
}
|
|
295
240
|
if (value.length < oldLen) {
|
|
296
241
|
target.delete(value.length, oldLen - value.length);
|
|
297
242
|
}
|
|
298
243
|
}
|
|
299
244
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
245
|
+
/** Whether a position already holds this value, so assigning it writes
|
|
246
|
+
* nothing. Compared as plain data, which is the form both sides are already
|
|
247
|
+
* in: the stored side through `toJSON`, the incoming side as the app wrote
|
|
248
|
+
* it. A pair that differs only in key ORDER compares unequal and is
|
|
249
|
+
* rewritten, which costs an op and stores the same thing. */
|
|
250
|
+
function samePlainValue(stored: unknown, next: unknown): boolean {
|
|
251
|
+
if (stored === next) return true;
|
|
252
|
+
if (
|
|
253
|
+
stored === null ||
|
|
254
|
+
next === null ||
|
|
255
|
+
typeof stored !== 'object' ||
|
|
256
|
+
typeof next !== 'object'
|
|
257
|
+
) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
return JSON.stringify(stored) === JSON.stringify(next);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Insert one value at `idx` (same plain-vs-container handling as the indexed-
|
|
264
|
+
// set trap). The proxy's native mutators build on this so an insertion costs
|
|
265
|
+
// one op at one position instead of a full-list replay.
|
|
266
|
+
function insertListAt(target: LoroMovableList, idx: number, rawItem: unknown): void {
|
|
305
267
|
const item = materializeContainers(rawItem);
|
|
306
268
|
if (isPlainObject(item)) {
|
|
307
269
|
const child = target.insertContainer(idx, new LoroMap());
|
|
308
270
|
assignPlain(child, item);
|
|
309
271
|
} else if (Array.isArray(item)) {
|
|
310
|
-
const child = target.insertContainer(idx, new
|
|
272
|
+
const child = target.insertContainer(idx, new LoroMovableList());
|
|
311
273
|
assignList(child, item);
|
|
312
274
|
} else {
|
|
313
275
|
target.insert(idx, item as never);
|
|
314
276
|
}
|
|
315
277
|
}
|
|
316
278
|
|
|
279
|
+
function insertListTail(target: LoroMovableList, rawItem: unknown): void {
|
|
280
|
+
insertListAt(target, target.length, rawItem);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Replace what an EXISTING position holds, in one op at that position.
|
|
284
|
+
//
|
|
285
|
+
// A movable list can set a position, where a plain one has to delete and
|
|
286
|
+
// insert. That matters beyond op count: the delete and the insert commute with
|
|
287
|
+
// a peer's concurrent write to the same position, so the pair leaves the entry
|
|
288
|
+
// stored twice, and `set` leaves it stored once.
|
|
289
|
+
function assignListPosition(
|
|
290
|
+
target: LoroMovableList,
|
|
291
|
+
idx: number,
|
|
292
|
+
rawItem: unknown,
|
|
293
|
+
): void {
|
|
294
|
+
const item = materializeContainers(rawItem);
|
|
295
|
+
if (isPlainObject(item)) {
|
|
296
|
+
const child = target.setContainer(idx, new LoroMap());
|
|
297
|
+
assignPlain(child, item);
|
|
298
|
+
} else if (Array.isArray(item)) {
|
|
299
|
+
const child = target.setContainer(idx, new LoroMovableList());
|
|
300
|
+
assignList(child, item);
|
|
301
|
+
} else {
|
|
302
|
+
target.set(idx, item as never);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** One element as plain data, for a mutator's return value. */
|
|
307
|
+
function plainListItem(target: LoroMovableList, idx: number): unknown {
|
|
308
|
+
const v = target.get(idx);
|
|
309
|
+
if (isContainer(v)) return (v as { toJSON(): unknown }).toJSON();
|
|
310
|
+
return v;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A record's BODY, wrapped so writing a field inside it lands.
|
|
315
|
+
*
|
|
316
|
+
* A body is one plain JSON value, not a container, so reading it hands back
|
|
317
|
+
* an ordinary object. Without this, `m[key].params.height = 3` mutates that
|
|
318
|
+
* object and stops there: nothing is written, and nothing says so, which is
|
|
319
|
+
* the shape of every field edit a developer would reach for first. The write
|
|
320
|
+
* is rebuilt into a copy and pushed back through `commit`, which reassigns
|
|
321
|
+
* the whole body, so the record stays the merge unit exactly as an explicit
|
|
322
|
+
* `m[key].params = {...}` does.
|
|
323
|
+
*
|
|
324
|
+
* Only objects and arrays are wrapped, and only as they are reached, so a
|
|
325
|
+
* mutation that assigns without reading allocates nothing.
|
|
326
|
+
*
|
|
327
|
+
* The traps compose on ONE working copy rather than deriving a fresh copy of
|
|
328
|
+
* the original per write. An array mutator (`push`, `splice`, `sort`,
|
|
329
|
+
* `reverse`) writes several properties in sequence, each reading what the
|
|
330
|
+
* last one left, so a per-write copy keeps only the final assignment and
|
|
331
|
+
* commits an array that was never asked for: `push` on `[1,2,3]` writes index
|
|
332
|
+
* 3 and then `length`, and the `length` write derived from the original
|
|
333
|
+
* commits `[1,2,3,<empty>]`. The working copy is shallow and every write
|
|
334
|
+
* replaces rather than mutates in place, so the value handed in is never
|
|
335
|
+
* touched.
|
|
336
|
+
*/
|
|
337
|
+
function writeThroughBody(
|
|
338
|
+
value: unknown,
|
|
339
|
+
commit: (next: unknown) => void,
|
|
340
|
+
): unknown {
|
|
341
|
+
if (value === null || typeof value !== 'object') return value;
|
|
342
|
+
const working: Record<string, unknown> | unknown[] = Array.isArray(value)
|
|
343
|
+
? ([...value] as unknown[])
|
|
344
|
+
: ({ ...(value as Record<string, unknown>) } as Record<string, unknown>);
|
|
345
|
+
const snapshot = (): Record<string, unknown> | unknown[] =>
|
|
346
|
+
Array.isArray(working)
|
|
347
|
+
? ([...working] as unknown[])
|
|
348
|
+
: ({ ...working } as Record<string, unknown>);
|
|
349
|
+
return new Proxy(working as object, {
|
|
350
|
+
get(target, prop, receiver) {
|
|
351
|
+
const inner: unknown = Reflect.get(target, prop, receiver);
|
|
352
|
+
if (typeof prop === 'symbol') return inner;
|
|
353
|
+
if (inner === null || typeof inner !== 'object') return inner;
|
|
354
|
+
return writeThroughBody(inner, (next) => {
|
|
355
|
+
(working as Record<string, unknown>)[prop] = next;
|
|
356
|
+
commit(snapshot());
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
set(_target, prop, next: unknown) {
|
|
360
|
+
if (typeof prop === 'symbol') return false;
|
|
361
|
+
(working as Record<string, unknown>)[prop] = next;
|
|
362
|
+
commit(snapshot());
|
|
363
|
+
return true;
|
|
364
|
+
},
|
|
365
|
+
deleteProperty(_target, prop) {
|
|
366
|
+
if (typeof prop === 'symbol') return false;
|
|
367
|
+
delete (working as Record<string, unknown>)[prop];
|
|
368
|
+
commit(snapshot());
|
|
369
|
+
return true;
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Where a root map's ADDITIONS go once the map has paged itself part-way
|
|
376
|
+
* through a mutation.
|
|
377
|
+
*
|
|
378
|
+
* A mutation large enough to need paging usually starts against a map that
|
|
379
|
+
* is not yet paged, so the decision cannot be made once at the top. The
|
|
380
|
+
* proxy consults this on every assignment; while it declines, the mutation
|
|
381
|
+
* runs exactly as an unpaged one does.
|
|
382
|
+
*/
|
|
383
|
+
interface WriteDivert {
|
|
384
|
+
// Take an assignment, or decline it so it goes into the map as usual. A
|
|
385
|
+
// key the map already stores is always declined: it is an edit, and the
|
|
386
|
+
// record it edits is not this writer's to move.
|
|
387
|
+
take(key: string, value: unknown): boolean;
|
|
388
|
+
// Whether a taken assignment is being held for `key`, so the mutation's
|
|
389
|
+
// own later reads see what it just wrote.
|
|
390
|
+
holds(key: string): boolean;
|
|
391
|
+
peek(key: string): unknown;
|
|
392
|
+
drop(key: string): void;
|
|
393
|
+
keys(): string[];
|
|
394
|
+
}
|
|
395
|
+
|
|
317
396
|
// Exported because `Document.mutate` reuses the same proxy for its
|
|
318
397
|
// `entities` root. Load-bearing: `getOwnPropertyDescriptor` does a single
|
|
319
398
|
// `loroMap.get(key)` (O(1) per key, so `Object.keys()` is O(N)). Iterating
|
|
@@ -326,84 +405,93 @@ export function wrapLoroMapProxy(
|
|
|
326
405
|
// which reaches the same size a bulk write does while touching no
|
|
327
406
|
// top-level key.
|
|
328
407
|
batcher?: MutateBatcher,
|
|
408
|
+
divert?: WriteDivert,
|
|
409
|
+
// Whether this map's keys are RECORD keys, and so must stay out of the
|
|
410
|
+
// reserved space the store's own bookkeeping occupies. True for a root map,
|
|
411
|
+
// which is what can page; false inside a record, whose field names are the
|
|
412
|
+
// app's own and constrained by nothing.
|
|
413
|
+
guardKeys = false,
|
|
329
414
|
): Record<string, unknown> {
|
|
330
415
|
const handler: ProxyHandler<object> = {
|
|
331
416
|
get(_t, key) {
|
|
332
417
|
if (typeof key === 'symbol') return undefined;
|
|
418
|
+
if (divert?.holds(key)) {
|
|
419
|
+
const held = divert.peek(key);
|
|
420
|
+
if (held === null || typeof held !== 'object') return held;
|
|
421
|
+
return writeThroughBody(held, (next) => {
|
|
422
|
+
// Charged like the set trap's own assignment: a record filled field
|
|
423
|
+
// by field (`m[id] = {}` then `m[id].params = ...`) reaches the
|
|
424
|
+
// divert through here and nowhere else, so without this it is
|
|
425
|
+
// charged only whatever its first assigned value was and a bulk
|
|
426
|
+
// write built that way rides ops sized by the mutation.
|
|
427
|
+
batcher?.record(next);
|
|
428
|
+
divert.take(key, next);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
333
431
|
const v = loroMap.get(key);
|
|
432
|
+
if (!isContainer(v) && v !== null && typeof v === 'object') {
|
|
433
|
+
// A plain object / array body: hand back a view that writes a field
|
|
434
|
+
// edit through, rather than a detached copy that swallows it.
|
|
435
|
+
return writeThroughBody(v, (next) => {
|
|
436
|
+
handler.set!(_t, key, next, _t);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
334
439
|
if (isContainer(v)) {
|
|
335
440
|
// Present an engine-neutral view, never the raw container, so app
|
|
336
441
|
// code can't take a hard dependency on the CRDT engine. A LoroMap
|
|
337
|
-
// recurses into another proxy; a
|
|
442
|
+
// recurses into another proxy; a LoroMovableList reads back via `toJSON`
|
|
338
443
|
// (not `toArray`, which is shallow and hands back live element
|
|
339
444
|
// handles that corrupt on reassignment; list editing goes through
|
|
340
445
|
// `parent.list(key).mutate(...)`). Other container types are outside
|
|
341
446
|
// the v1 portable surface, so project them via toJSON().
|
|
342
447
|
if (v instanceof LoroMap) return wrapLoroMapProxy(v, batcher);
|
|
343
|
-
if (v instanceof
|
|
448
|
+
if (v instanceof LoroMovableList) return v.toJSON();
|
|
344
449
|
return (v as { toJSON(): unknown }).toJSON();
|
|
345
450
|
}
|
|
346
451
|
return v;
|
|
347
452
|
},
|
|
348
453
|
set(_t, key, value) {
|
|
349
454
|
if (typeof key === 'symbol') return false;
|
|
455
|
+
if (guardKeys) assertUsableRecordKey(key);
|
|
350
456
|
// Before the write, so a commit it triggers seals the preceding
|
|
351
457
|
// batch rather than splitting this record out of it.
|
|
352
458
|
batcher?.record(value);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
// snapshot with per-element containers.
|
|
360
|
-
if (
|
|
361
|
-
existing !== undefined &&
|
|
362
|
-
!isContainer(existing) &&
|
|
363
|
-
typeof existing === 'object' &&
|
|
364
|
-
existing !== null &&
|
|
365
|
-
typeof value === 'object' &&
|
|
366
|
-
value !== null
|
|
367
|
-
) {
|
|
368
|
-
loroMap.set(key, toPlainDeep(value) as never);
|
|
369
|
-
return true;
|
|
370
|
-
}
|
|
371
|
-
if (isPlainObject(value)) {
|
|
372
|
-
// Reuse the existing LoroMap (see `assignPlain`): a fresh
|
|
373
|
-
// `setContainer` is a destroy+create Loro won't merge across, so
|
|
374
|
-
// `entities[id] = {...}` at drag cadence accumulates one undo entry
|
|
375
|
-
// per frame instead of collapsing into the merge window.
|
|
376
|
-
const sub =
|
|
377
|
-
existing instanceof LoroMap
|
|
378
|
-
? existing
|
|
379
|
-
: loroMap.setContainer(key, new LoroMap());
|
|
380
|
-
assignPlain(sub, value);
|
|
381
|
-
} else if (Array.isArray(value)) {
|
|
382
|
-
const sub =
|
|
383
|
-
existing instanceof LoroList
|
|
384
|
-
? existing
|
|
385
|
-
: loroMap.setContainer(key, new LoroList());
|
|
386
|
-
assignList(sub, value);
|
|
387
|
-
} else {
|
|
388
|
-
loroMap.set(key, value as never);
|
|
389
|
-
}
|
|
459
|
+
if (divert?.take(key, value)) return true;
|
|
460
|
+
assignEntry(loroMap, key, value);
|
|
461
|
+
// Named to the batcher AFTER the write, never before: `record` above
|
|
462
|
+
// may have committed the batch this call arrived in, and a key named
|
|
463
|
+
// to that commit would be one whose write the commit does not carry.
|
|
464
|
+
batcher?.noteKeys([key]);
|
|
390
465
|
return true;
|
|
391
466
|
},
|
|
392
467
|
deleteProperty(_t, key) {
|
|
393
468
|
if (typeof key === 'symbol') return false;
|
|
394
|
-
|
|
469
|
+
if (guardKeys) assertUsableRecordKey(key);
|
|
470
|
+
batcher?.recordDelete(key);
|
|
471
|
+
divert?.drop(key);
|
|
395
472
|
loroMap.delete(key);
|
|
473
|
+
batcher?.noteKeys([key]);
|
|
396
474
|
return true;
|
|
397
475
|
},
|
|
398
476
|
has(_t, key) {
|
|
399
477
|
if (typeof key === 'symbol') return false;
|
|
478
|
+
if (divert?.holds(key)) return true;
|
|
400
479
|
return loroMap.get(key) !== undefined;
|
|
401
480
|
},
|
|
402
481
|
ownKeys() {
|
|
403
|
-
return loroMap.keys();
|
|
482
|
+
if (!divert) return loroMap.keys();
|
|
483
|
+
return [...new Set([...loroMap.keys(), ...divert.keys()])];
|
|
404
484
|
},
|
|
405
485
|
getOwnPropertyDescriptor(_t, key) {
|
|
406
486
|
if (typeof key === 'symbol') return undefined;
|
|
487
|
+
if (divert?.holds(key)) {
|
|
488
|
+
return {
|
|
489
|
+
enumerable: true,
|
|
490
|
+
configurable: true,
|
|
491
|
+
writable: true,
|
|
492
|
+
value: divert.peek(key),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
407
495
|
// Single get per key. Do NOT iterate `loroMap.keys()` here: it makes
|
|
408
496
|
// Object.keys() O(N^2) at 50k entities.
|
|
409
497
|
const v = loroMap.get(key);
|
|
@@ -419,28 +507,97 @@ export function wrapLoroMapProxy(
|
|
|
419
507
|
return new Proxy({}, handler) as Record<string, unknown>;
|
|
420
508
|
}
|
|
421
509
|
|
|
422
|
-
// Array.prototype methods that mutate
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
510
|
+
// Array.prototype methods that mutate in place, applied to the container as
|
|
511
|
+
// the targeted edits they are rather than replayed over it.
|
|
512
|
+
//
|
|
513
|
+
// The list stores as a `LoroMovableList`, so a reposition is `move(from, to)`
|
|
514
|
+
// and a replacement is `set(pos, value)`, both of which name the position they
|
|
515
|
+
// touch. That keeps the ENTRY the merge unit: two peers reordering different
|
|
516
|
+
// entries commute, and two reordering the same one converge on a list holding
|
|
517
|
+
// it once, where a delete-plus-insert replay leaves it named twice. Each edit
|
|
518
|
+
// is also its own op, so the commit budget bounds these the way it bounds
|
|
519
|
+
// every other list write.
|
|
520
|
+
//
|
|
426
521
|
// `push` is fast-pathed natively above the generic fallback and never reaches
|
|
427
|
-
// this set.
|
|
428
|
-
const
|
|
429
|
-
'pop',
|
|
430
|
-
'shift',
|
|
431
|
-
'unshift',
|
|
432
|
-
'splice',
|
|
522
|
+
// this set; every other array method is a pure read.
|
|
523
|
+
const REORDERING_ARRAY_METHODS: ReadonlySet<string> = new Set([
|
|
433
524
|
'sort',
|
|
434
525
|
'reverse',
|
|
526
|
+
]);
|
|
527
|
+
const REPLACING_ARRAY_METHODS: ReadonlySet<string> = new Set([
|
|
435
528
|
'fill',
|
|
436
529
|
'copyWithin',
|
|
437
530
|
]);
|
|
438
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Bring the container's order to `want` using moves, comparing by identity of
|
|
534
|
+
* the values already stored.
|
|
535
|
+
*
|
|
536
|
+
* A permutation is applied front to back: at each position, find where the
|
|
537
|
+
* wanted entry currently sits and move it up. Every entry lands in one move at
|
|
538
|
+
* most, and a position already correct costs nothing, so a `sort` over a
|
|
539
|
+
* nearly-ordered list writes nearly nothing.
|
|
540
|
+
*
|
|
541
|
+
* Duplicate values are matched left to right from the first position not yet
|
|
542
|
+
* settled, which is what keeps a list holding the same value twice from
|
|
543
|
+
* collapsing onto one of them.
|
|
544
|
+
*/
|
|
545
|
+
function applyOrder(
|
|
546
|
+
target: LoroMovableList,
|
|
547
|
+
want: readonly unknown[],
|
|
548
|
+
batcher?: MutateBatcher,
|
|
549
|
+
): void {
|
|
550
|
+
const current = target.toJSON() as unknown[];
|
|
551
|
+
const print = (v: unknown): string => JSON.stringify(v) ?? 'undefined';
|
|
552
|
+
// Where each value still sits, in order, so a lookup does not rescan.
|
|
553
|
+
const positions = new Map<string, number[]>();
|
|
554
|
+
for (let i = 0; i < current.length; i += 1) {
|
|
555
|
+
const k = print(current[i]);
|
|
556
|
+
const at = positions.get(k);
|
|
557
|
+
if (at) at.push(i);
|
|
558
|
+
else positions.set(k, [i]);
|
|
559
|
+
}
|
|
560
|
+
for (let i = 0; i < want.length && i < current.length; i += 1) {
|
|
561
|
+
const k = print(want[i]);
|
|
562
|
+
const at = positions.get(k);
|
|
563
|
+
if (!at || at.length === 0) continue;
|
|
564
|
+
// The leftmost occurrence at or after `i`; anything before it is settled.
|
|
565
|
+
let pick = -1;
|
|
566
|
+
for (let j = 0; j < at.length; j += 1) {
|
|
567
|
+
if (at[j]! >= i) {
|
|
568
|
+
pick = j;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (pick === -1) continue;
|
|
573
|
+
const from = at[pick]!;
|
|
574
|
+
at.splice(pick, 1);
|
|
575
|
+
if (from === i) continue;
|
|
576
|
+
// A move costs the entry, not the list, so it is charged like one write.
|
|
577
|
+
batcher?.record(want[i]);
|
|
578
|
+
target.move(from, i);
|
|
579
|
+
// Everything between the destination and the source shifted up by one.
|
|
580
|
+
for (const list of positions.values()) {
|
|
581
|
+
for (let j = 0; j < list.length; j += 1) {
|
|
582
|
+
const p = list[j]!;
|
|
583
|
+
if (p >= i && p < from) list[j] = p + 1;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
439
589
|
// An array-proxy for list mutators: index assignments, `.push` / `.splice`,
|
|
440
|
-
// and length-set route into the underlying
|
|
590
|
+
// and length-set route into the underlying LoroMovableList so the user writes
|
|
441
591
|
// idiomatic JS. Reads delegate to a fresh snapshot per access so a sequence
|
|
442
592
|
// of reads inside the mutator sees each write.
|
|
443
|
-
function wrapLoroListProxy(
|
|
593
|
+
function wrapLoroListProxy(
|
|
594
|
+
loroList: LoroMovableList,
|
|
595
|
+
// Threaded into nested element proxies too, so a field edit inside an
|
|
596
|
+
// element counts. A list takes the same bulk writes a map does (an app
|
|
597
|
+
// building rows, the record store's own order list), and nothing else bounds
|
|
598
|
+
// what one call puts in a single op.
|
|
599
|
+
batcher?: MutateBatcher,
|
|
600
|
+
): unknown[] {
|
|
444
601
|
const handler: ProxyHandler<unknown[]> = {
|
|
445
602
|
get(_t, key) {
|
|
446
603
|
if (key === 'length') return loroList.length;
|
|
@@ -455,12 +612,52 @@ function wrapLoroListProxy(loroList: LoroList): unknown[] {
|
|
|
455
612
|
const v = loroList.get(idx);
|
|
456
613
|
if (isContainer(v)) {
|
|
457
614
|
// Engine-neutral view only (see the map proxy's get trap).
|
|
458
|
-
if (v instanceof LoroMap) return wrapLoroMapProxy(v);
|
|
459
|
-
if (v instanceof
|
|
615
|
+
if (v instanceof LoroMap) return wrapLoroMapProxy(v, batcher);
|
|
616
|
+
if (v instanceof LoroMovableList) return v.toJSON();
|
|
460
617
|
return (v as { toJSON(): unknown }).toJSON();
|
|
461
618
|
}
|
|
619
|
+
if (v !== null && typeof v === 'object') {
|
|
620
|
+
// A plain object / array element, the same case the map proxy
|
|
621
|
+
// wraps: hand back a view that writes a field edit through instead
|
|
622
|
+
// of a detached copy that swallows it.
|
|
623
|
+
//
|
|
624
|
+
// The write-back replaces the element as a plain VALUE rather than
|
|
625
|
+
// going through the `set` trap, which promotes a plain object to a
|
|
626
|
+
// container. Promoting here would change what merges on a field
|
|
627
|
+
// edit, and a field edit is exactly the operation that should not
|
|
628
|
+
// move the merge boundary.
|
|
629
|
+
//
|
|
630
|
+
// `set` rather than a delete plus an insert, for the reason
|
|
631
|
+
// `assignListPosition` carries: the pair commutes with a peer's
|
|
632
|
+
// concurrent write to this position and leaves the element stored
|
|
633
|
+
// twice. `set` takes a plain value, so it keeps the shape this
|
|
634
|
+
// branch exists to preserve.
|
|
635
|
+
return writeThroughBody(v, (next) => {
|
|
636
|
+
batcher?.record(next);
|
|
637
|
+
if (idx < loroList.length) loroList.set(idx, next as never);
|
|
638
|
+
else loroList.insert(idx, next as never);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
462
641
|
return v;
|
|
463
642
|
}
|
|
643
|
+
// Reposition an entry, which is the operation a plain array cannot
|
|
644
|
+
// state: spelled as a removal plus an insertion it merges with a peer's
|
|
645
|
+
// reposition of the same entry by keeping both insertions, and the entry
|
|
646
|
+
// ends up in the list twice.
|
|
647
|
+
if (key === 'move') {
|
|
648
|
+
return (rawFrom: unknown, rawTo: unknown) => {
|
|
649
|
+
const len = loroList.length;
|
|
650
|
+
const from = Math.trunc(Number(rawFrom));
|
|
651
|
+
let to = Math.trunc(Number(rawTo));
|
|
652
|
+
if (!Number.isFinite(from) || from < 0 || from >= len) return;
|
|
653
|
+
if (!Number.isFinite(to)) return;
|
|
654
|
+
to = to < 0 ? Math.max(len + to, 0) : Math.min(to, len - 1);
|
|
655
|
+
if (to === from) return;
|
|
656
|
+
// A move writes one entry, so it is charged like one write.
|
|
657
|
+
batcher?.record(plainListItem(loroList, from));
|
|
658
|
+
loroList.move(from, to);
|
|
659
|
+
};
|
|
660
|
+
}
|
|
464
661
|
// `push` is the hot path (the ordered-map primitive appends every new
|
|
465
662
|
// key through it; apps bulk-build lists with it), so append natively.
|
|
466
663
|
// The toArray + replay below is O(list) PER call, turning N appends
|
|
@@ -468,25 +665,114 @@ function wrapLoroListProxy(loroList: LoroList): unknown[] {
|
|
|
468
665
|
// is O(1).
|
|
469
666
|
if (key === 'push') {
|
|
470
667
|
return (...items: unknown[]) => {
|
|
471
|
-
for (const item of items)
|
|
668
|
+
for (const item of items) {
|
|
669
|
+
// Before the write, so a commit it triggers seals the preceding
|
|
670
|
+
// batch rather than splitting this element out of it.
|
|
671
|
+
batcher?.record(item);
|
|
672
|
+
insertListTail(loroList, item);
|
|
673
|
+
}
|
|
674
|
+
return loroList.length;
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
// The mutators that name the positions they touch, so each writes those
|
|
678
|
+
// positions and leaves the rest of the list alone. This is what keeps an
|
|
679
|
+
// element the merge unit: two peers removing different entries produce
|
|
680
|
+
// two targeted deletes that commute, where a replay of the whole list
|
|
681
|
+
// from each produces two full re-insertions that both survive.
|
|
682
|
+
if (key === 'splice') {
|
|
683
|
+
return (...args: unknown[]) => {
|
|
684
|
+
if (args.length === 0) return [];
|
|
685
|
+
const len = loroList.length;
|
|
686
|
+
const rawStart = Number(args[0]);
|
|
687
|
+
let start = Number.isFinite(rawStart) ? Math.trunc(rawStart) : 0;
|
|
688
|
+
start = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
|
|
689
|
+
const deleteCount =
|
|
690
|
+
args.length === 1
|
|
691
|
+
? len - start
|
|
692
|
+
: Math.min(
|
|
693
|
+
Math.max(Math.trunc(Number(args[1])) || 0, 0),
|
|
694
|
+
len - start,
|
|
695
|
+
);
|
|
696
|
+
// Read before writing, so the return value matches Array#splice.
|
|
697
|
+
const removed: unknown[] = [];
|
|
698
|
+
for (let i = 0; i < deleteCount; i += 1) {
|
|
699
|
+
removed.push(plainListItem(loroList, start + i));
|
|
700
|
+
}
|
|
701
|
+
const items = args.slice(2);
|
|
702
|
+
// A splice that removes and inserts over the same positions is a
|
|
703
|
+
// REPLACEMENT of them, which `l.splice(i, 1, x)` is the common way
|
|
704
|
+
// to write. Setting each such position states that as one op naming
|
|
705
|
+
// it, where deleting the run and re-inserting states it as a pair
|
|
706
|
+
// that commutes with a peer's concurrent write to the same position
|
|
707
|
+
// and leaves both entries stored. Only the part of the splice that
|
|
708
|
+
// genuinely changes the list's length is a delete or an insert.
|
|
709
|
+
const replaced = Math.min(deleteCount, items.length);
|
|
710
|
+
for (let i = 0; i < replaced; i += 1) {
|
|
711
|
+
batcher?.record(items[i]);
|
|
712
|
+
assignListPosition(loroList, start + i, items[i]);
|
|
713
|
+
}
|
|
714
|
+
if (deleteCount > replaced) {
|
|
715
|
+
loroList.delete(start + replaced, deleteCount - replaced);
|
|
716
|
+
}
|
|
717
|
+
for (let i = replaced; i < items.length; i += 1) {
|
|
718
|
+
batcher?.record(items[i]);
|
|
719
|
+
insertListAt(loroList, start + i, items[i]);
|
|
720
|
+
}
|
|
721
|
+
return removed;
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
if (key === 'pop') {
|
|
725
|
+
return () => {
|
|
726
|
+
const len = loroList.length;
|
|
727
|
+
if (len === 0) return undefined;
|
|
728
|
+
const last = plainListItem(loroList, len - 1);
|
|
729
|
+
loroList.delete(len - 1, 1);
|
|
730
|
+
return last;
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
if (key === 'shift') {
|
|
734
|
+
return () => {
|
|
735
|
+
if (loroList.length === 0) return undefined;
|
|
736
|
+
const first = plainListItem(loroList, 0);
|
|
737
|
+
loroList.delete(0, 1);
|
|
738
|
+
return first;
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (key === 'unshift') {
|
|
742
|
+
return (...items: unknown[]) => {
|
|
743
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
744
|
+
batcher?.record(items[i]);
|
|
745
|
+
insertListAt(loroList, i, items[i]);
|
|
746
|
+
}
|
|
472
747
|
return loroList.length;
|
|
473
748
|
};
|
|
474
749
|
}
|
|
475
750
|
// Remaining array methods: bind to a deep-plain `toJSON` snapshot so a
|
|
476
751
|
// callback reading an element's fields (`findIndex((c) => c.id === x)`,
|
|
477
752
|
// `find`, `filter`, `map`) sees plain objects, not the raw child
|
|
478
|
-
// containers `toArray` hands back (whose fields read undefined). A
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// Best-effort for v1; efficient mutation uses indexed assignment /
|
|
482
|
-
// length-set / `push`.
|
|
753
|
+
// containers `toArray` hands back (whose fields read undefined). A pure
|
|
754
|
+
// read writes nothing back, so a `find` inside a mutate leaves the list
|
|
755
|
+
// alone.
|
|
483
756
|
const arr = loroList.toJSON() as unknown[];
|
|
484
757
|
const val = (arr as unknown as Record<string | symbol, unknown>)[key];
|
|
485
758
|
if (typeof val === 'function') {
|
|
486
759
|
return (...args: unknown[]) => {
|
|
487
760
|
const fn = val as (...a: unknown[]) => unknown;
|
|
488
761
|
const result = fn.apply(arr, args);
|
|
489
|
-
|
|
762
|
+
// The mutators run against the snapshot to decide the answer, then
|
|
763
|
+
// that answer is applied to the container as the edits it implies:
|
|
764
|
+
// moves for a reorder, a set per position for a replacement. What
|
|
765
|
+
// reaches the document is the positions that actually changed.
|
|
766
|
+
if (REORDERING_ARRAY_METHODS.has(key)) {
|
|
767
|
+
applyOrder(loroList, arr, batcher);
|
|
768
|
+
} else if (REPLACING_ARRAY_METHODS.has(key)) {
|
|
769
|
+
for (let i = 0; i < arr.length && i < loroList.length; i += 1) {
|
|
770
|
+
const next = arr[i];
|
|
771
|
+
if (loroList.get(i) === next) continue;
|
|
772
|
+
batcher?.record(next);
|
|
773
|
+
assignListPosition(loroList, i, next);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
490
776
|
return result;
|
|
491
777
|
};
|
|
492
778
|
}
|
|
@@ -501,19 +787,16 @@ function wrapLoroListProxy(loroList: LoroList): unknown[] {
|
|
|
501
787
|
}
|
|
502
788
|
if (typeof key === 'string' && /^\d+$/.test(key)) {
|
|
503
789
|
const idx = Number(key);
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
} else {
|
|
515
|
-
loroList.insert(idx, value as never);
|
|
516
|
-
}
|
|
790
|
+
// Before the write, so a commit it triggers seals the preceding batch
|
|
791
|
+
// rather than splitting this element out of it.
|
|
792
|
+
batcher?.record(value);
|
|
793
|
+
// Replacing a position is `set`, which is ONE op at that position. A
|
|
794
|
+
// delete plus an insert is what the list has to do without one, and it
|
|
795
|
+
// leaves the entry duplicated when a peer edits the same position
|
|
796
|
+
// concurrently, because a list CRDT commutes the deletes and keeps
|
|
797
|
+
// both insertions.
|
|
798
|
+
if (idx < loroList.length) assignListPosition(loroList, idx, value);
|
|
799
|
+
else insertListAt(loroList, idx, value);
|
|
517
800
|
return true;
|
|
518
801
|
}
|
|
519
802
|
return false;
|
|
@@ -525,60 +808,484 @@ function wrapLoroListProxy(loroList: LoroList): unknown[] {
|
|
|
525
808
|
// ---- Paged record-store presentation -----------------------------------
|
|
526
809
|
//
|
|
527
810
|
// A large record-graph document stores its `instances` map PAGED: keyed
|
|
528
|
-
// `
|
|
529
|
-
//
|
|
811
|
+
// `brass:p:<peer>:<n>` -> a `{ type: '__page__', params: <gzipped bodies>,
|
|
812
|
+
// keys: <gzipped key list> }` container, filled in creation order (see
|
|
813
|
+
// `record-paging.ts`).
|
|
530
814
|
// These helpers let `MapHandleImpl` present that paged map as the SAME flat
|
|
531
815
|
// `key -> { type, params }` record map an unpaged store reads, so an app
|
|
532
|
-
// reading / editing `doc.map('instances')` works unchanged.
|
|
533
|
-
//
|
|
816
|
+
// reading / editing `doc.map('instances')` works unchanged. Only a root
|
|
817
|
+
// the `meta` markers name reaches here.
|
|
534
818
|
|
|
535
|
-
// A flattened
|
|
536
|
-
//
|
|
537
|
-
|
|
819
|
+
// A flattened entry: whatever the map stores under one key. A codec's record
|
|
820
|
+
// store holds `{ type, params }`; an app's map holds whatever the app assigns,
|
|
821
|
+
// which is any JSON value and which the paged path carries through unchanged.
|
|
822
|
+
// Deliberately not narrowed to an object: a write path that assumes fields is
|
|
823
|
+
// what turns `5` into an empty container and `'hello'` into one entry per
|
|
824
|
+
// character.
|
|
825
|
+
type FlatRecord = unknown;
|
|
538
826
|
|
|
539
827
|
const TAG_FIELD = 'type';
|
|
540
828
|
const BODY_FIELD = 'params';
|
|
541
829
|
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
|
|
830
|
+
// The record keys one page packs, read off the live container without
|
|
831
|
+
// materialising its bodies. Two fields out of wasm rather than the whole
|
|
832
|
+
// container: a store's bodies are its bulk, and an index build asks every page
|
|
833
|
+
// this question. `key` has already been classified as a page, so an entry that
|
|
834
|
+
// decodes to nothing is a corrupt page rather than something else.
|
|
835
|
+
function pageKeyList(m: LoroMap, key: string): string[] {
|
|
546
836
|
const v = m.get(key);
|
|
547
|
-
if (v instanceof LoroMap) return
|
|
548
|
-
|
|
837
|
+
if (!(v instanceof LoroMap)) return [];
|
|
838
|
+
try {
|
|
839
|
+
return decodePageKeys(v.get(PAGE_KEYS_FIELD));
|
|
840
|
+
} finally {
|
|
841
|
+
v.free();
|
|
842
|
+
}
|
|
549
843
|
}
|
|
550
844
|
|
|
551
845
|
// Decode a live paged `instances` map to the flat `key -> { type, params }`
|
|
552
846
|
// lookup. `toJSON` materialises the page containers to the plain shape the
|
|
553
847
|
// shared `flattenPagedLookup` consumes, so page flattening lives in one place.
|
|
554
848
|
// Only ever called on the handle's paged path (guarded by `_isPaged()` /
|
|
555
|
-
//
|
|
849
|
+
// the handle's page count), so the store is known paged. Pass `paged: true` rather than
|
|
556
850
|
// re-deriving it from a tag scan.
|
|
557
|
-
function flattenPagedMap(
|
|
851
|
+
function flattenPagedMap(
|
|
852
|
+
m: LoroMap,
|
|
853
|
+
into?: RecordLocationSink,
|
|
854
|
+
): Record<string, FlatRecord> {
|
|
558
855
|
return flattenPagedLookup(
|
|
559
856
|
m.toJSON() as Record<string, unknown>,
|
|
560
857
|
true,
|
|
858
|
+
into,
|
|
561
859
|
) as Record<string, FlatRecord>;
|
|
562
860
|
}
|
|
563
861
|
|
|
564
|
-
// Resolve a single record without flattening the whole store:
|
|
565
|
-
//
|
|
566
|
-
// proportional to one page, not the whole file.
|
|
862
|
+
// Resolve a single record without flattening the whole store: take the key's
|
|
863
|
+
// page from the index, decode only that page, find the triple. Keeps a per-key
|
|
864
|
+
// `get` proportional to one page, not the whole file.
|
|
567
865
|
function recordFromPage(
|
|
568
866
|
m: LoroMap,
|
|
569
867
|
recordKey: string,
|
|
570
|
-
|
|
868
|
+
memo?: PageMemo,
|
|
869
|
+
index?: PagedKeyIndex | null,
|
|
870
|
+
patchIndex?: PatchIndex | null,
|
|
571
871
|
): FlatRecord | undefined {
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
872
|
+
const base = recordBeforePatches(m, recordKey, memo, index);
|
|
873
|
+
if (base === undefined) return undefined;
|
|
874
|
+
// The patches fold on LAST, over whichever copy the passes above resolved,
|
|
875
|
+
// matching `flattenPagedLookup` so a keyed read and a bulk read cannot
|
|
876
|
+
// disagree about an edited record.
|
|
877
|
+
const keys = patchIndex?.keysFor(recordKey);
|
|
878
|
+
if (keys === undefined || keys.length === 0) return base;
|
|
879
|
+
const patches: RecordPatch[] = [];
|
|
880
|
+
for (const key of keys) {
|
|
881
|
+
const parsed = parsePatchKey(key);
|
|
882
|
+
if (parsed === null) continue;
|
|
883
|
+
patches.push({
|
|
884
|
+
peer: parsed.peer,
|
|
885
|
+
counter: parsed.counter,
|
|
886
|
+
fields: plainEntry(m, key),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return applyRecordPatches(base, patches);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Record key -> the patch keys editing it.
|
|
894
|
+
*
|
|
895
|
+
* Kept apart from `PagedKeyIndex` because the two cost different things. That
|
|
896
|
+
* index answers WHICH PAGE holds a record, which costs a read of every page's
|
|
897
|
+
* key list. A patch is a plain map key, so learning every patch costs a walk
|
|
898
|
+
* of the key list and no decode, which every keyed read can afford. Without it
|
|
899
|
+
* a `get` returns the page's pre-edit copy while `read` returns the edited
|
|
900
|
+
* one.
|
|
901
|
+
*/
|
|
902
|
+
class PatchIndex {
|
|
903
|
+
private readonly _byRecord = new Map<string, string[]>();
|
|
904
|
+
private _version: StateVersion | null = null;
|
|
905
|
+
|
|
906
|
+
/** Rebuild against `m` unless the document is at the version this index was
|
|
907
|
+
* built at. A mutation's own writes do not go through here: they call
|
|
908
|
+
* `note` / `clear`, which keep the index current while the open transaction
|
|
909
|
+
* holds the version still. */
|
|
910
|
+
refresh(m: LoroMap, version: StateVersion): void {
|
|
911
|
+
if (this._version !== null && sameStateVersion(this._version, version)) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
this._version = version;
|
|
915
|
+
this._byRecord.clear();
|
|
916
|
+
for (const key of m.keys()) {
|
|
917
|
+
const parsed = parsePatchKey(key);
|
|
918
|
+
if (parsed === null) continue;
|
|
919
|
+
const list = this._byRecord.get(parsed.recordKey);
|
|
920
|
+
if (list) list.push(key);
|
|
921
|
+
else this._byRecord.set(parsed.recordKey, [key]);
|
|
577
922
|
}
|
|
578
923
|
}
|
|
924
|
+
|
|
925
|
+
/** The patch keys editing `recordKey`, in no particular order (the fold
|
|
926
|
+
* orders them by counter and peer). */
|
|
927
|
+
keysFor(recordKey: string): string[] | undefined {
|
|
928
|
+
return this._byRecord.get(recordKey);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** The counter a new patch on `recordKey` must carry: one above every
|
|
932
|
+
* counter the record already holds.
|
|
933
|
+
*
|
|
934
|
+
* Per RECORD, because the fold's order has to be causal and this index is
|
|
935
|
+
* what a writer has merged. A writer that has seen an earlier patch on this
|
|
936
|
+
* record mints above it and so applies over it; two writers that have not
|
|
937
|
+
* seen each other mint the same number and are tied apart by peer id, which
|
|
938
|
+
* is the only case where the answer is arbitrary and the only case where it
|
|
939
|
+
* may be. Counting the writer's own edits instead orders patches by how
|
|
940
|
+
* busy each session was, which loses the later edit. */
|
|
941
|
+
nextCounterFor(recordKey: string): number {
|
|
942
|
+
const keys = this._byRecord.get(recordKey);
|
|
943
|
+
if (keys === undefined) return 1;
|
|
944
|
+
let highest = 0;
|
|
945
|
+
for (const key of keys) {
|
|
946
|
+
const parsed = parsePatchKey(key);
|
|
947
|
+
if (parsed === null) continue;
|
|
948
|
+
const counter = Number(parsed.counter);
|
|
949
|
+
if (Number.isFinite(counter) && counter > highest) highest = counter;
|
|
950
|
+
}
|
|
951
|
+
return highest + 1;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Record a patch this build just wrote, so a read later in the same
|
|
955
|
+
* mutation sees it without a rebuild. */
|
|
956
|
+
note(patchKey: string, recordKey: string): void {
|
|
957
|
+
const list = this._byRecord.get(recordKey);
|
|
958
|
+
if (list) list.push(patchKey);
|
|
959
|
+
else this._byRecord.set(recordKey, [patchKey]);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Forget `recordKey`'s patches, for a caller that has just removed them. */
|
|
963
|
+
clear(recordKey: string): void {
|
|
964
|
+
this._byRecord.delete(recordKey);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* The record the map holds under `recordKey` ITSELF, beside the pages rather
|
|
970
|
+
* than under a reserved key, or undefined when it holds none.
|
|
971
|
+
*
|
|
972
|
+
* A record under its own bare key is every record written before the map
|
|
973
|
+
* paged, plus what a client that did not know it was paged leaves behind. The
|
|
974
|
+
* store's own entries are the ones under `RESERVED_KEY_PREFIX`, so a record
|
|
975
|
+
* key never names one and the value is a record whatever it holds.
|
|
976
|
+
*
|
|
977
|
+
* Both the read path and the write path ask through this, so they cannot
|
|
978
|
+
* disagree about which entries of a paged map are records.
|
|
979
|
+
*/
|
|
980
|
+
function strayRecordAt(m: LoroMap, recordKey: string): unknown {
|
|
981
|
+
if (isReservedKey(recordKey)) return undefined;
|
|
982
|
+
return plainEntry(m, recordKey);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function recordBeforePatches(
|
|
986
|
+
m: LoroMap,
|
|
987
|
+
recordKey: string,
|
|
988
|
+
memo?: PageMemo,
|
|
989
|
+
index?: PagedKeyIndex | null,
|
|
990
|
+
): FlatRecord | undefined {
|
|
991
|
+
// A reserved key names one of the store's own entries, never a record, so a
|
|
992
|
+
// read of one answers as it would for a key nothing holds.
|
|
993
|
+
if (isReservedKey(recordKey)) return undefined;
|
|
994
|
+
// A delete a page still holds is its own entry, and it outranks every copy
|
|
995
|
+
// of the record, so it is resolved first. That is one map read this path
|
|
996
|
+
// would not otherwise make, and it is what keeps a concurrent edit from
|
|
997
|
+
// resurrecting a record its own reader was told is gone.
|
|
998
|
+
if (entryExists(m, tombstoneKeyFor(recordKey))) return undefined;
|
|
999
|
+
// Same precedence as `flattenPagedLookup`, so a keyed read and a bulk read
|
|
1000
|
+
// cannot disagree about which copy of a record wins: an edit written beside
|
|
1001
|
+
// the pages, then a record held under its own key, then the page.
|
|
1002
|
+
//
|
|
1003
|
+
// The overlay slot is checked before the index is consulted, and its
|
|
1004
|
+
// presence decides on its own: a remote edit that landed after the index was
|
|
1005
|
+
// built rewrites that container, which this read reaches whatever the index
|
|
1006
|
+
// says.
|
|
1007
|
+
const own = plainEntry(m, overlayKeyFor(recordKey));
|
|
1008
|
+
if (own !== undefined) return own;
|
|
1009
|
+
const stray = strayRecordAt(m, recordKey);
|
|
1010
|
+
if (stray !== undefined) return stray;
|
|
1011
|
+
// A page is filled in creation order, so the index is the only thing that
|
|
1012
|
+
// says which one holds this record. No index, no page answer.
|
|
1013
|
+
const pageKey = index?.readsFromPage(recordKey);
|
|
1014
|
+
if (pageKey === undefined) return undefined;
|
|
1015
|
+
const page = plainEntry(m, pageKey);
|
|
1016
|
+
if (memo) return memo.resolve(pageKey, page).get(recordKey);
|
|
1017
|
+
for (const packed of decodePageRecords(page)) {
|
|
1018
|
+
if (packed[0] === recordKey) return unpackRecord(packed);
|
|
1019
|
+
}
|
|
579
1020
|
return undefined;
|
|
580
1021
|
}
|
|
581
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Which page each record in a paged map lives in.
|
|
1025
|
+
*
|
|
1026
|
+
* A page is filled in creation order (see `SESSION_PAGE_PREFIX`), so the only
|
|
1027
|
+
* way to find a record in one is to have read the pages' key lists. This reads
|
|
1028
|
+
* them once and keeps the answer.
|
|
1029
|
+
*
|
|
1030
|
+
* Validity is stamped with the document's version, the same way `PatchIndex`
|
|
1031
|
+
* stamps itself: one commit that both removes an entry and adds one leaves the
|
|
1032
|
+
* map's entry COUNT where it found it while moving which keys are there, and a
|
|
1033
|
+
* count-stamped index then answers from a page set the map no longer has, with
|
|
1034
|
+
* nothing to correct it. That divergence has no read that resolves it, since a
|
|
1035
|
+
* whole-store `read` walks the map and a keyed `get` walks the index, so the
|
|
1036
|
+
* two disagree for as long as the handle lives; and a write consulting the
|
|
1037
|
+
* stale index takes a key it already holds for an addition and seals a second
|
|
1038
|
+
* copy of it into a new page.
|
|
1039
|
+
*/
|
|
1040
|
+
class PagedKeyIndex {
|
|
1041
|
+
// Record key -> the page whose body PACKS it. One map for the whole store,
|
|
1042
|
+
// so a build costs one insert per record: a second map alongside it costs
|
|
1043
|
+
// far more than the extra inserts (measured at 250,000 records, 415 ms
|
|
1044
|
+
// against 62 ms, the two together outgrowing what one keeps resident).
|
|
1045
|
+
private readonly _inPage = new Map<string, string>();
|
|
1046
|
+
// The records a container of its own answers for, which outrank the page's
|
|
1047
|
+
// copy on a read, and the ones a tombstone removes. Both are bounded by the
|
|
1048
|
+
// records actually edited rather than by the store's size, so they stay
|
|
1049
|
+
// small beside the page map.
|
|
1050
|
+
//
|
|
1051
|
+
// Kept apart from the page map because the two answer different questions
|
|
1052
|
+
// and diverge: a record read through its overlay container is still packed
|
|
1053
|
+
// into the page it was written into, and a page cannot be rewritten to drop
|
|
1054
|
+
// it. That is exactly what decides whether deleting the record needs a
|
|
1055
|
+
// tombstone or can just remove its container, so conflating the two loses
|
|
1056
|
+
// the record's deletion.
|
|
1057
|
+
private readonly _contained = new Set<string>();
|
|
1058
|
+
private readonly _removed = new Set<string>();
|
|
1059
|
+
// The document version this index's answers were last known to match, or
|
|
1060
|
+
// null while it has never been built.
|
|
1061
|
+
private _version: StateVersion | null = null;
|
|
1062
|
+
// Rebuilds this index has done. A rebuild decodes every page, so an edit
|
|
1063
|
+
// that forces one costs the whole store rather than one record; a test
|
|
1064
|
+
// reads this to pin that an ordinary edit does not.
|
|
1065
|
+
rebuilds = 0;
|
|
1066
|
+
|
|
1067
|
+
constructor(private readonly _stateVersion: () => StateVersion) {}
|
|
1068
|
+
|
|
1069
|
+
/** Whether the document has moved since the last build, and so whether this
|
|
1070
|
+
* index has to be built again before it answers. */
|
|
1071
|
+
stale(): boolean {
|
|
1072
|
+
return (
|
|
1073
|
+
this._version === null ||
|
|
1074
|
+
!sameStateVersion(this._version, this._stateVersion())
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Take this index's answers from a walk that is already resolving them (a
|
|
1079
|
+
* whole-store read decodes every page, which is the same question), rather
|
|
1080
|
+
* than decoding the store a second time on the first keyed read. The sink
|
|
1081
|
+
* is called from `flattenPagedLookup`'s own three passes, so the entries
|
|
1082
|
+
* arrive in the order `refresh` walks them.
|
|
1083
|
+
*
|
|
1084
|
+
* The stamp is dropped up front and set again by `stamp`, so a walk that
|
|
1085
|
+
* throws leaves an index that rebuilds rather than an empty one that reads
|
|
1086
|
+
* as current. */
|
|
1087
|
+
harvest(): RecordLocationSink {
|
|
1088
|
+
this.rebuilds += 1;
|
|
1089
|
+
this._version = null;
|
|
1090
|
+
this._inPage.clear();
|
|
1091
|
+
this._contained.clear();
|
|
1092
|
+
this._removed.clear();
|
|
1093
|
+
return {
|
|
1094
|
+
page: (recordKey, pageKey) => {
|
|
1095
|
+
this._inPage.set(recordKey, pageKey);
|
|
1096
|
+
},
|
|
1097
|
+
contained: (recordKey) => {
|
|
1098
|
+
this._contained.add(recordKey);
|
|
1099
|
+
},
|
|
1100
|
+
removed: (recordKey) => {
|
|
1101
|
+
this._removed.add(recordKey);
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Stamp a harvested index as current. */
|
|
1107
|
+
stamp(): void {
|
|
1108
|
+
this._version = this._stateVersion();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Rebuild against `m` if the document has moved since the last build. */
|
|
1112
|
+
refresh(m: LoroMap): void {
|
|
1113
|
+
const version = this._stateVersion();
|
|
1114
|
+
if (this._version !== null && sameStateVersion(this._version, version)) {
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
this.rebuilds += 1;
|
|
1118
|
+
this._inPage.clear();
|
|
1119
|
+
this._contained.clear();
|
|
1120
|
+
this._removed.clear();
|
|
1121
|
+
// Same passes, in the same order, as `flattenPagedLookup`: pages, then
|
|
1122
|
+
// records under their own key, then the edits beside them, then the
|
|
1123
|
+
// deletes. A key's location has to agree with which copy of it a read
|
|
1124
|
+
// returns, and resolving in one pass would make that depend on the order
|
|
1125
|
+
// the map hands its keys back, so a record a tombstone removes could be
|
|
1126
|
+
// indexed to the page still holding it.
|
|
1127
|
+
const strays: string[] = [];
|
|
1128
|
+
const overlays: string[] = [];
|
|
1129
|
+
const removed: string[] = [];
|
|
1130
|
+
for (const key of m.keys()) {
|
|
1131
|
+
const entry = classifyStoreKey(key);
|
|
1132
|
+
switch (entry.kind) {
|
|
1133
|
+
case 'record':
|
|
1134
|
+
strays.push(key);
|
|
1135
|
+
break;
|
|
1136
|
+
case 'overlay':
|
|
1137
|
+
overlays.push(entry.recordKey);
|
|
1138
|
+
break;
|
|
1139
|
+
case 'tombstone':
|
|
1140
|
+
removed.push(entry.recordKey);
|
|
1141
|
+
break;
|
|
1142
|
+
case 'page':
|
|
1143
|
+
// A page is asked for its KEY LIST alone. Materialising the whole
|
|
1144
|
+
// container instead would copy every record body in the store out of
|
|
1145
|
+
// wasm and then decompress and parse it, to learn what the keys
|
|
1146
|
+
// already say: measured at 250,000 records, 1.48 s against 0.45 s.
|
|
1147
|
+
for (const recordKey of pageKeyList(m, key)) {
|
|
1148
|
+
this._inPage.set(recordKey, key);
|
|
1149
|
+
}
|
|
1150
|
+
break;
|
|
1151
|
+
// A patch names no record of its own, so it must not be taken for a
|
|
1152
|
+
// stray. Which records they edit is `PatchIndex`'s business.
|
|
1153
|
+
case 'patch':
|
|
1154
|
+
case 'unknown':
|
|
1155
|
+
break;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
for (const key of strays) this._contained.add(key);
|
|
1159
|
+
for (const recordKey of overlays) this._contained.add(recordKey);
|
|
1160
|
+
for (const recordKey of removed) {
|
|
1161
|
+
this._removed.add(recordKey);
|
|
1162
|
+
this._contained.delete(recordKey);
|
|
1163
|
+
}
|
|
1164
|
+
this._version = version;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Every record key the map stores, from the page key lists and the entries
|
|
1168
|
+
* that outrank them. Answered without decoding a page BODY, which is what
|
|
1169
|
+
* separates it from a whole-store read: the key lists alone are measured at
|
|
1170
|
+
* 0.45 s against 1.48 s for the bodies at 250,000 records. */
|
|
1171
|
+
liveKeys(): Set<string> {
|
|
1172
|
+
const out = new Set<string>();
|
|
1173
|
+
for (const key of this._inPage.keys()) {
|
|
1174
|
+
if (!this._removed.has(key)) out.add(key);
|
|
1175
|
+
}
|
|
1176
|
+
for (const key of this._contained) out.add(key);
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/** Whether the map stores a record under `recordKey`. */
|
|
1181
|
+
has(recordKey: string): boolean {
|
|
1182
|
+
if (this._removed.has(recordKey)) return false;
|
|
1183
|
+
return this._contained.has(recordKey) || this._inPage.has(recordKey);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** The page a keyed read should decode for `recordKey`, or undefined when a
|
|
1187
|
+
* container of its own answers for it (or it is not stored at all). */
|
|
1188
|
+
readsFromPage(recordKey: string): string | undefined {
|
|
1189
|
+
if (this._contained.has(recordKey) || this._removed.has(recordKey)) {
|
|
1190
|
+
return undefined;
|
|
1191
|
+
}
|
|
1192
|
+
return this._inPage.get(recordKey);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Whether some page's body still holds `recordKey`, and so whether deleting
|
|
1196
|
+
* it has to be stated as a tombstone. */
|
|
1197
|
+
packedInPage(recordKey: string): boolean {
|
|
1198
|
+
return this._inPage.has(recordKey);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Record a page write this handle just made: every record `pageKey` now
|
|
1202
|
+
* packs. Takes the whole page rather than a record at a time so the version
|
|
1203
|
+
* is read once for the write instead of once per record, which a bulk write
|
|
1204
|
+
* reaches hundreds of thousands of times. */
|
|
1205
|
+
notePage(pageKey: string, recordKeys: Iterable<string>): void {
|
|
1206
|
+
for (const recordKey of recordKeys) {
|
|
1207
|
+
this._inPage.set(recordKey, pageKey);
|
|
1208
|
+
this._contained.delete(recordKey);
|
|
1209
|
+
this._removed.delete(recordKey);
|
|
1210
|
+
}
|
|
1211
|
+
this._restamp();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** Record that a container of its own now answers for `recordKey`. */
|
|
1215
|
+
noteContained(recordKey: string): void {
|
|
1216
|
+
this._contained.add(recordKey);
|
|
1217
|
+
this._removed.delete(recordKey);
|
|
1218
|
+
this._restamp();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Note a write that moved no record between a page and a container, so the
|
|
1222
|
+
* stamp stays current and the next read does not rebuild.
|
|
1223
|
+
*
|
|
1224
|
+
* A patch is this: it names no record of its own, so writing or removing
|
|
1225
|
+
* one leaves every answer this index gives unchanged while moving the
|
|
1226
|
+
* document the stamp is taken against. Without it a session-paged store
|
|
1227
|
+
* rebuilds on the access after each edit, and a rebuild decodes every page:
|
|
1228
|
+
* measured at 20,000 records, 79 ms per edit against 2.8 ms. */
|
|
1229
|
+
noteSideEntries(): void {
|
|
1230
|
+
this._restamp();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Record that `recordKey` is no longer stored. What a page still holds is
|
|
1234
|
+
* left alone: the page keeps its copy, and a later re-add has to know that
|
|
1235
|
+
* removing the tombstone alone would resurrect it. */
|
|
1236
|
+
noteRemoved(recordKey: string): void {
|
|
1237
|
+
this._removed.add(recordKey);
|
|
1238
|
+
this._contained.delete(recordKey);
|
|
1239
|
+
this._restamp();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Carry the stamp forward over a write this index has just taken account of.
|
|
1243
|
+
// Every caller notes AFTER its writes, so the version read here is the one
|
|
1244
|
+
// the noted content matches. Committing does not move the version, so a
|
|
1245
|
+
// mutation that commits as it goes stays stamped throughout.
|
|
1246
|
+
//
|
|
1247
|
+
// An index that has never been built stays unbuilt: stamping one fresh would
|
|
1248
|
+
// leave it empty and authoritative.
|
|
1249
|
+
private _restamp(): void {
|
|
1250
|
+
if (this._version === null) return;
|
|
1251
|
+
this._version = this._stateVersion();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* A one-entry memo of the last decoded page, so repeated keyed reads inside
|
|
1257
|
+
* one page decode it once. Keyed on the page's stored BODY, which is one
|
|
1258
|
+
* immutable string, so any write that changes the page misses and a stale
|
|
1259
|
+
* read is not expressible.
|
|
1260
|
+
*/
|
|
1261
|
+
class PageMemo {
|
|
1262
|
+
private _pageKey: string | null = null;
|
|
1263
|
+
private _body: unknown = undefined;
|
|
1264
|
+
private _records: Map<string, FlatRecord> = new Map();
|
|
1265
|
+
|
|
1266
|
+
resolve(pageKey: string, page: unknown): Map<string, FlatRecord> {
|
|
1267
|
+
const body = decodeRecordValue(page)?.body;
|
|
1268
|
+
if (this._pageKey === pageKey && this._body === body) return this._records;
|
|
1269
|
+
const records = new Map<string, FlatRecord>();
|
|
1270
|
+
for (const packed of decodePageRecords(page)) {
|
|
1271
|
+
records.set(packed[0], unpackRecord(packed));
|
|
1272
|
+
}
|
|
1273
|
+
this._pageKey = pageKey;
|
|
1274
|
+
this._body = body;
|
|
1275
|
+
this._records = records;
|
|
1276
|
+
return records;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** Whether a page still holds this record, which decides whether a delete
|
|
1281
|
+
* needs a marker or can just remove the record's own container. */
|
|
1282
|
+
function pageHoldsRecord(
|
|
1283
|
+
recordKey: string,
|
|
1284
|
+
index: PagedKeyIndex | null,
|
|
1285
|
+
): boolean {
|
|
1286
|
+
return index !== null && index.packedInPage(recordKey);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
582
1289
|
// ---- Change deltas (`{ delta: true }` map subscribers) ----------------
|
|
583
1290
|
//
|
|
584
1291
|
// A delta subscriber reports which keys changed since the previous fire so a
|
|
@@ -593,58 +1300,99 @@ function recordFromPage(
|
|
|
593
1300
|
// page -> keys index, diffing a changed page's current keys against it; a
|
|
594
1301
|
// deleted record was in the prior key set and absent from the rewritten page.
|
|
595
1302
|
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1303
|
+
// How many path segments name `m` itself. An event's `path` is absolute from
|
|
1304
|
+
// the document root, so a descendant event's entry key is the segment at this
|
|
1305
|
+
// index: 1 for a root container (`path[0]` is the root key), one more for each
|
|
1306
|
+
// container it is nested inside.
|
|
1307
|
+
//
|
|
1308
|
+
// Measured from the container rather than from the handle's anchor chain
|
|
1309
|
+
// because the two can disagree: a scoped handle over a paged store resolves
|
|
1310
|
+
// its record through the copy that answers for it, which sits one level below
|
|
1311
|
+
// the root either way but under a key the anchor does not name.
|
|
1312
|
+
function containerDepth(m: LoroMap | LoroMovableList): number {
|
|
1313
|
+
let depth = 0;
|
|
1314
|
+
let current: { parent(): unknown } | undefined = m;
|
|
1315
|
+
while (current) {
|
|
1316
|
+
depth += 1;
|
|
1317
|
+
current = current.parent() as { parent(): unknown } | undefined;
|
|
1318
|
+
}
|
|
1319
|
+
return depth;
|
|
609
1320
|
}
|
|
610
1321
|
|
|
611
1322
|
// Decode one page to its `key -> { type, params }` records in a single pass,
|
|
612
1323
|
// so a paged delta gets the changed records' VALUES from the same decode it
|
|
613
1324
|
// uses to find the changed keys (a second per-key decode would make a large
|
|
614
|
-
// initial build O(records x page-size)).
|
|
1325
|
+
// initial build O(records x page-size)). Null for a page key nothing holds,
|
|
1326
|
+
// which is how a delta tells a rewritten page from a removed one.
|
|
615
1327
|
function pageRecords(m: LoroMap, pageKey: string): Map<string, FlatRecord> | null {
|
|
616
|
-
const page =
|
|
617
|
-
if (
|
|
1328
|
+
const page = plainEntry(m, pageKey);
|
|
1329
|
+
if (page === undefined) return null;
|
|
618
1330
|
const records = new Map<string, FlatRecord>();
|
|
619
|
-
for (const packed of
|
|
620
|
-
|
|
621
|
-
records.set(packed[0], { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] });
|
|
622
|
-
}
|
|
1331
|
+
for (const packed of decodePageRecords(page)) {
|
|
1332
|
+
records.set(packed[0], unpackRecord(packed));
|
|
623
1333
|
}
|
|
624
1334
|
return records;
|
|
625
1335
|
}
|
|
626
1336
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1337
|
+
/**
|
|
1338
|
+
* What one subscription has been told about a paged store.
|
|
1339
|
+
*
|
|
1340
|
+
* `pages` is the record keys each page held at the last fire, which is what
|
|
1341
|
+
* recovers record granularity from a page-level change. `shadowed` is the
|
|
1342
|
+
* records the store answers for from OUTSIDE its pages: an edit written beside
|
|
1343
|
+
* them, a record under its own key, a delete marker, a patch. A page keeps its
|
|
1344
|
+
* copy of such a record, because nothing rewrites a page to drop one, so a
|
|
1345
|
+
* rewrite carries that copy forward and it is the value the store replaced.
|
|
1346
|
+
*
|
|
1347
|
+
* `shadowed` only grows, which is the safe direction: an entry that goes
|
|
1348
|
+
* leaves the record answered by its page again, and a resolve of it reads that
|
|
1349
|
+
* page.
|
|
1350
|
+
*/
|
|
1351
|
+
interface PagedDeltaIndex {
|
|
1352
|
+
readonly pages: Map<string, Set<string>>;
|
|
1353
|
+
readonly shadowed: Set<string>;
|
|
634
1354
|
}
|
|
635
1355
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1356
|
+
/** Note the record a store entry answers for, if it answers for one. */
|
|
1357
|
+
function noteShadowedRecord(shadowed: Set<string>, key: string): void {
|
|
1358
|
+
const entry = classifyStoreKey(key);
|
|
1359
|
+
switch (entry.kind) {
|
|
1360
|
+
case 'patch':
|
|
1361
|
+
shadowed.add(entry.parsed.recordKey);
|
|
1362
|
+
break;
|
|
1363
|
+
case 'overlay':
|
|
1364
|
+
case 'record':
|
|
1365
|
+
case 'tombstone':
|
|
1366
|
+
shadowed.add(entry.recordKey);
|
|
1367
|
+
break;
|
|
1368
|
+
case 'page':
|
|
1369
|
+
case 'unknown':
|
|
1370
|
+
break;
|
|
641
1371
|
}
|
|
642
|
-
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function shadowedRecords(m: LoroMap): Set<string> {
|
|
1375
|
+
const shadowed = new Set<string>();
|
|
1376
|
+
for (const key of m.keys()) noteShadowedRecord(shadowed, key);
|
|
1377
|
+
return shadowed;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function buildPagedDeltaIndex(m: LoroMap): PagedDeltaIndex {
|
|
1381
|
+
const pages = new Map<string, Set<string>>();
|
|
1382
|
+
const shadowed = new Set<string>();
|
|
1383
|
+
for (const key of m.keys()) {
|
|
1384
|
+
if (classifyStoreKey(key).kind === 'page') {
|
|
1385
|
+
pages.set(key, new Set(pageKeyList(m, key)));
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
noteShadowedRecord(shadowed, key);
|
|
1389
|
+
}
|
|
1390
|
+
return { pages, shadowed };
|
|
643
1391
|
}
|
|
644
1392
|
|
|
645
1393
|
function unpagedMapDelta(
|
|
646
1394
|
m: LoroMap,
|
|
647
|
-
|
|
1395
|
+
depth: number,
|
|
648
1396
|
batch: LoroEventBatch,
|
|
649
1397
|
): Change<unknown> {
|
|
650
1398
|
const setKeys = new Set<string>();
|
|
@@ -656,44 +1404,180 @@ function unpagedMapDelta(
|
|
|
656
1404
|
if (value === undefined) deleted.add(key);
|
|
657
1405
|
else setKeys.add(key);
|
|
658
1406
|
}
|
|
659
|
-
} else
|
|
660
|
-
// A descendant event (a field edit on
|
|
661
|
-
// Every batch event is within
|
|
662
|
-
//
|
|
663
|
-
const key = ev.path[
|
|
1407
|
+
} else {
|
|
1408
|
+
// A descendant event (a field edit on an entry's nested container).
|
|
1409
|
+
// Every batch event is within this map's subtree, so the entry's key is
|
|
1410
|
+
// the path segment directly under it, at this map's own depth.
|
|
1411
|
+
const key = ev.path[depth];
|
|
664
1412
|
if (typeof key === 'string') setKeys.add(key);
|
|
665
1413
|
}
|
|
666
1414
|
}
|
|
667
1415
|
for (const key of deleted) setKeys.delete(key);
|
|
668
1416
|
const set = new Map<string, unknown>();
|
|
669
|
-
for (const key of setKeys) set.set(key,
|
|
1417
|
+
for (const key of setKeys) set.set(key, plainEntry(m, key));
|
|
670
1418
|
return { set, deleted: [...deleted] };
|
|
671
1419
|
}
|
|
672
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* Read a changed page out to the records it holds, and name the ones it lost.
|
|
1423
|
+
*
|
|
1424
|
+
* A page is one value, so a change to it says nothing about WHICH of its
|
|
1425
|
+
* records moved; the index of what the page held before is what recovers that.
|
|
1426
|
+
* Both routes a page change arrives by resolve through here so they cannot
|
|
1427
|
+
* disagree about what a rewrite meant.
|
|
1428
|
+
*
|
|
1429
|
+
* A record something outside the page answers for is RESOLVED rather than read
|
|
1430
|
+
* out of the page, for the same reason the entry cases resolve: the page's copy
|
|
1431
|
+
* of it is one of several the store holds, and it is the one every read
|
|
1432
|
+
* discards. Reading it out hands a subscriber the value an edit replaced, or
|
|
1433
|
+
* puts a deleted record back into a view that then holds it for good.
|
|
1434
|
+
*/
|
|
1435
|
+
function expandPage(
|
|
1436
|
+
m: LoroMap,
|
|
1437
|
+
pageKey: string,
|
|
1438
|
+
index: PagedDeltaIndex,
|
|
1439
|
+
set: Map<string, unknown>,
|
|
1440
|
+
deleted: Set<string>,
|
|
1441
|
+
expanded: Set<string>,
|
|
1442
|
+
resolve: (recordKey: string) => unknown,
|
|
1443
|
+
): void {
|
|
1444
|
+
if (expanded.has(pageKey)) return;
|
|
1445
|
+
expanded.add(pageKey);
|
|
1446
|
+
const before = index.pages.get(pageKey);
|
|
1447
|
+
const after = pageRecords(m, pageKey);
|
|
1448
|
+
if (after) {
|
|
1449
|
+
for (const [key, value] of after) {
|
|
1450
|
+
if (!index.shadowed.has(key)) {
|
|
1451
|
+
set.set(key, value);
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const live = resolve(key);
|
|
1455
|
+
if (live === undefined) deleted.add(key);
|
|
1456
|
+
else set.set(key, live);
|
|
1457
|
+
}
|
|
1458
|
+
index.pages.set(pageKey, new Set(after.keys()));
|
|
1459
|
+
} else {
|
|
1460
|
+
index.pages.delete(pageKey);
|
|
1461
|
+
}
|
|
1462
|
+
if (before) {
|
|
1463
|
+
for (const key of before) {
|
|
1464
|
+
if (after?.has(key)) continue;
|
|
1465
|
+
// A record the page dropped that the store still answers for is not
|
|
1466
|
+
// gone, and the copy that answers for it is one this rewrite did not
|
|
1467
|
+
// touch, so the change says nothing about its value either.
|
|
1468
|
+
if (index.shadowed.has(key) && resolve(key) !== undefined) continue;
|
|
1469
|
+
deleted.add(key);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
673
1474
|
function pagedMapDelta(
|
|
674
1475
|
m: LoroMap,
|
|
675
1476
|
batch: LoroEventBatch,
|
|
676
|
-
|
|
1477
|
+
index: PagedDeltaIndex,
|
|
1478
|
+
resolve: (recordKey: string) => unknown,
|
|
677
1479
|
): Change<unknown> {
|
|
678
1480
|
const set = new Map<string, unknown>();
|
|
679
1481
|
const deleted = new Set<string>();
|
|
1482
|
+
// Every entry that answers for a record is noted before any page is read
|
|
1483
|
+
// out. An entry and a page carrying the same record can arrive in either
|
|
1484
|
+
// order within one batch, and both write the record into `set`, so a page
|
|
1485
|
+
// read out first would report the copy the entry supersedes.
|
|
680
1486
|
for (const ev of batch.events) {
|
|
681
|
-
if (ev.target
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const after = pageRecords(m, pageKey);
|
|
685
|
-
if (after) {
|
|
686
|
-
for (const [key, value] of after) set.set(key, value);
|
|
687
|
-
pageIndex.set(pageKey, new Set(after.keys()));
|
|
688
|
-
} else {
|
|
689
|
-
pageIndex.delete(pageKey);
|
|
1487
|
+
if (ev.target === m.id && ev.diff.type === 'map') {
|
|
1488
|
+
for (const changedKey of Object.keys(ev.diff.updated)) {
|
|
1489
|
+
noteShadowedRecord(index.shadowed, changedKey);
|
|
690
1490
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
const segment = ev.path[1];
|
|
1494
|
+
if (typeof segment === 'string') noteShadowedRecord(index.shadowed, segment);
|
|
1495
|
+
}
|
|
1496
|
+
// Pages this batch has already been read out of. One rewrite can reach here
|
|
1497
|
+
// from both directions (the map-level diff and the page's own container
|
|
1498
|
+
// event), and a page body is gzipped, so decoding it twice per batch is real
|
|
1499
|
+
// work on a store whose pages a pass rewrites all at once.
|
|
1500
|
+
const expanded = new Set<string>();
|
|
1501
|
+
// A patch key names no record, so the record it edits is resolved once at
|
|
1502
|
+
// the end however many patches this batch carried for it. Reporting the
|
|
1503
|
+
// patch key itself would name a record no reader has, and dropping it (it
|
|
1504
|
+
// is neither an overlay key nor a page) would leave a subscriber's view
|
|
1505
|
+
// showing the pre-edit value.
|
|
1506
|
+
const patched = new Set<string>();
|
|
1507
|
+
for (const ev of batch.events) {
|
|
1508
|
+
if (ev.target === m.id && ev.diff.type === 'map') {
|
|
1509
|
+
for (const changedKey of Object.keys(ev.diff.updated)) {
|
|
1510
|
+
const entry = classifyStoreKey(changedKey);
|
|
1511
|
+
switch (entry.kind) {
|
|
1512
|
+
case 'patch':
|
|
1513
|
+
patched.add(entry.parsed.recordKey);
|
|
1514
|
+
break;
|
|
1515
|
+
// An entry that names one record: the record's own container beside
|
|
1516
|
+
// the pages, or the record under its own bare key. Either is one of
|
|
1517
|
+
// several copies a paged store can hold, so the record is RESOLVED
|
|
1518
|
+
// rather than read off the entry and a subscriber is handed the
|
|
1519
|
+
// value a reader gets. A peer's patch outranks the container it was
|
|
1520
|
+
// written over, and a page copy survives the container going, so
|
|
1521
|
+
// the entry alone answers neither what the record holds nor whether
|
|
1522
|
+
// it is still there.
|
|
1523
|
+
case 'overlay':
|
|
1524
|
+
case 'record': {
|
|
1525
|
+
const value = resolve(entry.recordKey);
|
|
1526
|
+
if (value === undefined) deleted.add(entry.recordKey);
|
|
1527
|
+
else set.set(entry.recordKey, value);
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
// The marker arriving removes the record; the marker going is a
|
|
1531
|
+
// re-add, whose own entry the same batch carries, so the record is
|
|
1532
|
+
// resolved rather than reported from here.
|
|
1533
|
+
case 'tombstone': {
|
|
1534
|
+
if (entryExists(m, changedKey)) {
|
|
1535
|
+
deleted.add(entry.recordKey);
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
const value = resolve(entry.recordKey);
|
|
1539
|
+
if (value !== undefined) set.set(entry.recordKey, value);
|
|
1540
|
+
break;
|
|
1541
|
+
}
|
|
1542
|
+
case 'page':
|
|
1543
|
+
expandPage(m, changedKey, index, set, deleted, expanded, resolve);
|
|
1544
|
+
break;
|
|
1545
|
+
case 'unknown':
|
|
1546
|
+
break;
|
|
694
1547
|
}
|
|
695
1548
|
}
|
|
1549
|
+
continue;
|
|
696
1550
|
}
|
|
1551
|
+
// An event on a container under the root, whose key is the path segment
|
|
1552
|
+
// there. Two shapes reach here.
|
|
1553
|
+
//
|
|
1554
|
+
// A field edit fires on the record's own container, and names that record.
|
|
1555
|
+
//
|
|
1556
|
+
// A PAGE fires here when a covering mutation rewrites it: the page's
|
|
1557
|
+
// container already exists, so the rewrite sets fields on it rather than
|
|
1558
|
+
// assigning a key of the store's own map, and the map-level diff carries
|
|
1559
|
+
// nothing. Reading only that diff drops every record in every page a pass
|
|
1560
|
+
// covered, which is the whole store for a pass over the whole store, and
|
|
1561
|
+
// drops them silently: the writer's own app repaints the few records that
|
|
1562
|
+
// took another route and leaves the rest drawn where they were.
|
|
1563
|
+
const segment = ev.path[1];
|
|
1564
|
+
if (typeof segment === 'string') {
|
|
1565
|
+
const entry = classifyStoreKey(segment);
|
|
1566
|
+
if (entry.kind === 'page') {
|
|
1567
|
+
expandPage(m, segment, index, set, deleted, expanded, resolve);
|
|
1568
|
+
} else if (entry.kind === 'overlay' || entry.kind === 'record') {
|
|
1569
|
+
// Resolved for the same reason the map-level cases are: the container
|
|
1570
|
+
// this event fired on is one copy of the record, not the answer.
|
|
1571
|
+
const value = resolve(entry.recordKey);
|
|
1572
|
+
if (value !== undefined) set.set(entry.recordKey, value);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
for (const recordKey of patched) {
|
|
1577
|
+
if (set.has(recordKey) || deleted.has(recordKey)) continue;
|
|
1578
|
+
const value = resolve(recordKey);
|
|
1579
|
+
if (value === undefined) deleted.add(recordKey);
|
|
1580
|
+
else set.set(recordKey, value);
|
|
697
1581
|
}
|
|
698
1582
|
for (const key of deleted) set.delete(key);
|
|
699
1583
|
return { set, deleted: [...deleted] };
|
|
@@ -703,36 +1587,43 @@ function pagedMapDelta(
|
|
|
703
1587
|
// OVERLAY of in-batch changes (key -> record, or a tombstone for a delete);
|
|
704
1588
|
// reads consult the overlay first then the stored page, so an edit is
|
|
705
1589
|
// visible to later reads in the same `fn` as on an unpaged live proxy.
|
|
706
|
-
// Afterwards
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
1590
|
+
// Afterwards `applyOverlay` writes each EDITED record to its own container
|
|
1591
|
+
// beside the pages, leaving every existing page byte-identical. That is what
|
|
1592
|
+
// keeps two people editing different records in one page from overwriting each
|
|
1593
|
+
// other: a page body is a single gzipped value, so rewriting it would make the
|
|
1594
|
+
// page the merge unit and hand the whole page to the last writer. A `pruneRefs`
|
|
1595
|
+
// sweep that iterates the whole draft is the only path that reads every page,
|
|
1596
|
+
// the rare structural-delete case already treated as O(file).
|
|
710
1597
|
function mutatePaged<T>(
|
|
711
1598
|
m: LoroMap,
|
|
712
|
-
pageCount: number,
|
|
713
1599
|
fn: (draft: Record<string, T>) => void,
|
|
1600
|
+
pager: SessionPager | null,
|
|
1601
|
+
batcher?: MutateBatcher,
|
|
714
1602
|
): void {
|
|
715
|
-
// A paged store always carries a positive `meta.page_count`, so a
|
|
716
|
-
// non-positive count means the store is corrupt: the page layout can't be
|
|
717
|
-
// resolved and guessing would scramble which record lives where. Refuse
|
|
718
|
-
// rather than corrupt.
|
|
719
|
-
if (pageCount <= 0) {
|
|
720
|
-
throw new Error('Cannot edit a paged record store with no page_count.');
|
|
721
|
-
}
|
|
722
|
-
|
|
723
1603
|
const TOMBSTONE = Symbol('deleted');
|
|
724
1604
|
// In-batch changes: key -> new record, or TOMBSTONE when deleted. Absent
|
|
725
1605
|
// means "unchanged, read from the page".
|
|
726
1606
|
const overlay = new Map<string, FlatRecord | typeof TOMBSTONE>();
|
|
1607
|
+
// One decoded page shared by every read this mutation makes. Reading a
|
|
1608
|
+
// record through the draft (`m[key].params`) resolves it once per field
|
|
1609
|
+
// touched, and the ordinary edit shape touches several, so without this a
|
|
1610
|
+
// single-record edit gunzips and parses its whole page a handful of times.
|
|
1611
|
+
// `applyOverlay` never rewrites a page, so a page decoded here stays valid
|
|
1612
|
+
// for the whole mutation.
|
|
1613
|
+
const memo = new PageMemo();
|
|
1614
|
+
// Taken ONCE, before the first write. The version advances as the mutation
|
|
1615
|
+
// writes, so taking it again per record would rebuild the index per record;
|
|
1616
|
+
// the write path keeps this instance current instead (`note` / `clear`).
|
|
1617
|
+
const patchIndex = pager ? pager.patches(m) : null;
|
|
727
1618
|
|
|
728
1619
|
// The record a draft read should see: the overlay value if touched this
|
|
729
|
-
// batch, else the stored record
|
|
1620
|
+
// batch, else the stored record.
|
|
730
1621
|
const currentRecord = (key: string): FlatRecord | undefined => {
|
|
731
1622
|
if (overlay.has(key)) {
|
|
732
1623
|
const v = overlay.get(key)!;
|
|
733
1624
|
return v === TOMBSTONE ? undefined : v;
|
|
734
1625
|
}
|
|
735
|
-
return recordFromPage(m, key,
|
|
1626
|
+
return recordFromPage(m, key, memo, pager?.liveIndex ?? null, patchIndex);
|
|
736
1627
|
};
|
|
737
1628
|
|
|
738
1629
|
// Per-record sub-proxy: a field assignment writes through to the overlay
|
|
@@ -740,21 +1631,77 @@ function mutatePaged<T>(
|
|
|
740
1631
|
// reads return the deep-plain stored value, so spreading `{ ...rec.params }`
|
|
741
1632
|
// never leaks a live handle.
|
|
742
1633
|
const recordProxy = (key: string): Record<string, unknown> => {
|
|
1634
|
+
// The record a field write starts from. A value that is not an object has
|
|
1635
|
+
// no fields to keep, so the write starts from an empty record rather than
|
|
1636
|
+
// from that value's own entries (`'ab'` would contribute `0` and `1`).
|
|
1637
|
+
const fieldBase = (): Record<string, unknown> => {
|
|
1638
|
+
const rec = currentRecord(key);
|
|
1639
|
+
return isPlainObject(rec) ? { ...rec } : {};
|
|
1640
|
+
};
|
|
743
1641
|
return new Proxy(
|
|
744
1642
|
{},
|
|
745
1643
|
{
|
|
746
1644
|
get(_t, field) {
|
|
747
1645
|
if (typeof field === 'symbol') return undefined;
|
|
748
1646
|
const rec = currentRecord(key);
|
|
749
|
-
|
|
1647
|
+
const v = isPlainObject(rec) ? rec[field] : undefined;
|
|
1648
|
+
if (v !== null && typeof v === 'object') {
|
|
1649
|
+
// Same reason as the unpaged proxy: a body is a plain value, so
|
|
1650
|
+
// a field edit inside it has to be written back rather than
|
|
1651
|
+
// applied to the copy the read handed out.
|
|
1652
|
+
return writeThroughBody(v, (next) => {
|
|
1653
|
+
const base = fieldBase();
|
|
1654
|
+
base[field] = toPlainDeep(next);
|
|
1655
|
+
overlay.set(key, base);
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
return v;
|
|
750
1659
|
},
|
|
751
1660
|
set(_t, field, value) {
|
|
752
1661
|
if (typeof field === 'symbol') return false;
|
|
753
|
-
const base
|
|
1662
|
+
const base = fieldBase();
|
|
754
1663
|
base[field] = toPlainDeep(value);
|
|
755
1664
|
overlay.set(key, base);
|
|
756
1665
|
return true;
|
|
757
1666
|
},
|
|
1667
|
+
// A field removal has to write through here as an assignment does.
|
|
1668
|
+
// The default trap would delete from this proxy's own empty target
|
|
1669
|
+
// and report success, so `delete d[key].field` would apply on a small
|
|
1670
|
+
// store and silently do nothing once the store paged.
|
|
1671
|
+
deleteProperty(_t, field) {
|
|
1672
|
+
if (typeof field === 'symbol') return false;
|
|
1673
|
+
const base = fieldBase();
|
|
1674
|
+
if (!(field in base)) return true;
|
|
1675
|
+
delete base[field];
|
|
1676
|
+
overlay.set(key, base);
|
|
1677
|
+
return true;
|
|
1678
|
+
},
|
|
1679
|
+
// Enumerating a record is what `{ ...rec }`, `Object.keys(rec)` and
|
|
1680
|
+
// `JSON.stringify(rec)` all reach for, so the record answers with its
|
|
1681
|
+
// stored fields here as it does through the unpaged proxy. Without
|
|
1682
|
+
// these the proxy enumerates its own empty target, and an app that
|
|
1683
|
+
// copies a record reads it as `{}` on a paged store while the same
|
|
1684
|
+
// code works on a small one.
|
|
1685
|
+
has(_t, field) {
|
|
1686
|
+
if (typeof field === 'symbol') return false;
|
|
1687
|
+
const rec = currentRecord(key);
|
|
1688
|
+
return isPlainObject(rec) && field in rec;
|
|
1689
|
+
},
|
|
1690
|
+
ownKeys() {
|
|
1691
|
+
const rec = currentRecord(key);
|
|
1692
|
+
return isPlainObject(rec) ? Object.keys(rec) : [];
|
|
1693
|
+
},
|
|
1694
|
+
getOwnPropertyDescriptor(_t, field) {
|
|
1695
|
+
if (typeof field === 'symbol') return undefined;
|
|
1696
|
+
const rec = currentRecord(key);
|
|
1697
|
+
if (!isPlainObject(rec) || !(field in rec)) return undefined;
|
|
1698
|
+
return {
|
|
1699
|
+
enumerable: true,
|
|
1700
|
+
configurable: true,
|
|
1701
|
+
writable: true,
|
|
1702
|
+
value: rec[field],
|
|
1703
|
+
};
|
|
1704
|
+
},
|
|
758
1705
|
},
|
|
759
1706
|
) as Record<string, unknown>;
|
|
760
1707
|
};
|
|
@@ -764,19 +1711,29 @@ function mutatePaged<T>(
|
|
|
764
1711
|
{
|
|
765
1712
|
get(_t, key) {
|
|
766
1713
|
if (typeof key === 'symbol') return undefined;
|
|
767
|
-
|
|
1714
|
+
const rec = currentRecord(key as string);
|
|
1715
|
+
if (rec === undefined) return undefined;
|
|
1716
|
+
// As on an unpaged map: a stored value that is not an object is handed
|
|
1717
|
+
// back as itself, and a plain array as a view that writes an element
|
|
1718
|
+
// edit through instead of into a copy the caller then drops. Only an
|
|
1719
|
+
// object has the fields the per-record sub-proxy answers for.
|
|
1720
|
+
if (!isPlainObject(rec)) {
|
|
1721
|
+
if (!Array.isArray(rec)) return rec;
|
|
1722
|
+
return writeThroughBody(rec, (next) => {
|
|
1723
|
+
overlay.set(key as string, toPlainDeep(next));
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
768
1726
|
return recordProxy(key as string);
|
|
769
1727
|
},
|
|
770
1728
|
set(_t, key, value) {
|
|
771
1729
|
if (typeof key === 'symbol') return false;
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
toPlainDeep(value) as FlatRecord,
|
|
775
|
-
);
|
|
1730
|
+
assertUsableRecordKey(key as string);
|
|
1731
|
+
overlay.set(key as string, toPlainDeep(value));
|
|
776
1732
|
return true;
|
|
777
1733
|
},
|
|
778
1734
|
deleteProperty(_t, key) {
|
|
779
1735
|
if (typeof key === 'symbol') return false;
|
|
1736
|
+
assertUsableRecordKey(key as string);
|
|
780
1737
|
overlay.set(key as string, TOMBSTONE);
|
|
781
1738
|
return true;
|
|
782
1739
|
},
|
|
@@ -801,61 +1758,640 @@ function mutatePaged<T>(
|
|
|
801
1758
|
},
|
|
802
1759
|
) as Record<string, T>;
|
|
803
1760
|
|
|
804
|
-
fn(draft);
|
|
805
|
-
if (overlay.size === 0) return;
|
|
1761
|
+
fn(draft);
|
|
1762
|
+
if (overlay.size === 0) return;
|
|
1763
|
+
|
|
1764
|
+
applyOverlay(m, overlay, TOMBSTONE, patchIndex, memo, pager, batcher);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// The container holding one record beside the pages, reusing the one already
|
|
1768
|
+
// at this key. A fresh `setContainer` is a destroy+create rather than a write
|
|
1769
|
+
// (the same reason `assignPlain` reuses), so minting one per edit charges the
|
|
1770
|
+
// snapshot for every edit instead of for every edited RECORD: 2,000 edits to
|
|
1771
|
+
// one record cost 71.8 KB minting and 8.6 KB reusing. A drag, a slider, and a
|
|
1772
|
+
// rename are all repeated edits to one record, and every reader downloads the
|
|
1773
|
+
// difference on every open.
|
|
1774
|
+
function overlayContainer(m: LoroMap, overlayKey: string): LoroMap {
|
|
1775
|
+
const existing = m.get(overlayKey);
|
|
1776
|
+
return existing instanceof LoroMap
|
|
1777
|
+
? existing
|
|
1778
|
+
: m.setContainer(overlayKey, new LoroMap());
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// State that a record a page still holds is deleted. The marker carries
|
|
1782
|
+
// nothing but its own existence, so it is a plain value under a key of its
|
|
1783
|
+
// own: a field on the record's container would be a field of the app's record,
|
|
1784
|
+
// and a record that happened to carry it would read as its own deletion.
|
|
1785
|
+
function markDeleted(m: LoroMap, recordKey: string): void {
|
|
1786
|
+
m.set(tombstoneKeyFor(recordKey), true);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Take back a record's delete marker, and report whether there was one.
|
|
1790
|
+
// Called wherever a record's own container is written, since that container is
|
|
1791
|
+
// the record being there again.
|
|
1792
|
+
function clearTombstone(m: LoroMap, recordKey: string): boolean {
|
|
1793
|
+
const key = tombstoneKeyFor(recordKey);
|
|
1794
|
+
if (!entryExists(m, key)) return false;
|
|
1795
|
+
m.delete(key);
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Write out what a paged mutation changed.
|
|
1800
|
+
//
|
|
1801
|
+
// An EDITED record is written OUTSIDE the page holding it, as a patch naming
|
|
1802
|
+
// the fields that changed or, once this session has patched it before, as a
|
|
1803
|
+
// container of its own. A page body is one value, so writing the edit into the
|
|
1804
|
+
// page would make the page the merge unit: two peers changing different
|
|
1805
|
+
// records in one page would resolve last-writer-wins, and an undo would
|
|
1806
|
+
// restore the whole page over a peer's record. What that costs is an entry per
|
|
1807
|
+
// record edited, bounded by the records actually edited rather than by the
|
|
1808
|
+
// store's size.
|
|
1809
|
+
//
|
|
1810
|
+
// The exception is a mutation that replaces EVERY record a page holds, which
|
|
1811
|
+
// `writeEdits` rewrites in place: nothing in the page is left for the rewrite
|
|
1812
|
+
// to resolve on another writer's behalf, so the merge unit does not move. That
|
|
1813
|
+
// is the one route by which an edited store returns to the entry count it
|
|
1814
|
+
// imported at, and it is why a pass over the whole store is much cheaper than
|
|
1815
|
+
// the same edits made a scattered subset at a time.
|
|
1816
|
+
//
|
|
1817
|
+
// A bulk ADD is written as fresh pages instead, in the writer's own key space,
|
|
1818
|
+
// which is safe for the same reason an edit in place is not: those keys are
|
|
1819
|
+
// new, so no peer holds a copy to lose.
|
|
1820
|
+
function applyOverlay(
|
|
1821
|
+
m: LoroMap,
|
|
1822
|
+
overlay: Map<string, FlatRecord | symbol>,
|
|
1823
|
+
tombstone: symbol,
|
|
1824
|
+
patchIndex: PatchIndex | null,
|
|
1825
|
+
memo?: PageMemo,
|
|
1826
|
+
pager?: SessionPager | null,
|
|
1827
|
+
// Commits as the writes accumulate, on the same size budget an unpaged
|
|
1828
|
+
// mutation uses. Without it every page a mutation writes rides one op,
|
|
1829
|
+
// whose size then scales with the mutation rather than with the budget.
|
|
1830
|
+
batcher?: MutateBatcher,
|
|
1831
|
+
): void {
|
|
1832
|
+
let index = pager?.liveIndex ?? null;
|
|
1833
|
+
// Records that MIGHT be additions: nothing holds a container for them yet,
|
|
1834
|
+
// so each is either new or living in a page. Held back so a bulk add is
|
|
1835
|
+
// written as whole pages instead of a container each. A record that already
|
|
1836
|
+
// has a container keeps it: the page holding a record cannot be rewritten to
|
|
1837
|
+
// carry an edit (see above), and moving it would mean deleting a container a
|
|
1838
|
+
// peer may be writing to at that moment.
|
|
1839
|
+
const candidates: [string, FlatRecord][] = [];
|
|
1840
|
+
|
|
1841
|
+
for (const [key, rec] of overlay) {
|
|
1842
|
+
const overlayKey = overlayKeyFor(key);
|
|
1843
|
+
if (rec === tombstone) {
|
|
1844
|
+
// Charged before the writes, so a commit it triggers closes the batch
|
|
1845
|
+
// that precedes this delete rather than splitting it out of one. A
|
|
1846
|
+
// paged store's deletes are unbounded where an unpaged store's stop at
|
|
1847
|
+
// the threshold, so this is the path where a bulk delete would
|
|
1848
|
+
// otherwise ride one op sized by the delete instead of by the budget.
|
|
1849
|
+
batcher?.recordDelete(key);
|
|
1850
|
+
// A record held under its own key outgoes the page on every read, so a
|
|
1851
|
+
// delete that left it would not remove the record. Off the hot read
|
|
1852
|
+
// path: only a delete pays for this.
|
|
1853
|
+
if (entryExists(m, key)) m.delete(key);
|
|
1854
|
+
if (entryExists(m, overlayKey)) m.delete(overlayKey);
|
|
1855
|
+
dropPatches(m, patchIndex, key);
|
|
1856
|
+
// A record a page still holds needs the delete stated, since the page
|
|
1857
|
+
// cannot be rewritten to drop it. One that exists only as its own
|
|
1858
|
+
// container is simply removed, so an add-then-delete leaves nothing and
|
|
1859
|
+
// no marker outlives the record it was about.
|
|
1860
|
+
if (pageHoldsRecord(key, index)) markDeleted(m, key);
|
|
1861
|
+
index?.noteRemoved(key);
|
|
1862
|
+
batcher?.noteKeys([key]);
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1865
|
+
const r = rec;
|
|
1866
|
+
// A record already answering for itself beside the pages: rewrite that
|
|
1867
|
+
// container in place, so repeated edits merge per field and cost one write
|
|
1868
|
+
// each rather than a fresh container.
|
|
1869
|
+
if (m.get(overlayKey) !== undefined) {
|
|
1870
|
+
dropPatches(m, patchIndex, key);
|
|
1871
|
+
writeRecordContainer(m, key, r);
|
|
1872
|
+
index?.noteContained(key);
|
|
1873
|
+
batcher?.noteKeys([key]);
|
|
1874
|
+
batcher?.record(r);
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
// A record under its own BARE key is edited where it already is, exactly
|
|
1878
|
+
// as an unpaged store edits it.
|
|
1879
|
+
//
|
|
1880
|
+
// Giving it an overlay container instead is a `setContainer` at a key no
|
|
1881
|
+
// peer holds yet, so two peers making that record's first edit at the same
|
|
1882
|
+
// time each mint one and the map entry resolves last-writer-wins, dropping
|
|
1883
|
+
// an edit outright. Writing into the container already there merges per
|
|
1884
|
+
// field, and leaves the record stored once rather than twice.
|
|
1885
|
+
if (strayRecordAt(m, key) !== undefined) {
|
|
1886
|
+
dropPatches(m, patchIndex, key);
|
|
1887
|
+
// The record is present again, so its delete marker goes, exactly as it
|
|
1888
|
+
// does when a record is written to a container of its own. A marker
|
|
1889
|
+
// outranks every copy of a record on a read, so an edit that left one
|
|
1890
|
+
// behind would store the new record and answer that it is gone.
|
|
1891
|
+
clearTombstone(m, key);
|
|
1892
|
+
assignEntry(m, key, r);
|
|
1893
|
+
index?.noteContained(key);
|
|
1894
|
+
batcher?.noteKeys([key]);
|
|
1895
|
+
batcher?.record(r);
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
if (pager) {
|
|
1899
|
+
candidates.push([key, r]);
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
dropPatches(m, patchIndex, key);
|
|
1903
|
+
writeRecordContainer(m, key, r);
|
|
1904
|
+
index?.noteContained(key);
|
|
1905
|
+
batcher?.noteKeys([key]);
|
|
1906
|
+
batcher?.record(r);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
if (candidates.length === 0) return;
|
|
1910
|
+
// Below a page's worth there is nothing to PAGE, so nothing here adds a
|
|
1911
|
+
// page; the two branches differ only in whether a bulk write refreshes the
|
|
1912
|
+
// index up front before telling an edit from an addition.
|
|
1913
|
+
const perPage = recordsPerPage(sampleBodyBytes(candidates, pagedBodyOf));
|
|
1914
|
+
const added: [string, FlatRecord][] = [];
|
|
1915
|
+
if (candidates.length >= perPage && pager) {
|
|
1916
|
+
index = pager.ensureIndex(m);
|
|
1917
|
+
const edits: [string, FlatRecord][] = [];
|
|
1918
|
+
for (const entry of candidates) {
|
|
1919
|
+
// The page already holds this record, so this is an EDIT rather than an
|
|
1920
|
+
// addition. That covers a record this store DELETED as well: the page
|
|
1921
|
+
// keeps its copy whatever the marker says, so re-adding it has to write
|
|
1922
|
+
// beside the page rather than into a second one.
|
|
1923
|
+
if (pager.holds(entry[0]) || index.packedInPage(entry[0])) {
|
|
1924
|
+
edits.push(entry);
|
|
1925
|
+
} else added.push(entry);
|
|
1926
|
+
}
|
|
1927
|
+
writeEdits(m, pager, index, patchIndex, edits, memo, batcher);
|
|
1928
|
+
} else if (pager) {
|
|
1929
|
+
for (const [key, r] of candidates) {
|
|
1930
|
+
if (pageHoldsRecord(key, index)) {
|
|
1931
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
writeRecordContainer(m, key, r);
|
|
1935
|
+
index?.noteContained(key);
|
|
1936
|
+
batcher?.noteKeys([key]);
|
|
1937
|
+
batcher?.record(r);
|
|
1938
|
+
}
|
|
1939
|
+
return;
|
|
1940
|
+
} else {
|
|
1941
|
+
for (const [key, r] of candidates) {
|
|
1942
|
+
writeRecordContainer(m, key, r);
|
|
1943
|
+
index?.noteContained(key);
|
|
1944
|
+
batcher?.noteKeys([key]);
|
|
1945
|
+
batcher?.record(r);
|
|
1946
|
+
}
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const written = pager!.writePages(m, added, perPage, batcher);
|
|
1951
|
+
for (let i = written; i < added.length; i += 1) {
|
|
1952
|
+
const [key, r] = added[i]!;
|
|
1953
|
+
writeRecordContainer(m, key, r);
|
|
1954
|
+
index.noteContained(key);
|
|
1955
|
+
batcher?.noteKeys([key]);
|
|
1956
|
+
batcher?.record(r);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* Write a mutation's edits to records their pages hold.
|
|
1962
|
+
*
|
|
1963
|
+
* A page whose every record this mutation replaces is REWRITTEN in place, and
|
|
1964
|
+
* the patches on those records go with it. That is what keeps a transform over
|
|
1965
|
+
* the whole store from landing it in its worst state: a patch each would add
|
|
1966
|
+
* an entry per record, where rewriting leaves the store at the entry count it
|
|
1967
|
+
* had. A bulk pass naturally covers whole pages, because a read hands its keys
|
|
1968
|
+
* back grouped by page.
|
|
1969
|
+
*
|
|
1970
|
+
* The rewrite is safe against the edits that are not rewrites, which is every
|
|
1971
|
+
* other route an edit can take. A page body is one value, so a rewrite is
|
|
1972
|
+
* last-writer-wins on the PAGE, but a peer's patch and a peer's own container
|
|
1973
|
+
* both live outside it and both outrank it on read, so a concurrent edit
|
|
1974
|
+
* survives a rewrite that did not see it. Only another rewrite of the same
|
|
1975
|
+
* page can lose one, and that is two people transforming one model at once.
|
|
1976
|
+
*
|
|
1977
|
+
* Patches are dropped only for the records this rewrite absorbed, so a patch
|
|
1978
|
+
* written concurrently, under a key this writer never saw, is left to apply
|
|
1979
|
+
* over the rewritten page.
|
|
1980
|
+
*/
|
|
1981
|
+
function writeEdits(
|
|
1982
|
+
m: LoroMap,
|
|
1983
|
+
pager: SessionPager,
|
|
1984
|
+
index: PagedKeyIndex,
|
|
1985
|
+
patchIndex: PatchIndex | null,
|
|
1986
|
+
edits: [string, FlatRecord][],
|
|
1987
|
+
memo: PageMemo | undefined,
|
|
1988
|
+
batcher: MutateBatcher | undefined,
|
|
1989
|
+
): void {
|
|
1990
|
+
const byPage = new Map<string, [string, FlatRecord][]>();
|
|
1991
|
+
const loose: [string, FlatRecord][] = [];
|
|
1992
|
+
for (const entry of edits) {
|
|
1993
|
+
const pageKey = index.readsFromPage(entry[0]);
|
|
1994
|
+
if (pageKey === undefined || pageKey === null) {
|
|
1995
|
+
loose.push(entry);
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
const group = byPage.get(pageKey);
|
|
1999
|
+
if (group) group.push(entry);
|
|
2000
|
+
else byPage.set(pageKey, [entry]);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
for (const [pageKey, group] of byPage) {
|
|
2004
|
+
if (!rewritePage(m, pageKey, group, batcher)) {
|
|
2005
|
+
for (const [key, r] of group) {
|
|
2006
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
2007
|
+
}
|
|
2008
|
+
continue;
|
|
2009
|
+
}
|
|
2010
|
+
for (const [key] of group) dropPatches(m, patchIndex, key);
|
|
2011
|
+
index.noteSideEntries();
|
|
2012
|
+
batcher?.noteKeys(group.map(([key]) => key));
|
|
2013
|
+
}
|
|
2014
|
+
for (const [key, r] of loose) {
|
|
2015
|
+
writeRecordEdit(m, pager, index, patchIndex, key, r, memo, batcher);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Rewrite `pageKey`'s body with `group`'s values, or report that this mutation
|
|
2021
|
+
* does not cover the page and leave it untouched.
|
|
2022
|
+
*
|
|
2023
|
+
* Covering means every record the body holds is one this mutation replaces, or
|
|
2024
|
+
* one a container already answers for, whose page copy is dead either way. A
|
|
2025
|
+
* partial rewrite would be the merge unit moving from the record to the page,
|
|
2026
|
+
* which is what patches exist to prevent.
|
|
2027
|
+
*/
|
|
2028
|
+
function rewritePage(
|
|
2029
|
+
m: LoroMap,
|
|
2030
|
+
pageKey: string,
|
|
2031
|
+
group: [string, FlatRecord][],
|
|
2032
|
+
batcher: MutateBatcher | undefined,
|
|
2033
|
+
): boolean {
|
|
2034
|
+
const page = plainEntry(m, pageKey);
|
|
2035
|
+
if (page === undefined) return false;
|
|
2036
|
+
// The RAW triples, not the memo's unpacked records: a triple carries either
|
|
2037
|
+
// a codec's tag beside its body or one whole value, and `repackRecord` needs
|
|
2038
|
+
// the original to keep each one in the shape its readers expect.
|
|
2039
|
+
const held = decodePageRecords(page);
|
|
2040
|
+
if (held.length === 0) return false;
|
|
2041
|
+
const next = new Map(group);
|
|
2042
|
+
for (const packed of held) {
|
|
2043
|
+
if (next.has(packed[0])) continue;
|
|
2044
|
+
if (entryExists(m, overlayKeyFor(packed[0]))) continue;
|
|
2045
|
+
if (entryExists(m, tombstoneKeyFor(packed[0]))) continue;
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
const triples = held.map((packed) => {
|
|
2050
|
+
const value = next.get(packed[0]);
|
|
2051
|
+
return value === undefined ? packed : repackRecord(packed, value);
|
|
2052
|
+
});
|
|
2053
|
+
const encoded = encodePage(triples);
|
|
2054
|
+
writePage(m, pageKey, encoded);
|
|
2055
|
+
// Charged by what the page PACKS rather than by the gzipped bytes it
|
|
2056
|
+
// occupies, so a record costs the same here as it costs assigned into an
|
|
2057
|
+
// unpaged map and one budget means one thing on every route.
|
|
2058
|
+
batcher?.recordChars(encoded.chars);
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Write an encoded page into its container: the tag, the bodies, and the keys
|
|
2063
|
+
// they belong to. The two strings are set together because a reader zips them
|
|
2064
|
+
// by index, so a page carrying one without the other holds no records.
|
|
2065
|
+
function writePage(m: LoroMap, pageKey: string, encoded: EncodedPage): void {
|
|
2066
|
+
const container = overlayContainer(m, pageKey);
|
|
2067
|
+
container.set(TAG_FIELD, PAGE_TAG);
|
|
2068
|
+
container.set(BODY_FIELD, encoded.body);
|
|
2069
|
+
container.set(PAGE_KEYS_FIELD, encoded.keys);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* Remove the patches a whole-record write supersedes.
|
|
2074
|
+
*
|
|
2075
|
+
* A read folds patches on LAST, over whichever copy of the record the page and
|
|
2076
|
+
* container passes resolved, so a patch left behind by an earlier edit would
|
|
2077
|
+
* overwrite the record this write is putting there. Every site that makes a
|
|
2078
|
+
* record's own container authoritative (an edit, a re-add over a tombstone,
|
|
2079
|
+
* the tombstone itself) therefore clears them first.
|
|
2080
|
+
*
|
|
2081
|
+
* This races nothing. A patch key is never written twice, so a peer editing
|
|
2082
|
+
* the same record concurrently writes a key this cannot have seen, and that
|
|
2083
|
+
* patch survives to apply over the container, which is the merge the record
|
|
2084
|
+
* would have had anyway.
|
|
2085
|
+
*/
|
|
2086
|
+
function dropPatches(
|
|
2087
|
+
m: LoroMap,
|
|
2088
|
+
patchIndex: PatchIndex | null,
|
|
2089
|
+
key: string,
|
|
2090
|
+
): void {
|
|
2091
|
+
const keys = patchIndex?.keysFor(key);
|
|
2092
|
+
if (keys === undefined) return;
|
|
2093
|
+
for (const patchKey of keys) m.delete(patchKey);
|
|
2094
|
+
patchIndex!.clear(key);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* Write an edit to a record a page already holds.
|
|
2099
|
+
*
|
|
2100
|
+
* A patch carries only the fields this edit changed, under a key nothing will
|
|
2101
|
+
* write again, so the record stays in its page and two peers changing
|
|
2102
|
+
* different fields of it both keep theirs. A whole-record write cannot do
|
|
2103
|
+
* either: it takes the record out of its page for good, and the second writer
|
|
2104
|
+
* overwrites the first.
|
|
2105
|
+
*
|
|
2106
|
+
* Removing a field is the one edit a patch cannot state, since a patch is
|
|
2107
|
+
* applied by overlaying its keys. That falls back to the whole-record write,
|
|
2108
|
+
* which is correct and merely gives up the packing for that record.
|
|
2109
|
+
*
|
|
2110
|
+
* A record this session has ALREADY patched takes that fallback too. Patches
|
|
2111
|
+
* accumulate where a container's keys are rewritten in place, so a record
|
|
2112
|
+
* edited over and over (one drag is many edits to one record) would grow the
|
|
2113
|
+
* document without bound. Making the first edit a patch and the second a
|
|
2114
|
+
* container bounds it at one patch per record: an entropy sweep that touches
|
|
2115
|
+
* every record once stays packed, and a hot record costs what it always did.
|
|
2116
|
+
* The FIRST edit is the one that has to be a patch, because that is the moment
|
|
2117
|
+
* two peers editing one record would otherwise mint a container at the same
|
|
2118
|
+
* key and lose one of the two.
|
|
2119
|
+
*/
|
|
2120
|
+
function writeRecordEdit(
|
|
2121
|
+
m: LoroMap,
|
|
2122
|
+
pager: SessionPager,
|
|
2123
|
+
index: PagedKeyIndex | null,
|
|
2124
|
+
patchIndex: PatchIndex | null,
|
|
2125
|
+
key: string,
|
|
2126
|
+
record: FlatRecord,
|
|
2127
|
+
memo: PageMemo | undefined,
|
|
2128
|
+
batcher: MutateBatcher | undefined,
|
|
2129
|
+
): void {
|
|
2130
|
+
const previous = recordFromPage(m, key, memo, index, patchIndex);
|
|
2131
|
+
const patch = alreadyPatchedHere(patchIndex, key, pager.token)
|
|
2132
|
+
? null
|
|
2133
|
+
: changedFields(previous, record);
|
|
2134
|
+
if (patch === null) {
|
|
2135
|
+
dropPatches(m, patchIndex, key);
|
|
2136
|
+
writeRecordContainer(m, key, record);
|
|
2137
|
+
index?.noteContained(key);
|
|
2138
|
+
batcher?.noteKeys([key]);
|
|
2139
|
+
batcher?.record(record);
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (Object.keys(patch).length === 0) return;
|
|
2143
|
+
const patchKey = pager.nextPatchKey(key, patchIndex);
|
|
2144
|
+
m.set(patchKey, toPlainDeep(patch) as never);
|
|
2145
|
+
patchIndex?.note(patchKey, key);
|
|
2146
|
+
index?.noteSideEntries();
|
|
2147
|
+
batcher?.noteKeys([key]);
|
|
2148
|
+
batcher?.record(patch);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Whether this session already holds a patch on `key`. Another peer's patch
|
|
2152
|
+
* does not count: every peer's FIRST edit has to be a patch for two of them
|
|
2153
|
+
* editing one record to merge. */
|
|
2154
|
+
function alreadyPatchedHere(
|
|
2155
|
+
patchIndex: PatchIndex | null,
|
|
2156
|
+
key: string,
|
|
2157
|
+
token: string,
|
|
2158
|
+
): boolean {
|
|
2159
|
+
const keys = patchIndex?.keysFor(key);
|
|
2160
|
+
if (keys === undefined) return false;
|
|
2161
|
+
for (const patchKey of keys) {
|
|
2162
|
+
if (parsePatchKey(patchKey)?.peer === token) return true;
|
|
2163
|
+
}
|
|
2164
|
+
return false;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/** The fields `next` changes, or null when this edit is not one a patch can
|
|
2168
|
+
* express: a field was removed, or either side is not an object, and a patch
|
|
2169
|
+
* is applied by overlaying field names. */
|
|
2170
|
+
function changedFields(
|
|
2171
|
+
previous: unknown,
|
|
2172
|
+
next: unknown,
|
|
2173
|
+
): Record<string, unknown> | null {
|
|
2174
|
+
if (!isPlainObject(previous) || !isPlainObject(next)) return null;
|
|
2175
|
+
for (const field of Object.keys(previous)) {
|
|
2176
|
+
if (!(field in next)) return null;
|
|
2177
|
+
}
|
|
2178
|
+
const patch: Record<string, unknown> = {};
|
|
2179
|
+
for (const [field, value] of Object.entries(next)) {
|
|
2180
|
+
const before = previous[field];
|
|
2181
|
+
if (before === value) continue;
|
|
2182
|
+
if (JSON.stringify(before) === JSON.stringify(value)) continue;
|
|
2183
|
+
patch[field] = value;
|
|
2184
|
+
}
|
|
2185
|
+
return patch;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// Write one record beside the pages, under the key that shadows the page copy.
|
|
2189
|
+
// A record store's record is `{ type, params }`; an app's is whatever the app
|
|
2190
|
+
// assigned, and both are stored the same way here so the paged path carries an
|
|
2191
|
+
// app's own shape through unchanged.
|
|
2192
|
+
//
|
|
2193
|
+
// An OBJECT takes a container, field by field as PLAIN values: every field
|
|
2194
|
+
// stays a plain value rather than being promoted to a child container, which is
|
|
2195
|
+
// what keeps the RECORD the merge unit (see the proxy's plain-value
|
|
2196
|
+
// preservation). Any other value an app assigned (a number, a string, an
|
|
2197
|
+
// array) is stored as that value, because a container cannot hold one and
|
|
2198
|
+
// walking its entries into fields is what destroys it: `5` has none, so it
|
|
2199
|
+
// would store as an empty container, and `'hello'` has one per character.
|
|
2200
|
+
//
|
|
2201
|
+
// Takes back the record's delete marker, if it had one: this entry IS the
|
|
2202
|
+
// record being present. Every write that puts a record back clears the marker,
|
|
2203
|
+
// here and on the in-place edit of a record still under its own bare key,
|
|
2204
|
+
// because a marker outranks every copy of a record on a read, so a route that
|
|
2205
|
+
// left one would re-add a record nothing can read.
|
|
2206
|
+
function writeRecordContainer(
|
|
2207
|
+
m: LoroMap,
|
|
2208
|
+
recordKey: string,
|
|
2209
|
+
record: unknown,
|
|
2210
|
+
): void {
|
|
2211
|
+
// The RECORD's key, not the key it is written under. Both are strings, so a
|
|
2212
|
+
// caller that hands over the key it already computed writes the record one
|
|
2213
|
+
// prefix deeper, where every reader resolves a different record. The draft
|
|
2214
|
+
// refuses a reserved key at the write, so one arriving here is that mistake.
|
|
2215
|
+
assertUsableRecordKey(recordKey);
|
|
2216
|
+
clearTombstone(m, recordKey);
|
|
2217
|
+
const overlayKey = overlayKeyFor(recordKey);
|
|
2218
|
+
if (!isPlainObject(record)) {
|
|
2219
|
+
m.set(overlayKey, toPlainDeep(record) as never);
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
const container = overlayContainer(m, overlayKey);
|
|
2223
|
+
for (const field of container.keys()) {
|
|
2224
|
+
if (!(field in record)) container.delete(field);
|
|
2225
|
+
}
|
|
2226
|
+
for (const [field, value] of Object.entries(record)) {
|
|
2227
|
+
container.set(field, toPlainDeep(value) as never);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* One editing session's writer of dense pages, and the key index that finds
|
|
2233
|
+
* records in them.
|
|
2234
|
+
*
|
|
2235
|
+
* A page is one value, so two writers appending to the same page container
|
|
2236
|
+
* resolve last-writer-wins and one writer's records are lost. Each session
|
|
2237
|
+
* therefore writes only pages carrying its own token, and never rewrites one
|
|
2238
|
+
* it has sealed. Nothing is ever moved out of a container into a page, so no
|
|
2239
|
+
* write here can race a peer's edit of a record.
|
|
2240
|
+
*/
|
|
2241
|
+
class SessionPager {
|
|
2242
|
+
private readonly _index: PagedKeyIndex;
|
|
2243
|
+
private readonly _patchIndex = new PatchIndex();
|
|
2244
|
+
// Whether the index has been built. A pager over an unpaged root never needs
|
|
2245
|
+
// one, so it stays unbuilt until something asks the map where a record is.
|
|
2246
|
+
private _live = false;
|
|
2247
|
+
private readonly _token: string;
|
|
2248
|
+
private _next = -1;
|
|
2249
|
+
|
|
2250
|
+
constructor(private readonly _paging: PagedRootHooks) {
|
|
2251
|
+
this._token = _paging.sessionToken();
|
|
2252
|
+
this._index = new PagedKeyIndex(() => _paging.stateVersion());
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
/** The index, if it is being kept; null when nothing has needed it. */
|
|
2256
|
+
get liveIndex(): PagedKeyIndex | null {
|
|
2257
|
+
return this._live ? this._index : null;
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/** How many times the page index has been rebuilt. */
|
|
2261
|
+
get indexRebuilds(): number {
|
|
2262
|
+
return this._index.rebuilds;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
/** Build the index if it does not exist, and bring it up to date. */
|
|
2266
|
+
ensureIndex(m: LoroMap): PagedKeyIndex {
|
|
2267
|
+
this._live = true;
|
|
2268
|
+
this._index.refresh(m);
|
|
2269
|
+
return this._index;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** The sink a whole-store read feeds the index through, or null when the
|
|
2273
|
+
* index already answers for the map as it stands. Close it with
|
|
2274
|
+
* `endHarvest` once the read has walked every entry. */
|
|
2275
|
+
beginHarvest(): RecordLocationSink | null {
|
|
2276
|
+
if (this._live && !this._index.stale()) return null;
|
|
2277
|
+
this._live = true;
|
|
2278
|
+
return this._index.harvest();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/** Stamp the harvested index as current. */
|
|
2282
|
+
endHarvest(): void {
|
|
2283
|
+
this._index.stamp();
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/** The patch lookup, brought up to date against the document. Always kept,
|
|
2287
|
+
* unlike the page index: it costs a walk of the keys with no page decode,
|
|
2288
|
+
* and a keyed read that skipped it would return the record's pre-edit copy.
|
|
2289
|
+
*
|
|
2290
|
+
* A WRITE takes this once, before its first write, and the `note` / `clear`
|
|
2291
|
+
* calls under it keep that one instance current. Taking it again per record
|
|
2292
|
+
* would rebuild per record, since the version advances as the mutation
|
|
2293
|
+
* writes. */
|
|
2294
|
+
patches(m: LoroMap): PatchIndex {
|
|
2295
|
+
this._patchIndex.refresh(m, this._paging.stateVersion());
|
|
2296
|
+
return this._patchIndex;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/** Whether the map already stores a record under `key`. Only meaningful
|
|
2300
|
+
* after `ensureIndex`. */
|
|
2301
|
+
holds(key: string): boolean {
|
|
2302
|
+
return this._index.has(key);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/** Whether some page's body holds `key`, which stays true after the record
|
|
2306
|
+
* is deleted: a page cannot be rewritten to drop it. Only meaningful after
|
|
2307
|
+
* `ensureIndex`. */
|
|
2308
|
+
packedInPage(key: string): boolean {
|
|
2309
|
+
return this._index.packedInPage(key);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** This writer's token, which names the page and patch keys it may write. */
|
|
2313
|
+
get token(): string {
|
|
2314
|
+
return this._token;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/** A patch key for an edit to `recordKey`: this session's token, plus a
|
|
2318
|
+
* counter one above what the record already carries, so the fold applies
|
|
2319
|
+
* this edit over every patch the writer had merged.
|
|
2320
|
+
*
|
|
2321
|
+
* `patchIndex` is the same instance `patches` hands out, which the write
|
|
2322
|
+
* path keeps current as it goes; the counter has to come from it rather
|
|
2323
|
+
* than from a rebuild, since a patch this mutation just wrote is one this
|
|
2324
|
+
* one must still mint above. */
|
|
2325
|
+
nextPatchKey(recordKey: string, patchIndex: PatchIndex | null): string {
|
|
2326
|
+
const counter = (patchIndex ?? this._patchIndex).nextCounterFor(recordKey);
|
|
2327
|
+
return patchKeyFor(recordKey, this._token, counter);
|
|
2328
|
+
}
|
|
806
2329
|
|
|
807
|
-
|
|
808
|
-
|
|
2330
|
+
/** Write as many of `records` as fill whole pages, and return how many were
|
|
2331
|
+
* written. A remainder shorter than one page is left to the caller, which
|
|
2332
|
+
* gives it a container each. */
|
|
2333
|
+
writePages(
|
|
2334
|
+
m: LoroMap,
|
|
2335
|
+
records: [string, FlatRecord][],
|
|
2336
|
+
perPage: number,
|
|
2337
|
+
batcher?: MutateBatcher,
|
|
2338
|
+
): number {
|
|
2339
|
+
const pages = Math.floor(records.length / perPage);
|
|
2340
|
+
if (pages === 0) return 0;
|
|
2341
|
+
for (let p = 0; p < pages; p += 1) {
|
|
2342
|
+
const slice = records.slice(p * perPage, (p + 1) * perPage);
|
|
2343
|
+
const key = this._mintPageKey(m);
|
|
2344
|
+
const encoded = encodePage(slice.map(([k, r]) => packWholeValue(k, r)));
|
|
2345
|
+
writePage(m, key, encoded);
|
|
2346
|
+
this._index.notePage(
|
|
2347
|
+
key,
|
|
2348
|
+
slice.map(([k]) => k),
|
|
2349
|
+
);
|
|
2350
|
+
batcher?.noteKeys(slice.map(([k]) => k));
|
|
2351
|
+
// Charged by what the page PACKS rather than by the gzipped bytes it
|
|
2352
|
+
// occupies: this is the only charge a bulk add into an already-paged
|
|
2353
|
+
// store takes, so pricing it compressed would let the same records ride
|
|
2354
|
+
// one op here and several on the route that pages part-way through.
|
|
2355
|
+
batcher?.recordChars(encoded.chars);
|
|
2356
|
+
}
|
|
2357
|
+
return pages * perPage;
|
|
2358
|
+
}
|
|
809
2359
|
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
|
|
814
|
-
m: LoroMap
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
touchedPages.add(pageKeyFor(key, pageCount));
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
for (const pageKey of touchedPages) {
|
|
825
|
-
const existing = pageValue(m, pageKey);
|
|
826
|
-
const triples: PackedRecord<unknown>[] = [];
|
|
827
|
-
// Carry over the page's untouched records.
|
|
828
|
-
if (peekRecordTag(existing) === PAGE_TAG) {
|
|
829
|
-
for (const packed of decodePageBody(decodeRecordValue(existing)?.body)) {
|
|
830
|
-
if (
|
|
831
|
-
Array.isArray(packed) &&
|
|
832
|
-
typeof packed[0] === 'string' &&
|
|
833
|
-
!overlay.has(packed[0])
|
|
834
|
-
) {
|
|
835
|
-
triples.push(packed);
|
|
2360
|
+
// The next unused page key for this token. Seeded above whatever the store
|
|
2361
|
+
// already holds, so a session that reopens a document cannot overwrite the
|
|
2362
|
+
// pages an earlier session under the same token sealed, and stepped past any
|
|
2363
|
+
// key already occupied by something that is not one of this session's pages.
|
|
2364
|
+
private _mintPageKey(m: LoroMap): string {
|
|
2365
|
+
if (this._next < 0) {
|
|
2366
|
+
let highest = -1;
|
|
2367
|
+
for (const key of m.keys()) {
|
|
2368
|
+
const parsed = parseSessionPageKey(key);
|
|
2369
|
+
if (parsed && parsed.token === this._token) {
|
|
2370
|
+
highest = Math.max(highest, parsed.index);
|
|
836
2371
|
}
|
|
837
2372
|
}
|
|
2373
|
+
this._next = highest + 1;
|
|
838
2374
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const r = rec as FlatRecord;
|
|
844
|
-
triples.push([key, String(r[TAG_FIELD] ?? ''), r[BODY_FIELD]]);
|
|
845
|
-
}
|
|
846
|
-
if (triples.length === 0) {
|
|
847
|
-
if (existing !== undefined) m.delete(pageKey);
|
|
848
|
-
continue;
|
|
2375
|
+
let key = sessionPageKey(this._token, this._next);
|
|
2376
|
+
while (m.get(key) !== undefined) {
|
|
2377
|
+
this._next += 1;
|
|
2378
|
+
key = sessionPageKey(this._token, this._next);
|
|
849
2379
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
page.set(BODY_FIELD, encodePageBody(triples));
|
|
2380
|
+
this._next += 1;
|
|
2381
|
+
return key;
|
|
853
2382
|
}
|
|
854
2383
|
}
|
|
855
2384
|
|
|
2385
|
+
// The value a page holds of one record, which is what `sampleBodyBytes` weighs
|
|
2386
|
+
// to size a page. An app's map packs each record's whole stored value as its
|
|
2387
|
+
// triple's body (`packWholeValue`), so the record itself is that value.
|
|
2388
|
+
function pagedBodyOf(entry: [string, FlatRecord]): FlatRecord {
|
|
2389
|
+
return entry[1];
|
|
2390
|
+
}
|
|
2391
|
+
|
|
856
2392
|
// ---- Concrete handle implementations ----------------------------------
|
|
857
2393
|
//
|
|
858
|
-
// Both handles wrap an "anchor": a root LoroMap/
|
|
2394
|
+
// Both handles wrap an "anchor": a root LoroMap/LoroMovableList directly, or a
|
|
859
2395
|
// (parent LoroMap, key) pair. The anchor gives the handle stable identity
|
|
860
2396
|
// even before the container exists (a `doc.map('foo').map('bar')` chain
|
|
861
2397
|
// returns a working handle for `foo.bar`, created on the first mutate).
|
|
@@ -875,12 +2411,19 @@ interface MapAnchor {
|
|
|
875
2411
|
// container, so `_resync` invalidates it. A no-op for root anchors, which
|
|
876
2412
|
// re-point via `rebind`.
|
|
877
2413
|
invalidate(): void;
|
|
2414
|
+
// The plain value at this anchor's key, when the key holds one instead of a
|
|
2415
|
+
// container. Only a CHILD anchor can be in that state (a root key is always
|
|
2416
|
+
// a container), so a root anchor omits it.
|
|
2417
|
+
plain?(): unknown;
|
|
878
2418
|
}
|
|
879
2419
|
|
|
880
2420
|
interface ListAnchor {
|
|
881
|
-
resolve():
|
|
882
|
-
resolveOrCreate():
|
|
2421
|
+
resolve(): LoroMovableList | null;
|
|
2422
|
+
resolveOrCreate(): LoroMovableList;
|
|
883
2423
|
invalidate(): void;
|
|
2424
|
+
// See `MapAnchor.plain`: a child key can hold a plain array instead of a
|
|
2425
|
+
// container, and a scoped handle must read it rather than report it empty.
|
|
2426
|
+
plain?(): unknown;
|
|
884
2427
|
}
|
|
885
2428
|
|
|
886
2429
|
// A root anchor is re-pointable: a version checkout swaps the Document's
|
|
@@ -894,14 +2437,14 @@ interface RootMapAnchor extends MapAnchor {
|
|
|
894
2437
|
rebind(next: LoroMap): void;
|
|
895
2438
|
}
|
|
896
2439
|
interface RootListAnchor extends ListAnchor {
|
|
897
|
-
rebind(next:
|
|
2440
|
+
rebind(next: LoroMovableList): void;
|
|
898
2441
|
}
|
|
899
2442
|
|
|
900
2443
|
// A root anchor holds one container that a version checkout swaps in place
|
|
901
2444
|
// (`rebind`). Between rebinds `resolve()` returns the same instance, so the
|
|
902
2445
|
// per-container Loro subscription attaches to a stable identity. `LoroMap` and
|
|
903
|
-
// `
|
|
904
|
-
function rootAnchor<C extends LoroMap |
|
|
2446
|
+
// `LoroMovableList` roots share this; the kind is the type parameter.
|
|
2447
|
+
function rootAnchor<C extends LoroMap | LoroMovableList>(
|
|
905
2448
|
root: C,
|
|
906
2449
|
): {
|
|
907
2450
|
resolve(): C;
|
|
@@ -924,11 +2467,16 @@ function rootAnchor<C extends LoroMap | LoroList>(
|
|
|
924
2467
|
// per-container subscribers attach to a stable instance. `ctor` selects the
|
|
925
2468
|
// container kind: a value of the wrong kind at the key reads as absent, and
|
|
926
2469
|
// `resolveOrCreate` mints one only inside a mutate context.
|
|
927
|
-
function childAnchor<C extends LoroMap |
|
|
2470
|
+
function childAnchor<C extends LoroMap | LoroMovableList>(
|
|
928
2471
|
parent: MapAnchor,
|
|
929
2472
|
key: string,
|
|
930
2473
|
ctor: new () => C,
|
|
931
|
-
): {
|
|
2474
|
+
): {
|
|
2475
|
+
resolve(): C | null;
|
|
2476
|
+
resolveOrCreate(): C;
|
|
2477
|
+
invalidate(): void;
|
|
2478
|
+
plain(): unknown;
|
|
2479
|
+
} {
|
|
932
2480
|
// `invalidate` clears the cache on a source swap so the next resolve
|
|
933
2481
|
// re-derives through the (rebound) parent.
|
|
934
2482
|
let cached: C | null = null;
|
|
@@ -950,6 +2498,11 @@ function childAnchor<C extends LoroMap | LoroList>(
|
|
|
950
2498
|
cached = v as C;
|
|
951
2499
|
return cached;
|
|
952
2500
|
}
|
|
2501
|
+
// A container of the OTHER kind reads as absent, and the handle this
|
|
2502
|
+
// read minted is one nothing keeps. A live subscription resolves on
|
|
2503
|
+
// every change to the document it hangs off, so a handle left undropped
|
|
2504
|
+
// here ratchets wasm memory for the length of the session.
|
|
2505
|
+
if (isContainer(v)) (v as { free(): void }).free();
|
|
953
2506
|
return null;
|
|
954
2507
|
},
|
|
955
2508
|
resolveOrCreate: () => {
|
|
@@ -961,16 +2514,123 @@ function childAnchor<C extends LoroMap | LoroList>(
|
|
|
961
2514
|
cached = v as C;
|
|
962
2515
|
return cached;
|
|
963
2516
|
}
|
|
2517
|
+
// The key may hold a PLAIN value rather than a container: that is how a
|
|
2518
|
+
// record body is stored above the paging threshold, and how a promoted
|
|
2519
|
+
// record is stored beside its pages. Taking a scoped handle there is a
|
|
2520
|
+
// request for finer granularity on that record, so the container is
|
|
2521
|
+
// seeded from the value it replaces. Creating an empty one instead
|
|
2522
|
+
// discards every field the record already had, which the caller cannot
|
|
2523
|
+
// see coming: it asked for a handle, not for a delete.
|
|
964
2524
|
const created = p.setContainer(key, new ctor());
|
|
2525
|
+
if (created instanceof LoroMap && isPlainObject(v)) {
|
|
2526
|
+
assignPlain(created, v);
|
|
2527
|
+
} else if (created instanceof LoroMovableList && Array.isArray(v)) {
|
|
2528
|
+
assignList(created, v);
|
|
2529
|
+
}
|
|
965
2530
|
cached = created;
|
|
966
2531
|
return created;
|
|
967
2532
|
},
|
|
2533
|
+
// The plain value at this key, when it holds one instead of a container.
|
|
2534
|
+
// A read through a scoped handle answers from this rather than reporting
|
|
2535
|
+
// the key empty, so `read()` agrees with what the parent map returns.
|
|
2536
|
+
plain: () => {
|
|
2537
|
+
const p = parent.resolve();
|
|
2538
|
+
if (!p) return undefined;
|
|
2539
|
+
const v = p.get(key);
|
|
2540
|
+
// A container is what `resolve` answers for, whatever kind it is, so
|
|
2541
|
+
// what this reports is a plain value or nothing. The handle the read
|
|
2542
|
+
// minted goes back either way (see `plainEntry`).
|
|
2543
|
+
if (isContainer(v)) {
|
|
2544
|
+
(v as { free(): void }).free();
|
|
2545
|
+
return undefined;
|
|
2546
|
+
}
|
|
2547
|
+
return v;
|
|
2548
|
+
},
|
|
968
2549
|
invalidate: () => {
|
|
969
2550
|
cached = null;
|
|
970
2551
|
},
|
|
971
2552
|
};
|
|
972
2553
|
}
|
|
973
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* What a composed handle's subscription needs from its handle to follow the
|
|
2557
|
+
* entry it was taken for. `read` answers what the entry holds as any other
|
|
2558
|
+
* reader sees it, `print` reduces that to a value the tracker can compare, and
|
|
2559
|
+
* `catchUp` hands the subscriber the whole entry.
|
|
2560
|
+
*/
|
|
2561
|
+
interface EntryTracking<C extends LoroMap | LoroMovableList, V> {
|
|
2562
|
+
resolve(): C | null;
|
|
2563
|
+
read(): V;
|
|
2564
|
+
print(value: V): string;
|
|
2565
|
+
catchUp(value: V): void;
|
|
2566
|
+
attach(container: C): () => void;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/**
|
|
2570
|
+
* Keep a composed handle's per-container subscription bound to whatever
|
|
2571
|
+
* currently answers for its entry.
|
|
2572
|
+
*
|
|
2573
|
+
* Which container that is moves under the app. A map key deleted and written
|
|
2574
|
+
* again holds a NEW one, and over a paged record store an edit is parked
|
|
2575
|
+
* beside the pages first, so for a while none answers for the record at all.
|
|
2576
|
+
* Loro's subscribe is per-container, so a subscription bound once goes silent
|
|
2577
|
+
* on the record it was taken for while `read()` follows that record, and the
|
|
2578
|
+
* app keeps rendering what it last heard with nothing to correct it.
|
|
2579
|
+
*
|
|
2580
|
+
* `root` is the topmost container of the handle's chain, which the document
|
|
2581
|
+
* holds for its whole life, so every write that could reach this entry passes
|
|
2582
|
+
* through its subtree and no delete takes the watch down with it. A watch on
|
|
2583
|
+
* the nearest live ancestor cannot promise that: deleting that ancestor
|
|
2584
|
+
* strands the subscription on a dead container, including for the write that
|
|
2585
|
+
* puts the ancestor back.
|
|
2586
|
+
*
|
|
2587
|
+
* Each fire compares what answers for the entry now against what the
|
|
2588
|
+
* subscription is bound to, and on a difference hands the subscriber the whole
|
|
2589
|
+
* entry before re-binding, the sequence `_resync` runs for a source swap.
|
|
2590
|
+
* `catchUp` runs inside the root's fire, so the subscription `attach` returns
|
|
2591
|
+
* misses the batch that made the difference, which is why the whole entry goes
|
|
2592
|
+
* first.
|
|
2593
|
+
*
|
|
2594
|
+
* The print covers an entry no container answers for: its value still moves (a
|
|
2595
|
+
* page rewritten around it, an edit beside the pages, a delete marker), and
|
|
2596
|
+
* comparing it is the only way to see that. It is taken only while nothing is
|
|
2597
|
+
* bound, so an entry with a container of its own costs one cached resolve per
|
|
2598
|
+
* fire and no read.
|
|
2599
|
+
*/
|
|
2600
|
+
function trackEntryContainer<C extends LoroMap | LoroMovableList, V>(
|
|
2601
|
+
root: LoroMap | null,
|
|
2602
|
+
spec: EntryTracking<C, V>,
|
|
2603
|
+
): () => void {
|
|
2604
|
+
if (!root) return () => {};
|
|
2605
|
+
let bound = spec.resolve();
|
|
2606
|
+
let print = bound === null ? spec.print(spec.read()) : '';
|
|
2607
|
+
let detach = bound === null ? () => {} : spec.attach(bound);
|
|
2608
|
+
const watch = root.subscribe(() => {
|
|
2609
|
+
const next = spec.resolve();
|
|
2610
|
+
if (next === bound) {
|
|
2611
|
+
// A bound container reports its own changes, so there is nothing to
|
|
2612
|
+
// compare and nothing to re-bind.
|
|
2613
|
+
if (next !== null) return;
|
|
2614
|
+
const value = spec.read();
|
|
2615
|
+
const current = spec.print(value);
|
|
2616
|
+
if (current === print) return;
|
|
2617
|
+
print = current;
|
|
2618
|
+
spec.catchUp(value);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
detach();
|
|
2622
|
+
bound = next;
|
|
2623
|
+
const value = spec.read();
|
|
2624
|
+
print = next === null ? spec.print(value) : '';
|
|
2625
|
+
spec.catchUp(value);
|
|
2626
|
+
detach = next === null ? () => {} : spec.attach(next);
|
|
2627
|
+
});
|
|
2628
|
+
return () => {
|
|
2629
|
+
watch();
|
|
2630
|
+
detach();
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
|
|
974
2634
|
// Shared per-container subscription bookkeeping for both handle kinds. Holds
|
|
975
2635
|
// the active { callback, detach } records so a version checkout can re-fire
|
|
976
2636
|
// and re-attach each against the swapped source, and a suspend / close can
|
|
@@ -980,8 +2640,66 @@ function childAnchor<C extends LoroMap | LoroList>(
|
|
|
980
2640
|
// dedup, a different shape: it models no per-listener external resource, and
|
|
981
2641
|
// the Loro subscription each record must re-create on rebind is exactly
|
|
982
2642
|
// that.)
|
|
2643
|
+
/** Run a callback the SDK owns, without letting it break the caller. */
|
|
2644
|
+
/** Report a subscriber that threw, then carry on. One app callback failing
|
|
2645
|
+
* must not break a registration or stop the other subscribers, but swallowing
|
|
2646
|
+
* it leaves an app that stalled inside its own callback with nothing in the
|
|
2647
|
+
* console to read. */
|
|
2648
|
+
function reportSubscriberThrow(err: unknown): void {
|
|
2649
|
+
console.error('[Brass] subscriber threw:', err);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function fireQuietly(cb: () => void): void {
|
|
2653
|
+
try {
|
|
2654
|
+
cb();
|
|
2655
|
+
} catch (err) {
|
|
2656
|
+
reportSubscriberThrow(err);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
983
2660
|
class SubscriptionRegistry<Cb> {
|
|
984
2661
|
private readonly _subs = new Set<{ cb: Cb; detach: () => void }>();
|
|
2662
|
+
private readonly _settled = new Set<() => void>();
|
|
2663
|
+
// Whether a whole-container delivery is in progress. Always false while a
|
|
2664
|
+
// delivery is one synchronous call, and the reason `onSettled` is defined
|
|
2665
|
+
// against a delivery rather than against a call: when a delivery arrives in
|
|
2666
|
+
// several parts, registering mid-way waits for the end instead of reporting
|
|
2667
|
+
// a container the caller has only some of.
|
|
2668
|
+
private _delivering = false;
|
|
2669
|
+
|
|
2670
|
+
/**
|
|
2671
|
+
* Report each time this container has been delivered whole.
|
|
2672
|
+
*
|
|
2673
|
+
* Fires on registration, and again after every re-delivery: a version
|
|
2674
|
+
* checkout, a return to head, a hydrate. Repeating rather than one-shot is
|
|
2675
|
+
* what an app needs, since the work it hangs here (frame the camera, run the
|
|
2676
|
+
* first scan, decide whether seeding is safe) is owed again each time the
|
|
2677
|
+
* container is replaced under it. A one-shot signal is the bug four demos
|
|
2678
|
+
* hand-rolled, where a `firstFire` flag never re-arms and the camera never
|
|
2679
|
+
* frames the version the reader switched to.
|
|
2680
|
+
*
|
|
2681
|
+
* It does NOT fire when some OTHER subscriber registers: that delivery went
|
|
2682
|
+
* to one new subscriber, and says nothing to the callers already holding the
|
|
2683
|
+
* container.
|
|
2684
|
+
*
|
|
2685
|
+
* What it can claim is what this peer had converged to when the delivery
|
|
2686
|
+
* began. More always arrives afterwards.
|
|
2687
|
+
*/
|
|
2688
|
+
onSettled(cb: () => void): HandleUnsubscribe {
|
|
2689
|
+
this._settled.add(cb);
|
|
2690
|
+
if (!this._delivering) fireQuietly(cb);
|
|
2691
|
+
return () => {
|
|
2692
|
+
this._settled.delete(cb);
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
private _settle(): void {
|
|
2697
|
+
// Copied before the walk: a callback that unsubscribes another would
|
|
2698
|
+
// otherwise remove it from the set mid-iteration, and an entry deleted
|
|
2699
|
+
// before it is reached is never visited.
|
|
2700
|
+
const listeners = Array.from(this._settled);
|
|
2701
|
+
for (const cb of listeners) fireQuietly(cb);
|
|
2702
|
+
}
|
|
985
2703
|
|
|
986
2704
|
// Fire the whole-container catch-up once, attach the per-container change
|
|
987
2705
|
// subscription, track the record, and return an idempotent unsubscribe.
|
|
@@ -990,7 +2708,12 @@ class SubscriptionRegistry<Cb> {
|
|
|
990
2708
|
fireWhole: (cb: Cb) => void,
|
|
991
2709
|
attach: (cb: Cb) => () => void,
|
|
992
2710
|
): HandleUnsubscribe {
|
|
993
|
-
|
|
2711
|
+
this._delivering = true;
|
|
2712
|
+
try {
|
|
2713
|
+
fireWhole(cb);
|
|
2714
|
+
} finally {
|
|
2715
|
+
this._delivering = false;
|
|
2716
|
+
}
|
|
994
2717
|
const rec = { cb, detach: attach(cb) };
|
|
995
2718
|
this._subs.add(rec);
|
|
996
2719
|
return () => {
|
|
@@ -1002,31 +2725,102 @@ class SubscriptionRegistry<Cb> {
|
|
|
1002
2725
|
// Re-fire every active subscriber with the whole current container and
|
|
1003
2726
|
// re-attach its change subscription to it (checkout / return-to-head).
|
|
1004
2727
|
resync(fireWhole: (cb: Cb) => void, attach: (cb: Cb) => () => void): void {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
2728
|
+
this._delivering = true;
|
|
2729
|
+
try {
|
|
2730
|
+
for (const rec of this._subs) {
|
|
2731
|
+
rec.detach();
|
|
2732
|
+
fireWhole(rec.cb);
|
|
2733
|
+
rec.detach = attach(rec.cb);
|
|
2734
|
+
}
|
|
2735
|
+
} finally {
|
|
2736
|
+
this._delivering = false;
|
|
1009
2737
|
}
|
|
2738
|
+
// The container has been delivered whole again, which is the transition
|
|
2739
|
+
// `onSettled` exists to report.
|
|
2740
|
+
this._settle();
|
|
1010
2741
|
}
|
|
1011
2742
|
|
|
1012
2743
|
// Detach every active subscription and forget it (suspend / close).
|
|
1013
2744
|
teardown(): void {
|
|
1014
2745
|
for (const rec of this._subs) rec.detach();
|
|
1015
2746
|
this._subs.clear();
|
|
2747
|
+
this._settled.clear();
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
/**
|
|
2752
|
+
* What a ROOT map handle needs from its owning Document to present a paged
|
|
2753
|
+
* store. Bundled rather than passed one by one: every member is read off the
|
|
2754
|
+
* ACTIVE source's `meta` at call time (a version checkout swaps the source),
|
|
2755
|
+
* and a handle that can never be a paged root is given none of them.
|
|
2756
|
+
*/
|
|
2757
|
+
/**
|
|
2758
|
+
* The document's version, as the freshness stamp a cached index checks.
|
|
2759
|
+
*
|
|
2760
|
+
* Loro's frontiers, which advance on every op whatever produced it: a local
|
|
2761
|
+
* write, a remote import, an undo, a version checkout. A cached index cannot
|
|
2762
|
+
* key on the map's SIZE instead, because a remote change and a local undo can
|
|
2763
|
+
* leave the size they found it at while changing which keys are there, and the
|
|
2764
|
+
* index then answers from keys the map no longer holds. Reading it costs about
|
|
2765
|
+
* 2 us and does not close the open transaction, so a mutation still commits as
|
|
2766
|
+
* one change.
|
|
2767
|
+
*/
|
|
2768
|
+
export type StateVersion = readonly { peer: string; counter: number }[];
|
|
2769
|
+
|
|
2770
|
+
function sameStateVersion(a: StateVersion, b: StateVersion): boolean {
|
|
2771
|
+
if (a.length !== b.length) return false;
|
|
2772
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
2773
|
+
if (a[i]!.peer !== b[i]!.peer || a[i]!.counter !== b[i]!.counter) {
|
|
2774
|
+
return false;
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
return true;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
export interface PagedRootHooks {
|
|
2781
|
+
// Whether the root is paged.
|
|
2782
|
+
isPaged(): boolean;
|
|
2783
|
+
// Mark this root paged. Called when a local write pushes an unpaged root
|
|
2784
|
+
// past the threshold.
|
|
2785
|
+
page(): void;
|
|
2786
|
+
// A token unique to this writer, naming the page keys it may write.
|
|
2787
|
+
sessionToken(): string;
|
|
2788
|
+
// The document's version, for a cached index to check itself against.
|
|
2789
|
+
stateVersion(): StateVersion;
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* Hide a handle's own fields from enumeration.
|
|
2794
|
+
*
|
|
2795
|
+
* A handle is a VIEW onto a container, not the container, so `Object.keys` on
|
|
2796
|
+
* one is a natural thing to reach for and it type-checks. Enumerable, it
|
|
2797
|
+
* answers with the handle's own fields, which look enough like record keys to
|
|
2798
|
+
* pass and are the same handful whatever the document holds, so the mistake
|
|
2799
|
+
* reads as a small document rather than as a mistake. Hidden, the answer is
|
|
2800
|
+
* empty, and the reader goes looking for `keys()`.
|
|
2801
|
+
*
|
|
2802
|
+
* Every field is declared with an initialiser, so they all exist by the time
|
|
2803
|
+
* this runs and a later assignment keeps the descriptor this sets.
|
|
2804
|
+
*/
|
|
2805
|
+
function hideOwnFields(handle: object): void {
|
|
2806
|
+
for (const key of Object.keys(handle)) {
|
|
2807
|
+
Object.defineProperty(handle, key, { enumerable: false });
|
|
1016
2808
|
}
|
|
1017
2809
|
}
|
|
1018
2810
|
|
|
1019
2811
|
class MapHandleImpl<T> implements MapHandle<T> {
|
|
1020
2812
|
private readonly _anchor: MapAnchor;
|
|
1021
2813
|
private readonly _owner: HandleOwner;
|
|
1022
|
-
//
|
|
1023
|
-
// never be a
|
|
1024
|
-
//
|
|
1025
|
-
private readonly
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
// `
|
|
1029
|
-
private
|
|
2814
|
+
// How this root reaches its store's paging state. `null` for handles that
|
|
2815
|
+
// can never be a paged root (composed child handles, and doc-less roots),
|
|
2816
|
+
// which skip the paged paths entirely.
|
|
2817
|
+
private readonly _paging: PagedRootHooks | null;
|
|
2818
|
+
// Writes this session's own dense pages and indexes the store's records.
|
|
2819
|
+
// Built on first use over a paged store and kept for the handle's life; see
|
|
2820
|
+
// `SessionPager`.
|
|
2821
|
+
private _pager: SessionPager | null = null;
|
|
2822
|
+
// Memo for this handle's keyed reads over a paged store; see `PageMemo`.
|
|
2823
|
+
private readonly _pageMemo = new PageMemo();
|
|
1030
2824
|
private readonly _childMaps = new Map<string, MapHandleImpl<unknown>>();
|
|
1031
2825
|
private readonly _childLists = new Map<string, ListHandleImpl<unknown>>();
|
|
1032
2826
|
// Active subscriptions, tracked so a version checkout (which swaps the
|
|
@@ -1039,82 +2833,132 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1039
2833
|
// matching `_resync` to report the keys the source swap removed. Null
|
|
1040
2834
|
// outside a rebind cycle, so a bare `_resync` reports no removals.
|
|
1041
2835
|
private _priorKeys: Set<string> | null = null;
|
|
2836
|
+
// The handle this one is composed off, or null for a root. A subscription
|
|
2837
|
+
// taken before the container exists watches up this chain for the write
|
|
2838
|
+
// that creates it (`_nearestLiveContainer`).
|
|
2839
|
+
private readonly _parent: MapHandleImpl<unknown> | null;
|
|
1042
2840
|
|
|
1043
2841
|
constructor(
|
|
1044
2842
|
anchor: MapAnchor,
|
|
1045
2843
|
owner: HandleOwner,
|
|
1046
|
-
|
|
1047
|
-
|
|
2844
|
+
paging: PagedRootHooks | null = null,
|
|
2845
|
+
parent: MapHandleImpl<unknown> | null = null,
|
|
1048
2846
|
) {
|
|
1049
2847
|
this._anchor = anchor;
|
|
1050
2848
|
this._owner = owner;
|
|
1051
|
-
this.
|
|
1052
|
-
this.
|
|
2849
|
+
this._paging = paging;
|
|
2850
|
+
this._parent = parent;
|
|
2851
|
+
hideOwnFields(this);
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// The container at the top of this handle's composition chain: the root it
|
|
2855
|
+
// is composed off, which the document holds for its whole life (`getMap` is
|
|
2856
|
+
// get-or-create, and a root anchor always resolves). A composed handle's
|
|
2857
|
+
// subscription watches it rather than its nearest live ancestor, which a
|
|
2858
|
+
// delete can take away. Internal: `ListHandleImpl` walks the same chain from
|
|
2859
|
+
// its parent.
|
|
2860
|
+
_chainRoot(): LoroMap | null {
|
|
2861
|
+
return this._parent ? this._parent._chainRoot() : this._anchor.resolve();
|
|
1053
2862
|
}
|
|
1054
2863
|
|
|
1055
|
-
// Whether the map is a PAGED record store. Defined by `meta
|
|
1056
|
-
// (set
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
2864
|
+
// Whether the map is a PAGED record store. Defined by the root's own `meta`
|
|
2865
|
+
// marker (set once when the root is paged, never changed by a field edit),
|
|
2866
|
+
// read through the hooks in O(1). MUST stay O(1): it is on the hot path of every
|
|
2867
|
+
// `read` / `mutate` (twice per ordered-map edit), so scanning the map to
|
|
2868
|
+
// peek tags would make a single field edit O(file).
|
|
1060
2869
|
private _isPaged(): boolean {
|
|
1061
|
-
return this.
|
|
2870
|
+
return this._paging !== null && this._paging.isPaged();
|
|
1062
2871
|
}
|
|
1063
2872
|
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
2873
|
+
// This handle's pager, over a store known to be paged. A page is filled in
|
|
2874
|
+
// creation order, so the index is what locates a record; it is refreshed
|
|
2875
|
+
// against the map here, and every entry point that resolves a record reads a
|
|
2876
|
+
// current one.
|
|
2877
|
+
private _sessionPager(m: LoroMap): SessionPager {
|
|
2878
|
+
this._pager ??= new SessionPager(this._paging!);
|
|
2879
|
+
this._pager.ensureIndex(m);
|
|
2880
|
+
return this._pager;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// The plain object this handle's key holds, when it holds a value rather
|
|
2884
|
+
// than a container. A record body above the paging threshold is stored that
|
|
2885
|
+
// way, so a scoped handle onto one must read it rather than report the key
|
|
2886
|
+
// empty; the alternative is a handle that answers `{}` over data the parent
|
|
2887
|
+
// map returns in full.
|
|
2888
|
+
private _plainBody(): Record<string, unknown> | undefined {
|
|
2889
|
+
const v = this._anchor.plain?.();
|
|
2890
|
+
return isPlainObject(v) ? v : undefined;
|
|
1075
2891
|
}
|
|
1076
2892
|
|
|
1077
2893
|
read(): Record<string, T> {
|
|
2894
|
+
this._owner.assertReadable();
|
|
1078
2895
|
const m = this._anchor.resolve();
|
|
1079
|
-
if (!m) return {}
|
|
2896
|
+
if (!m) return (this._plainBody() ?? {}) as Record<string, T>;
|
|
1080
2897
|
// A paged map flattens back to the flat lookup, so a read is identical
|
|
1081
2898
|
// paged or not.
|
|
1082
|
-
if (this._isPaged()) return
|
|
2899
|
+
if (this._isPaged()) return this._readPaged(m);
|
|
1083
2900
|
return m.toJSON() as Record<string, T>;
|
|
1084
2901
|
}
|
|
1085
2902
|
|
|
2903
|
+
// The whole-store read, which decodes every page. That walk answers the same
|
|
2904
|
+
// question a keyed read asks (which page holds which record), so the index
|
|
2905
|
+
// takes its answers from it and a `get` after a read costs one page rather
|
|
2906
|
+
// than the store.
|
|
2907
|
+
private _readPaged(m: LoroMap): Record<string, T> {
|
|
2908
|
+
this._pager ??= new SessionPager(this._paging!);
|
|
2909
|
+
const sink = this._pager.beginHarvest();
|
|
2910
|
+
if (sink === null) return flattenPagedMap(m) as Record<string, T>;
|
|
2911
|
+
const flat = flattenPagedMap(m, sink) as Record<string, T>;
|
|
2912
|
+
this._pager.endHarvest();
|
|
2913
|
+
return flat;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
1086
2916
|
get(entryKey: string): T | undefined {
|
|
2917
|
+
this._owner.assertReadable();
|
|
1087
2918
|
const m = this._anchor.resolve();
|
|
1088
|
-
if (!m) return undefined;
|
|
2919
|
+
if (!m) return this._plainBody()?.[entryKey] as T | undefined;
|
|
1089
2920
|
if (this._isPaged()) {
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
2921
|
+
const pager = this._sessionPager(m);
|
|
2922
|
+
return recordFromPage(
|
|
2923
|
+
m,
|
|
2924
|
+
entryKey,
|
|
2925
|
+
this._pageMemo,
|
|
2926
|
+
pager.liveIndex,
|
|
2927
|
+
pager.patches(m),
|
|
2928
|
+
) as T | undefined;
|
|
1098
2929
|
}
|
|
1099
|
-
|
|
1100
|
-
if (v === undefined) return undefined;
|
|
1101
|
-
if (v instanceof LoroMap) return v.toJSON() as T;
|
|
1102
|
-
if (v instanceof LoroList) return v.toJSON() as T;
|
|
1103
|
-
return v as T;
|
|
2930
|
+
return plainEntry(m, entryKey) as T | undefined;
|
|
1104
2931
|
}
|
|
1105
2932
|
|
|
1106
2933
|
keys(): string[] {
|
|
2934
|
+
return [...this.liveKeySet()];
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
/**
|
|
2938
|
+
* The keys this map holds, as a set, without materialising a record.
|
|
2939
|
+
*
|
|
2940
|
+
* A paged store answers from the page index, which is built from each page's
|
|
2941
|
+
* key list and never decodes a body, so asking which keys exist costs a
|
|
2942
|
+
* fraction of asking what they hold. Everything that needs membership rather
|
|
2943
|
+
* than content goes through here.
|
|
2944
|
+
*/
|
|
2945
|
+
liveKeySet(): Set<string> {
|
|
2946
|
+
this._owner.assertReadable();
|
|
1107
2947
|
const m = this._anchor.resolve();
|
|
1108
|
-
if (!m) return
|
|
1109
|
-
if (this._isPaged()) return
|
|
1110
|
-
return m.keys();
|
|
2948
|
+
if (!m) return new Set(Object.keys(this._plainBody() ?? {}));
|
|
2949
|
+
if (this._isPaged()) return this._sessionPager(m).ensureIndex(m).liveKeys();
|
|
2950
|
+
return new Set(m.keys());
|
|
1111
2951
|
}
|
|
1112
2952
|
|
|
1113
2953
|
has(entryKey: string): boolean {
|
|
2954
|
+
this._owner.assertReadable();
|
|
1114
2955
|
const m = this._anchor.resolve();
|
|
1115
|
-
if (!m)
|
|
2956
|
+
if (!m) {
|
|
2957
|
+
const body = this._plainBody();
|
|
2958
|
+
return body !== undefined && Object.prototype.hasOwnProperty.call(body, entryKey);
|
|
2959
|
+
}
|
|
1116
2960
|
if (this._isPaged()) return this.get(entryKey) !== undefined;
|
|
1117
|
-
return m
|
|
2961
|
+
return entryExists(m, entryKey);
|
|
1118
2962
|
}
|
|
1119
2963
|
|
|
1120
2964
|
mutate(fn: (draft: Record<string, T>) => void): void {
|
|
@@ -1139,50 +2983,337 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1139
2983
|
batcher?: MutateBatcher,
|
|
1140
2984
|
): void {
|
|
1141
2985
|
const m = this._anchor.resolveOrCreate();
|
|
2986
|
+
this._pageIfOutgrown(m);
|
|
1142
2987
|
if (this._isPaged()) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
// record into the wrong page. Refuse loudly rather than corrupt it; the
|
|
1146
|
-
// read path stays best-effort (only a persisted edit can corrupt).
|
|
1147
|
-
if (this._pagingScheme && !isPagingSchemeEditable(this._pagingScheme())) {
|
|
1148
|
-
throw new BrassError(
|
|
1149
|
-
'This document was saved in a newer storage format. Update this app to the latest @brass-build/client to edit it.',
|
|
1150
|
-
{ status: 426 },
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
mutatePaged(m, this._pageCount!(), fn);
|
|
2988
|
+
const pager = this._sessionPager(m);
|
|
2989
|
+
mutatePaged(m, fn, pager, batcher);
|
|
1154
2990
|
return;
|
|
1155
2991
|
}
|
|
1156
|
-
const
|
|
2992
|
+
const divert = this._paging ? this._pagingDivert(m, batcher) : null;
|
|
2993
|
+
const proxy = wrapLoroMapProxy(
|
|
2994
|
+
m,
|
|
2995
|
+
batcher,
|
|
2996
|
+
divert?.hooks,
|
|
2997
|
+
this._paging !== null,
|
|
2998
|
+
) as Record<string, T>;
|
|
1157
2999
|
fn(proxy);
|
|
3000
|
+
divert?.finish();
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
// Route this mutation's ADDITIONS into pages from the moment the map
|
|
3004
|
+
// outgrows the threshold, rather than only from the next mutation.
|
|
3005
|
+
//
|
|
3006
|
+
// One `mutate` is allowed to be as large as an app likes (it commits as it
|
|
3007
|
+
// goes past a size budget), so the mutation that fills a map is routinely
|
|
3008
|
+
// the same one that pushes it past the threshold, and a map checked only on
|
|
3009
|
+
// the way in would never page at all for an app that writes in one call.
|
|
3010
|
+
//
|
|
3011
|
+
// Only additions divert. A key the map already stores is an edit of a record
|
|
3012
|
+
// this writer does not own, and moving it would mean deleting a container a
|
|
3013
|
+
// peer may be writing to at that moment.
|
|
3014
|
+
private _pagingDivert(
|
|
3015
|
+
m: LoroMap,
|
|
3016
|
+
batcher?: MutateBatcher,
|
|
3017
|
+
): { hooks: WriteDivert; finish: () => void } {
|
|
3018
|
+
// Additions waiting to be sealed into a page, in creation order. A page
|
|
3019
|
+
// packs whatever value a record is, and `writeRecordContainer` writes an
|
|
3020
|
+
// object as fields and anything else as the value it is, so a buffer here
|
|
3021
|
+
// carries the app's value through whichever of the two it ends up taking.
|
|
3022
|
+
const held = new Map<string, FlatRecord>();
|
|
3023
|
+
// Additions taken before this mutation is known to be bulk. They are held
|
|
3024
|
+
// rather than written as a container each, because a container written now
|
|
3025
|
+
// is one this write can never take back: nothing already stored moves, so
|
|
3026
|
+
// the first `UNPAGED_MAX_RECORDS` records of a bulk write would stay
|
|
3027
|
+
// containers for the life of the document. Holding them costs their size
|
|
3028
|
+
// until the decision and lets the whole write be paged.
|
|
3029
|
+
//
|
|
3030
|
+
// Holding is not the fold that is unsafe. A record created inside a
|
|
3031
|
+
// mutation that is still running has never been visible to a peer, so
|
|
3032
|
+
// nothing is deleted and nothing is raced; the choice is only which shape
|
|
3033
|
+
// to write it in.
|
|
3034
|
+
const pending = new Map<string, FlatRecord>();
|
|
3035
|
+
// Additions that must take a container of their own instead: a key this
|
|
3036
|
+
// mutation deleted out of a sealed page keeps its tombstone (the page
|
|
3037
|
+
// cannot be rewritten to drop the old copy), and the container is what
|
|
3038
|
+
// outranks both. Paging it again would leave two pages claiming the key.
|
|
3039
|
+
const contained = new Map<string, FlatRecord>();
|
|
3040
|
+
// Keys the mutation deleted that a sealed page still holds. A page cannot
|
|
3041
|
+
// be rewritten to drop one, so each becomes a tombstone at the end.
|
|
3042
|
+
const removed = new Set<string>();
|
|
3043
|
+
const memo = new PageMemo();
|
|
3044
|
+
let pager: SessionPager | null = null;
|
|
3045
|
+
|
|
3046
|
+
// The record count this mutation will leave behind: what the map holds
|
|
3047
|
+
// plus what is held out of it. Counting only the map would never reach the
|
|
3048
|
+
// threshold once records are held rather than written.
|
|
3049
|
+
const totalRecords = (): number => m.size + pending.size + held.size;
|
|
3050
|
+
|
|
3051
|
+
const startPaging = (): boolean => {
|
|
3052
|
+
if (pager) return true;
|
|
3053
|
+
if (totalRecords() <= UNPAGED_MAX_RECORDS) return false;
|
|
3054
|
+
this._paging!.page();
|
|
3055
|
+
pager = this._sessionPager(m);
|
|
3056
|
+
// Built while the map is still all containers, so this is a walk of the
|
|
3057
|
+
// keys rather than a decode of every page.
|
|
3058
|
+
pager.ensureIndex(m);
|
|
3059
|
+
// Everything held back before the threshold was crossed goes into pages
|
|
3060
|
+
// with the rest, which is the whole point of having held it.
|
|
3061
|
+
for (const [key, record] of pending) held.set(key, record);
|
|
3062
|
+
pending.clear();
|
|
3063
|
+
return true;
|
|
3064
|
+
};
|
|
3065
|
+
|
|
3066
|
+
// A record this mutation already sealed into a page. Once sealed it is no
|
|
3067
|
+
// longer in `held` and not in the live map either, so without this the
|
|
3068
|
+
// mutation's own later read of it comes back undefined and its own later
|
|
3069
|
+
// delete of it silently does nothing.
|
|
3070
|
+
const sealedRecord = (key: string): FlatRecord | undefined => {
|
|
3071
|
+
const index = pager?.liveIndex;
|
|
3072
|
+
const pageKey = index?.readsFromPage(key);
|
|
3073
|
+
if (pageKey === undefined) return undefined;
|
|
3074
|
+
return memo.resolve(pageKey, plainEntry(m, pageKey)).get(key);
|
|
3075
|
+
};
|
|
3076
|
+
|
|
3077
|
+
// Seal whole pages out of what is held, leaving any remainder for the next
|
|
3078
|
+
// boundary. Runs at each commit the batcher takes, so a mutation of any
|
|
3079
|
+
// size ships its pages as it goes instead of as one op at the end.
|
|
3080
|
+
const sealPages = (): void => {
|
|
3081
|
+
if (!pager || held.size === 0) return;
|
|
3082
|
+
const records = [...held];
|
|
3083
|
+
const perPage = recordsPerPage(sampleBodyBytes(records, pagedBodyOf));
|
|
3084
|
+
const written = pager.writePages(m, records, perPage);
|
|
3085
|
+
const sealed: string[] = [];
|
|
3086
|
+
for (let i = 0; i < written; i += 1) {
|
|
3087
|
+
sealed.push(records[i]![0]);
|
|
3088
|
+
held.delete(records[i]![0]);
|
|
3089
|
+
}
|
|
3090
|
+
// Named to the commit this seal runs just ahead of, which is what lets
|
|
3091
|
+
// an ordered map append the order entries for these records alongside
|
|
3092
|
+
// them instead of appending the whole list at the first commit. The
|
|
3093
|
+
// batcher is not passed to `writePages` above, since charging a page
|
|
3094
|
+
// there would take a commit inside a commit.
|
|
3095
|
+
batcher?.noteKeys(sealed);
|
|
3096
|
+
};
|
|
3097
|
+
batcher?.onFlush(sealPages);
|
|
3098
|
+
|
|
3099
|
+
const hooks: WriteDivert = {
|
|
3100
|
+
take: (key, value) => {
|
|
3101
|
+
// A key this mutation is already buffering keeps its buffer, whatever
|
|
3102
|
+
// shape the new value is. Declining it would write to the map behind
|
|
3103
|
+
// the buffer, which `finish` would then overwrite with the value the
|
|
3104
|
+
// buffer still held.
|
|
3105
|
+
if (held.has(key)) {
|
|
3106
|
+
held.set(key, toPlainDeep(value));
|
|
3107
|
+
return true;
|
|
3108
|
+
}
|
|
3109
|
+
if (contained.has(key)) {
|
|
3110
|
+
contained.set(key, toPlainDeep(value));
|
|
3111
|
+
return true;
|
|
3112
|
+
}
|
|
3113
|
+
if (pending.has(key)) {
|
|
3114
|
+
pending.set(key, toPlainDeep(value));
|
|
3115
|
+
return true;
|
|
3116
|
+
}
|
|
3117
|
+
// An existing key is an edit, and an edit keeps whatever shape the
|
|
3118
|
+
// record already has, so it is never held.
|
|
3119
|
+
if (m.get(key) !== undefined) return false;
|
|
3120
|
+
if (!startPaging()) {
|
|
3121
|
+
// Only a RECORD is held. A map entry that is a scalar, a string, an
|
|
3122
|
+
// array, or a container is not what paging packs, and holding one
|
|
3123
|
+
// would hand `finish` a value it can only write as a record: a
|
|
3124
|
+
// boolean comes back as an empty container. Those keep the write
|
|
3125
|
+
// they have always had.
|
|
3126
|
+
if (!isPlainObject(value)) return false;
|
|
3127
|
+
// Undecided: hold the addition and re-ask, since this one may be
|
|
3128
|
+
// what crosses the threshold.
|
|
3129
|
+
pending.set(key, toPlainDeep(value));
|
|
3130
|
+
startPaging();
|
|
3131
|
+
return true;
|
|
3132
|
+
}
|
|
3133
|
+
// A key this mutation already sealed into a page, or deleted out of
|
|
3134
|
+
// one, takes a container of its own. The page keeps its copy either
|
|
3135
|
+
// way, so declining the write would leave the record stored twice: the
|
|
3136
|
+
// map's own key beside the page's stale copy, which a keyed read and a
|
|
3137
|
+
// streaming read then answer differently.
|
|
3138
|
+
if (pager!.packedInPage(key) || removed.has(key)) {
|
|
3139
|
+
contained.set(key, toPlainDeep(value));
|
|
3140
|
+
return true;
|
|
3141
|
+
}
|
|
3142
|
+
// A container already answers for this key (a record written before
|
|
3143
|
+
// the crossing), so the write goes into it as an ordinary edit.
|
|
3144
|
+
if (pager!.holds(key)) return false;
|
|
3145
|
+
held.set(key, toPlainDeep(value));
|
|
3146
|
+
return true;
|
|
3147
|
+
},
|
|
3148
|
+
holds: (key) => {
|
|
3149
|
+
if (held.has(key) || contained.has(key) || pending.has(key)) return true;
|
|
3150
|
+
if (removed.has(key)) return false;
|
|
3151
|
+
return sealedRecord(key) !== undefined;
|
|
3152
|
+
},
|
|
3153
|
+
peek: (key) => {
|
|
3154
|
+
if (pending.has(key)) return pending.get(key);
|
|
3155
|
+
if (held.has(key)) return held.get(key);
|
|
3156
|
+
if (contained.has(key)) return contained.get(key);
|
|
3157
|
+
return removed.has(key) ? undefined : sealedRecord(key);
|
|
3158
|
+
},
|
|
3159
|
+
drop: (key) => {
|
|
3160
|
+
held.delete(key);
|
|
3161
|
+
pending.delete(key);
|
|
3162
|
+
contained.delete(key);
|
|
3163
|
+
if (sealedRecord(key) !== undefined) removed.add(key);
|
|
3164
|
+
},
|
|
3165
|
+
keys: () => {
|
|
3166
|
+
const keys = new Set([
|
|
3167
|
+
...held.keys(),
|
|
3168
|
+
...pending.keys(),
|
|
3169
|
+
...contained.keys(),
|
|
3170
|
+
]);
|
|
3171
|
+
for (const key of removed) {
|
|
3172
|
+
if (!contained.has(key)) keys.delete(key);
|
|
3173
|
+
}
|
|
3174
|
+
return [...keys];
|
|
3175
|
+
},
|
|
3176
|
+
};
|
|
3177
|
+
|
|
3178
|
+
return {
|
|
3179
|
+
hooks,
|
|
3180
|
+
finish: () => {
|
|
3181
|
+
// The mutation ended below the threshold, so the store stays unpaged
|
|
3182
|
+
// and these take exactly the shape the undiverted write gives them: a
|
|
3183
|
+
// container under their own key, filled by `assignPlain`, which is
|
|
3184
|
+
// what makes a nested object a container a scoped handle can anchor
|
|
3185
|
+
// on. `writeRecordContainer` writes fields as plain values instead,
|
|
3186
|
+
// so a record written through it has no `props` container for
|
|
3187
|
+
// `handle.map('props')` to reach.
|
|
3188
|
+
//
|
|
3189
|
+
// Charged HERE rather than where the draft took them, because holding
|
|
3190
|
+
// is what decides that a record's bytes land in this commit and not an
|
|
3191
|
+
// earlier one: a write charged on the way in commits its neighbours
|
|
3192
|
+
// while its own value is still held, so a mutation that holds
|
|
3193
|
+
// everything would spend its boundaries on empty commits and then put
|
|
3194
|
+
// every record in the one op at the end.
|
|
3195
|
+
for (const [key, record] of pending) {
|
|
3196
|
+
// Only an OBJECT takes a container filled field by field. A buffered
|
|
3197
|
+
// key is re-taken whatever shape the new value is, so a rewrite can
|
|
3198
|
+
// leave a string, a number, or an array here, and `assignPlain`
|
|
3199
|
+
// walks a value's entries into fields: it would store `'hello'` as
|
|
3200
|
+
// one field per character and `5` as an empty container, losing what
|
|
3201
|
+
// the app assigned. `assignEntry` is the same write the map makes
|
|
3202
|
+
// for a key the divert declined, so the two shapes agree.
|
|
3203
|
+
if (isPlainObject(record)) {
|
|
3204
|
+
const existing = m.get(key);
|
|
3205
|
+
const sub =
|
|
3206
|
+
existing instanceof LoroMap
|
|
3207
|
+
? existing
|
|
3208
|
+
: m.setContainer(key, new LoroMap());
|
|
3209
|
+
assignPlain(sub, record);
|
|
3210
|
+
} else {
|
|
3211
|
+
assignEntry(m, key, record);
|
|
3212
|
+
}
|
|
3213
|
+
batcher?.noteKeys([key]);
|
|
3214
|
+
batcher?.record(record);
|
|
3215
|
+
}
|
|
3216
|
+
pending.clear();
|
|
3217
|
+
sealPages();
|
|
3218
|
+
// Every write below is charged the same way the draft's own
|
|
3219
|
+
// assignments are, because the charge the draft took paid for BUFFERING
|
|
3220
|
+
// the value, not for writing it. These loops run after the last
|
|
3221
|
+
// boundary the mutation took, so a route that only names its keys puts
|
|
3222
|
+
// its whole write in the final commit however much it holds:
|
|
3223
|
+
// `contained` grows with the records a mutation rewrites after sealing
|
|
3224
|
+
// them, and at 50,000 that op reaches 12 MiB, past what the document
|
|
3225
|
+
// can store at all.
|
|
3226
|
+
//
|
|
3227
|
+
// The remainder is taken out of `held` first, so a commit these charges
|
|
3228
|
+
// trigger runs `sealPages` over an empty buffer rather than over the
|
|
3229
|
+
// map this loop is walking.
|
|
3230
|
+
const remainder = [...held];
|
|
3231
|
+
held.clear();
|
|
3232
|
+
// A remainder shorter than a page takes a container each, which is
|
|
3233
|
+
// what it would have had anyway.
|
|
3234
|
+
for (const [key, record] of remainder) {
|
|
3235
|
+
batcher?.record(record);
|
|
3236
|
+
writeRecordContainer(m, key, record);
|
|
3237
|
+
pager!.liveIndex?.noteContained(key);
|
|
3238
|
+
batcher?.noteKeys([key]);
|
|
3239
|
+
}
|
|
3240
|
+
for (const key of removed) {
|
|
3241
|
+
if (contained.has(key)) continue;
|
|
3242
|
+
batcher?.recordDelete(key);
|
|
3243
|
+
markDeleted(m, key);
|
|
3244
|
+
pager!.liveIndex?.noteRemoved(key);
|
|
3245
|
+
batcher?.noteKeys([key]);
|
|
3246
|
+
}
|
|
3247
|
+
removed.clear();
|
|
3248
|
+
for (const [key, record] of contained) {
|
|
3249
|
+
batcher?.record(record);
|
|
3250
|
+
writeRecordContainer(m, key, record);
|
|
3251
|
+
pager!.liveIndex?.noteContained(key);
|
|
3252
|
+
batcher?.noteKeys([key]);
|
|
3253
|
+
}
|
|
3254
|
+
contained.clear();
|
|
3255
|
+
},
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// Page this root once it has outgrown the one-container-per-record shape.
|
|
3260
|
+
//
|
|
3261
|
+
// The cost of reading a map back rises faster than its entry count (measured
|
|
3262
|
+
// at ~12us an entry at 12,500 entries and ~145us at 200,000), so a map an app
|
|
3263
|
+
// keeps filling eventually costs minutes to read on the reload after it is
|
|
3264
|
+
// written. Paging is what holds the entry count down, and an app should not
|
|
3265
|
+
// have to ask for it, so the threshold is checked here: on the write that
|
|
3266
|
+
// crosses it, the root is stamped paged and everything added from then on is
|
|
3267
|
+
// written into pages.
|
|
3268
|
+
//
|
|
3269
|
+
// Nothing already stored moves. A record in its own container reads as a
|
|
3270
|
+
// stray, which already outranks a page, so leaving them costs one entry each
|
|
3271
|
+
// and no correctness. Moving them would mean deleting containers a peer may
|
|
3272
|
+
// be editing at that moment, and a delete concurrent with an edit discards
|
|
3273
|
+
// the edit.
|
|
3274
|
+
private _pageIfOutgrown(m: LoroMap): void {
|
|
3275
|
+
if (!this._paging || this._isPaged()) return;
|
|
3276
|
+
if (m.size <= UNPAGED_MAX_RECORDS) return;
|
|
3277
|
+
this._paging.page();
|
|
1158
3278
|
}
|
|
1159
3279
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
return
|
|
3280
|
+
/** @internal How many times this handle's page index has been rebuilt, for a
|
|
3281
|
+
* test to pin that an ordinary edit does not force one. A rebuild decodes
|
|
3282
|
+
* every page, so the count is the difference between an edit costing a
|
|
3283
|
+
* record and one costing the store. */
|
|
3284
|
+
_pageIndexRebuilds(): number {
|
|
3285
|
+
return this._pager?.indexRebuilds ?? 0;
|
|
1166
3286
|
}
|
|
1167
3287
|
|
|
1168
|
-
// A predicate over the map
|
|
1169
|
-
// caller checking many keys doesn't re-scan per key. The
|
|
1170
|
-
// primitive uses it to decide, per set, whether a key is new
|
|
1171
|
-
// be appended to the order list) without paying O(map) per
|
|
3288
|
+
// A predicate over the key set the map held when this was taken, resolved
|
|
3289
|
+
// ONCE so a caller checking many keys doesn't re-scan per key. The
|
|
3290
|
+
// ordered-map primitive uses it to decide, per set, whether a key is new
|
|
3291
|
+
// (and so must be appended to the order list) without paying O(map) per
|
|
3292
|
+
// added key.
|
|
3293
|
+
//
|
|
3294
|
+
// "When this was taken" is the whole contract, and it is why the unpaged
|
|
3295
|
+
// branch copies the key set rather than asking the live map per call. A
|
|
3296
|
+
// mutation's own writes reach that map as they are made, so a live predicate
|
|
3297
|
+
// reports a key by what this batch has done to it so far: a key deleted and
|
|
3298
|
+
// re-added reads absent and is appended a second time, and a key added
|
|
3299
|
+
// through the paging divert (which holds additions back) reads absent even
|
|
3300
|
+
// after the batch has written it. The caller tracks the batch's own
|
|
3301
|
+
// additions and removals on top of a start-of-batch answer, which is what
|
|
3302
|
+
// the paged branch already gives it.
|
|
1172
3303
|
_existsChecker(): (key: string) => boolean {
|
|
1173
3304
|
const m = this._anchor.resolve();
|
|
1174
3305
|
if (!m) return () => false;
|
|
1175
3306
|
if (this._isPaged()) {
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
}
|
|
1182
|
-
const pageCount = this._pageCount!();
|
|
1183
|
-
return (key) => recordFromPage(m, key, pageCount) !== undefined;
|
|
3307
|
+
// The index answers from one walk of the pages' key lists instead of a
|
|
3308
|
+
// page decode per key, which is what keeps a bulk insert through the
|
|
3309
|
+
// ordered map off an O(keys x page) path.
|
|
3310
|
+
const index = this._sessionPager(m).ensureIndex(m);
|
|
3311
|
+
return (key) => index.has(key);
|
|
1184
3312
|
}
|
|
1185
|
-
|
|
3313
|
+
// One walk of an unpaged map, whose size the paging threshold bounds, and
|
|
3314
|
+
// no wasm handle minted per key the way a per-key read would.
|
|
3315
|
+
const keys = new Set(m.keys());
|
|
3316
|
+
return (key) => keys.has(key);
|
|
1186
3317
|
}
|
|
1187
3318
|
|
|
1188
3319
|
// Fire a subscriber once with the whole map as `change.set`, so a
|
|
@@ -1197,54 +3328,121 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1197
3328
|
// source swap but are gone after it: a checkout to a version with fewer
|
|
1198
3329
|
// records must tell a delta-reconcile subscriber to drop those keys, or they
|
|
1199
3330
|
// linger as phantom records in the preview. `_resync` computes it.
|
|
3331
|
+
// `snapshot` is the map as a caller has ALREADY read it, so a tracker that
|
|
3332
|
+
// read the entry to decide whether to fire does not read it a second time to
|
|
3333
|
+
// fire it.
|
|
1200
3334
|
private _fireWhole(
|
|
1201
3335
|
cb: InternalMapSubscriber<T>,
|
|
1202
3336
|
deleted: string[] = [],
|
|
3337
|
+
snapshot?: Record<string, T>,
|
|
1203
3338
|
): void {
|
|
1204
3339
|
try {
|
|
1205
3340
|
cb(
|
|
1206
|
-
{
|
|
3341
|
+
{
|
|
3342
|
+
set: new Map<string, T>(Object.entries(snapshot ?? this.read())),
|
|
3343
|
+
deleted,
|
|
3344
|
+
},
|
|
1207
3345
|
undefined,
|
|
1208
3346
|
true,
|
|
1209
3347
|
);
|
|
1210
|
-
} catch {
|
|
1211
|
-
|
|
3348
|
+
} catch (err) {
|
|
3349
|
+
reportSubscriberThrow(err);
|
|
1212
3350
|
}
|
|
1213
3351
|
}
|
|
1214
3352
|
|
|
1215
|
-
// Attach the
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
3353
|
+
// Attach the change subscription for `cb`, returning its detach.
|
|
3354
|
+
//
|
|
3355
|
+
// A ROOT handle binds straight to its container: that container is the
|
|
3356
|
+
// document's for its whole life, so nothing can move out from under the
|
|
3357
|
+
// subscription except a source swap, which `_resync` re-runs this for.
|
|
3358
|
+
//
|
|
3359
|
+
// A COMPOSED handle's entry is not that stable. Which container answers for
|
|
3360
|
+
// it changes as the entry is deleted and written again, and over a paged
|
|
3361
|
+
// record store it can be nothing at all while an edit sits beside the pages,
|
|
3362
|
+
// so its subscription tracks the entry instead of binding once (see
|
|
3363
|
+
// `trackEntryContainer`).
|
|
1219
3364
|
private _attachChange(cb: InternalMapSubscriber<T>): () => void {
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
//
|
|
1225
|
-
|
|
3365
|
+
if (this._parent === null) {
|
|
3366
|
+
const m = this._anchor.resolve();
|
|
3367
|
+
return m === null ? () => {} : this._subscribeContainer(m, cb);
|
|
3368
|
+
}
|
|
3369
|
+
// The keys the subscriber has been told this entry holds, folded from
|
|
3370
|
+
// every change it has been handed. A re-bind names the ones the container
|
|
3371
|
+
// now answering does not carry: a subscriber folds `set` over `deleted`,
|
|
3372
|
+
// so a key it is never told is gone stays in its model and renders as a
|
|
3373
|
+
// field of a record that no longer has it.
|
|
3374
|
+
const known = new Set<string>(Object.keys(this.read()));
|
|
3375
|
+
const fold: InternalMapSubscriber<T> = (change, origin, resync) => {
|
|
3376
|
+
for (const key of change.deleted) known.delete(key);
|
|
3377
|
+
for (const key of change.set.keys()) known.add(key);
|
|
3378
|
+
cb(change, origin, resync);
|
|
3379
|
+
};
|
|
3380
|
+
return trackEntryContainer<LoroMap, Record<string, T>>(this._chainRoot(), {
|
|
3381
|
+
resolve: () => this._anchor.resolve(),
|
|
3382
|
+
read: () => this.read(),
|
|
3383
|
+
print: (value) => JSON.stringify(value),
|
|
3384
|
+
catchUp: (value) => {
|
|
3385
|
+
const deleted = [...known].filter((key) => !(key in value));
|
|
3386
|
+
this._fireWhole(fold, deleted, value);
|
|
3387
|
+
},
|
|
3388
|
+
attach: (m) => this._subscribeContainer(m, fold),
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
// Subscribe `cb` to one container's own changes, returning its detach. The
|
|
3393
|
+
// instance is captured here (Loro's subscribe is per-container), so
|
|
3394
|
+
// whatever re-binds calls this again against the container it re-binds to.
|
|
3395
|
+
private _subscribeContainer(
|
|
3396
|
+
m: LoroMap,
|
|
3397
|
+
cb: InternalMapSubscriber<T>,
|
|
3398
|
+
): () => void {
|
|
1226
3399
|
// Paged delta needs prior page state to spot deleted records, so index
|
|
1227
3400
|
// the pages once now; the index advances on every fire or its next delete
|
|
1228
3401
|
// diff is wrong. Unpaged maps read their delta off the Loro diff.
|
|
1229
|
-
|
|
1230
|
-
const
|
|
3402
|
+
let index = this._isPaged() ? buildPagedDeltaIndex(m) : null;
|
|
3403
|
+
const depth = containerDepth(m);
|
|
1231
3404
|
const sub = m.subscribe((event) => {
|
|
3405
|
+
// Which reading the delta takes is decided per BATCH, not once at
|
|
3406
|
+
// attach: a store crosses the paging threshold while its subscriptions
|
|
3407
|
+
// are live, and one attached before the crossing would otherwise keep
|
|
3408
|
+
// reporting the map's raw keys, naming pages and overlay slots as if
|
|
3409
|
+
// they were records and never naming a record added after it.
|
|
3410
|
+
//
|
|
3411
|
+
// The index holds the pages this subscriber has already been told
|
|
3412
|
+
// about, so a subscription that has been reading an unpaged store
|
|
3413
|
+
// starts from none: while the store was unpaged there were no pages,
|
|
3414
|
+
// and the batch that pages it names every page it creates. What the
|
|
3415
|
+
// store answers for from outside its pages is read off the map here,
|
|
3416
|
+
// because a store that paged remotely can arrive already carrying edits
|
|
3417
|
+
// and deletes this subscription has never seen a key for.
|
|
3418
|
+
if (index === null && this._isPaged()) {
|
|
3419
|
+
index = {
|
|
3420
|
+
pages: new Map<string, Set<string>>(),
|
|
3421
|
+
shadowed: shadowedRecords(m),
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
1232
3424
|
const change = (
|
|
1233
|
-
|
|
1234
|
-
? pagedMapDelta(m, event,
|
|
1235
|
-
: unpagedMapDelta(m,
|
|
3425
|
+
index
|
|
3426
|
+
? pagedMapDelta(m, event, index, (key) => this.get(key))
|
|
3427
|
+
: unpagedMapDelta(m, depth, event)
|
|
1236
3428
|
) as Change<T>;
|
|
1237
3429
|
const origin: ChangeOrigin = { source: this._owner.originOf(event) };
|
|
1238
3430
|
try {
|
|
1239
3431
|
cb(change, origin, false);
|
|
1240
|
-
} catch {
|
|
1241
|
-
|
|
3432
|
+
} catch (err) {
|
|
3433
|
+
reportSubscriberThrow(err);
|
|
1242
3434
|
}
|
|
1243
3435
|
});
|
|
1244
3436
|
return () => sub();
|
|
1245
3437
|
}
|
|
1246
3438
|
|
|
3439
|
+
onSettled(cb: () => void): HandleUnsubscribe {
|
|
3440
|
+
this._owner.assertReadable();
|
|
3441
|
+
return this._registry.onSettled(cb);
|
|
3442
|
+
}
|
|
3443
|
+
|
|
1247
3444
|
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe {
|
|
3445
|
+
this._owner.assertReadable();
|
|
1248
3446
|
// App subscribers see `(change, origin?)`; the internal re-sync flag is
|
|
1249
3447
|
// dropped here so it never leaks into the public surface.
|
|
1250
3448
|
return this._subscribeInternal((change, origin) => cb(change, origin));
|
|
@@ -1275,11 +3473,20 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1275
3473
|
}
|
|
1276
3474
|
|
|
1277
3475
|
// Capture the current key set of this handle and its composed child maps,
|
|
1278
|
-
// read against the
|
|
1279
|
-
// against the post-rebind source to find removed keys.
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
|
|
3476
|
+
// read against the source the handle currently describes, so `_resync` can
|
|
3477
|
+
// diff it against the post-rebind source to find removed keys. Child maps
|
|
3478
|
+
// resolve through this anchor, so they must be read now too.
|
|
3479
|
+
//
|
|
3480
|
+
// Reading keys depends on whether the store is paged, and a handle answers
|
|
3481
|
+
// that from the Document's ACTIVE source rather than from its own anchor, so
|
|
3482
|
+
// a correct snapshot needs both to still name the old source. The two move
|
|
3483
|
+
// separately across a checkout (the Document repoints the active source, then
|
|
3484
|
+
// rebinds the anchors), which is why `_snapshotRootHandleKeys` takes the
|
|
3485
|
+
// snapshot before EITHER moves and this is idempotent: the `_rebindAnchor`
|
|
3486
|
+
// call is the backstop for a rebind that arrives without that phase, and must
|
|
3487
|
+
// not overwrite a snapshot already taken against the right source.
|
|
3488
|
+
_snapshotPriorKeys(): void {
|
|
3489
|
+
if (this._priorKeys !== null) return;
|
|
1283
3490
|
this._priorKeys = new Set(Object.keys(this.read()));
|
|
1284
3491
|
for (const child of this._childMaps.values()) child._snapshotPriorKeys();
|
|
1285
3492
|
}
|
|
@@ -1296,6 +3503,9 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1296
3503
|
// container, and after a source swap that cache points at the old source.
|
|
1297
3504
|
// Prior keys were snapshotted pre-rebind, so this cannot lose them.
|
|
1298
3505
|
this._anchor.invalidate();
|
|
3506
|
+
// The index describes the container set of ONE source, and a checkout /
|
|
3507
|
+
// return-to-head swaps it.
|
|
3508
|
+
this._pager = null;
|
|
1299
3509
|
const prior = this._priorKeys;
|
|
1300
3510
|
this._priorKeys = null;
|
|
1301
3511
|
const currentKeys = new Set(Object.keys(this.read()));
|
|
@@ -1324,8 +3534,12 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1324
3534
|
map<U>(entryKey: string): MapHandle<U> {
|
|
1325
3535
|
let h = this._childMaps.get(entryKey);
|
|
1326
3536
|
if (!h) {
|
|
1327
|
-
|
|
1328
|
-
|
|
3537
|
+
h = new MapHandleImpl<unknown>(
|
|
3538
|
+
this._entryAnchor(entryKey, LoroMap),
|
|
3539
|
+
this._owner,
|
|
3540
|
+
null,
|
|
3541
|
+
this as MapHandleImpl<unknown>,
|
|
3542
|
+
);
|
|
1329
3543
|
this._childMaps.set(entryKey, h);
|
|
1330
3544
|
}
|
|
1331
3545
|
return h as unknown as MapHandle<U>;
|
|
@@ -1334,76 +3548,339 @@ class MapHandleImpl<T> implements MapHandle<T> {
|
|
|
1334
3548
|
list<U>(entryKey: string): ListHandle<U> {
|
|
1335
3549
|
let h = this._childLists.get(entryKey);
|
|
1336
3550
|
if (!h) {
|
|
1337
|
-
|
|
1338
|
-
|
|
3551
|
+
h = new ListHandleImpl<unknown>(
|
|
3552
|
+
this._entryAnchor(entryKey, LoroMovableList),
|
|
3553
|
+
this._owner,
|
|
3554
|
+
this as MapHandleImpl<unknown>,
|
|
3555
|
+
);
|
|
1339
3556
|
this._childLists.set(entryKey, h);
|
|
1340
3557
|
}
|
|
1341
3558
|
return h as unknown as ListHandle<U>;
|
|
1342
3559
|
}
|
|
3560
|
+
|
|
3561
|
+
/**
|
|
3562
|
+
* The anchor a scoped handle onto one ENTRY of this map reads and writes
|
|
3563
|
+
* through.
|
|
3564
|
+
*
|
|
3565
|
+
* On a root that can be a record store the entry is a RECORD, and where a
|
|
3566
|
+
* record is stored is a property of the store's size: above the paging
|
|
3567
|
+
* threshold it is packed into a page rather than held under its own key. An
|
|
3568
|
+
* anchor that resolves `parent[entryKey]` therefore finds nothing on a large
|
|
3569
|
+
* store, and creating a container there mints an EMPTY record that outranks
|
|
3570
|
+
* the page's copy, so `records.map(id)` destroys the record it was asked
|
|
3571
|
+
* for. The paged anchor below resolves through the same presentation every
|
|
3572
|
+
* other reader uses, and writes by taking the record over into a container
|
|
3573
|
+
* of its own beside the pages.
|
|
3574
|
+
*
|
|
3575
|
+
* A composed child (a record's `params`) holds fields rather than records
|
|
3576
|
+
* and takes the plain anchor: `_paging` is null there, so nothing below a
|
|
3577
|
+
* root ever reaches the paged path.
|
|
3578
|
+
*/
|
|
3579
|
+
private _entryAnchor<C extends LoroMap | LoroMovableList>(
|
|
3580
|
+
entryKey: string,
|
|
3581
|
+
ctor: new () => C,
|
|
3582
|
+
): {
|
|
3583
|
+
resolve(): C | null;
|
|
3584
|
+
resolveOrCreate(): C;
|
|
3585
|
+
invalidate(): void;
|
|
3586
|
+
plain(): unknown;
|
|
3587
|
+
} {
|
|
3588
|
+
// Where a record written before the store paged still sits, and where a
|
|
3589
|
+
// record taken over by an edit goes. Both cache their resolved container,
|
|
3590
|
+
// so a per-container subscription keeps a stable instance across reads.
|
|
3591
|
+
const direct = childAnchor(this._anchor, entryKey, ctor);
|
|
3592
|
+
if (this._paging === null) return direct;
|
|
3593
|
+
assertUsableRecordKey(entryKey);
|
|
3594
|
+
const overlay = childAnchor(this._anchor, overlayKeyFor(entryKey), ctor);
|
|
3595
|
+
|
|
3596
|
+
// Whether an edit written beside the pages is waiting to be folded over
|
|
3597
|
+
// this record. Asked only once a container has been found, since a record
|
|
3598
|
+
// with no container of its own is answered from the flattened value
|
|
3599
|
+
// either way. The patch index is a walk of the map's key list, so this
|
|
3600
|
+
// takes the pager WITHOUT the page index: learning which page holds each
|
|
3601
|
+
// record reads every page's key list, which no answer here needs.
|
|
3602
|
+
const patched = (m: LoroMap): boolean => {
|
|
3603
|
+
this._pager ??= new SessionPager(this._paging!);
|
|
3604
|
+
const keys = this._pager.patches(m).keysFor(entryKey);
|
|
3605
|
+
return keys !== undefined && keys.length > 0;
|
|
3606
|
+
};
|
|
3607
|
+
|
|
3608
|
+
return {
|
|
3609
|
+
resolve: () => {
|
|
3610
|
+
if (!this._isPaged()) return direct.resolve();
|
|
3611
|
+
const m = this._anchor.resolve();
|
|
3612
|
+
if (!m) return null;
|
|
3613
|
+
// A delete outranks every copy of the record, so a container a page
|
|
3614
|
+
// still shadows reads as absent here as it does everywhere else.
|
|
3615
|
+
if (entryExists(m, tombstoneKeyFor(entryKey))) return null;
|
|
3616
|
+
// Same precedence as `recordBeforePatches`: the edit beside the pages,
|
|
3617
|
+
// then the record under its own key.
|
|
3618
|
+
const own = overlay.resolve() ?? direct.resolve();
|
|
3619
|
+
if (own === null) return null;
|
|
3620
|
+
// A patch is folded over the record's own copy on every read, so while
|
|
3621
|
+
// one exists the container alone is not the record. Answering from it
|
|
3622
|
+
// would report a record the parent map does not.
|
|
3623
|
+
return patched(m) ? null : own;
|
|
3624
|
+
},
|
|
3625
|
+
resolveOrCreate: () => {
|
|
3626
|
+
if (!this._isPaged()) return direct.resolveOrCreate();
|
|
3627
|
+
const m = this._anchor.resolveOrCreate();
|
|
3628
|
+
const pager = this._sessionPager(m);
|
|
3629
|
+
const patchIndex = pager.patches(m);
|
|
3630
|
+
const patchKeys = patchIndex.keysFor(entryKey);
|
|
3631
|
+
const hadPatches = patchKeys !== undefined && patchKeys.length > 0;
|
|
3632
|
+
// The record as every other reader resolves it, taken before this
|
|
3633
|
+
// handle takes it over.
|
|
3634
|
+
const record = recordFromPage(
|
|
3635
|
+
m,
|
|
3636
|
+
entryKey,
|
|
3637
|
+
this._pageMemo,
|
|
3638
|
+
pager.liveIndex,
|
|
3639
|
+
patchIndex,
|
|
3640
|
+
);
|
|
3641
|
+
// From here the container IS the record, so the same bookkeeping a
|
|
3642
|
+
// whole-record write settles is settled here: a patch left behind
|
|
3643
|
+
// applies over the container on the next read and would undo whatever
|
|
3644
|
+
// this handle writes, and a delete marker left behind would hide it.
|
|
3645
|
+
dropPatches(m, patchIndex, entryKey);
|
|
3646
|
+
clearTombstone(m, entryKey);
|
|
3647
|
+
const existing = overlay.resolve() ?? direct.resolve();
|
|
3648
|
+
// A container that already holds the whole record is edited in place.
|
|
3649
|
+
// Seeding it again would rewrite every field, which costs the snapshot
|
|
3650
|
+
// an edit per field and hands a peer's concurrent field edit a
|
|
3651
|
+
// conflict this write has no reason to raise.
|
|
3652
|
+
if (existing !== null && !hadPatches) {
|
|
3653
|
+
pager.liveIndex?.noteContained(entryKey);
|
|
3654
|
+
return existing;
|
|
3655
|
+
}
|
|
3656
|
+
const target = existing ?? overlay.resolveOrCreate();
|
|
3657
|
+
if (target instanceof LoroMap) {
|
|
3658
|
+
if (isPlainObject(record)) assignPlain(target, record);
|
|
3659
|
+
} else if (Array.isArray(record)) {
|
|
3660
|
+
assignList(target as LoroMovableList, record);
|
|
3661
|
+
}
|
|
3662
|
+
pager.liveIndex?.noteContained(entryKey);
|
|
3663
|
+
return target;
|
|
3664
|
+
},
|
|
3665
|
+
// The record as the parent map reports it, for the reads that run when
|
|
3666
|
+
// no container answers: packed in a page, or held as one plain value.
|
|
3667
|
+
plain: () => {
|
|
3668
|
+
if (!this._isPaged()) return direct.plain();
|
|
3669
|
+
const m = this._anchor.resolve();
|
|
3670
|
+
if (!m) return undefined;
|
|
3671
|
+
const pager = this._sessionPager(m);
|
|
3672
|
+
return recordFromPage(
|
|
3673
|
+
m,
|
|
3674
|
+
entryKey,
|
|
3675
|
+
this._pageMemo,
|
|
3676
|
+
pager.liveIndex,
|
|
3677
|
+
pager.patches(m),
|
|
3678
|
+
);
|
|
3679
|
+
},
|
|
3680
|
+
invalidate: () => {
|
|
3681
|
+
direct.invalidate();
|
|
3682
|
+
overlay.invalidate();
|
|
3683
|
+
},
|
|
3684
|
+
};
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
/**
|
|
3689
|
+
* Loro's list diff as absolute splices.
|
|
3690
|
+
*
|
|
3691
|
+
* The diff is a retain / insert / delete stream over a moving cursor, which is
|
|
3692
|
+
* the same information in a shape a caller has to walk statefully. Splices
|
|
3693
|
+
* carry the position each edit lands at, so an app applies them in order and
|
|
3694
|
+
* is done; nothing about the cursor reaches it.
|
|
3695
|
+
*/
|
|
3696
|
+
function splicesFromListDiff(diff: readonly Delta<unknown[]>[]): Splice<unknown>[] {
|
|
3697
|
+
const out: Splice<unknown>[] = [];
|
|
3698
|
+
let at = 0;
|
|
3699
|
+
for (const part of diff) {
|
|
3700
|
+
if (part.retain !== undefined) {
|
|
3701
|
+
at += part.retain;
|
|
3702
|
+
continue;
|
|
3703
|
+
}
|
|
3704
|
+
if (part.delete !== undefined) {
|
|
3705
|
+
out.push({ at, removed: part.delete, inserted: [] });
|
|
3706
|
+
continue;
|
|
3707
|
+
}
|
|
3708
|
+
if (part.insert !== undefined) {
|
|
3709
|
+
const inserted = part.insert.map(plainDiffItem);
|
|
3710
|
+
out.push({ at, removed: 0, inserted });
|
|
3711
|
+
at += inserted.length;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
return out;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
/**
|
|
3718
|
+
* One inserted element as plain data.
|
|
3719
|
+
*
|
|
3720
|
+
* An object or array element is stored as a child container, and the diff
|
|
3721
|
+
* names it as the raw engine handle. A subscriber applies its splices into
|
|
3722
|
+
* whatever it is rendering from, so handing it that handle both takes the app
|
|
3723
|
+
* onto the CRDT engine's own surface (a field read off it answers `undefined`,
|
|
3724
|
+
* while `JSON.stringify` still prints the record, so the failure is silent)
|
|
3725
|
+
* and hands it a pointer that outlives nothing: it is freed with the document.
|
|
3726
|
+
* The whole-container fire converts through `read()`; this is the same
|
|
3727
|
+
* conversion for the delta.
|
|
3728
|
+
*/
|
|
3729
|
+
function plainDiffItem(item: unknown): unknown {
|
|
3730
|
+
return isContainer(item) ? (item as { toJSON(): unknown }).toJSON() : item;
|
|
1343
3731
|
}
|
|
1344
3732
|
|
|
1345
3733
|
class ListHandleImpl<T> implements ListHandle<T> {
|
|
3734
|
+
// How many items each subscriber currently believes it holds, so a whole
|
|
3735
|
+
// fire can say what it replaces. Tracked per subscriber rather than read off
|
|
3736
|
+
// the container, because a resync swaps the source and the container's own
|
|
3737
|
+
// length says nothing about what this subscriber was handed before it.
|
|
3738
|
+
private readonly _held = new Map<InternalListSubscriber, number>();
|
|
3739
|
+
|
|
1346
3740
|
private readonly _anchor: ListAnchor;
|
|
1347
3741
|
private readonly _owner: HandleOwner;
|
|
1348
3742
|
// Active subscriptions, tracked so a version checkout can re-fire and
|
|
1349
3743
|
// re-attach each one against the new container.
|
|
1350
|
-
private readonly _registry =
|
|
3744
|
+
private readonly _registry =
|
|
3745
|
+
new SubscriptionRegistry<InternalListSubscriber>();
|
|
3746
|
+
// See `MapHandleImpl._parent`.
|
|
3747
|
+
private readonly _parent: MapHandleImpl<unknown> | null;
|
|
1351
3748
|
|
|
1352
|
-
constructor(
|
|
3749
|
+
constructor(
|
|
3750
|
+
anchor: ListAnchor,
|
|
3751
|
+
owner: HandleOwner,
|
|
3752
|
+
parent: MapHandleImpl<unknown> | null = null,
|
|
3753
|
+
) {
|
|
1353
3754
|
this._anchor = anchor;
|
|
1354
3755
|
this._owner = owner;
|
|
3756
|
+
this._parent = parent;
|
|
3757
|
+
hideOwnFields(this);
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
// The plain array this handle's key holds, when it holds a value rather
|
|
3761
|
+
// than a container. See `MapHandleImpl._plainBody`.
|
|
3762
|
+
private _plainBody(): unknown[] | undefined {
|
|
3763
|
+
const v = this._anchor.plain?.();
|
|
3764
|
+
return Array.isArray(v) ? v : undefined;
|
|
1355
3765
|
}
|
|
1356
3766
|
|
|
1357
3767
|
read(): T[] {
|
|
3768
|
+
this._owner.assertReadable();
|
|
1358
3769
|
const l = this._anchor.resolve();
|
|
1359
|
-
if (!l) return [];
|
|
3770
|
+
if (!l) return (this._plainBody() ?? []) as T[];
|
|
1360
3771
|
return l.toJSON() as T[];
|
|
1361
3772
|
}
|
|
1362
3773
|
|
|
1363
3774
|
length(): number {
|
|
3775
|
+
this._owner.assertReadable();
|
|
1364
3776
|
const l = this._anchor.resolve();
|
|
1365
|
-
return l ? l.length : 0;
|
|
3777
|
+
return l ? l.length : (this._plainBody()?.length ?? 0);
|
|
1366
3778
|
}
|
|
1367
3779
|
|
|
1368
|
-
mutate(fn: (draft: T
|
|
3780
|
+
mutate(fn: (draft: ListDraft<T>) => void): void {
|
|
1369
3781
|
this._owner.assertWritable();
|
|
1370
3782
|
this._owner.ensureLocalUpdatesSubscribed();
|
|
1371
|
-
|
|
3783
|
+
// Commits as it goes past a size budget; see `mutate-batching.ts`.
|
|
3784
|
+
this._runMutate(
|
|
3785
|
+
fn,
|
|
3786
|
+
new MutateBatcher(() => {
|
|
3787
|
+
this._owner.commitMutate();
|
|
3788
|
+
}),
|
|
3789
|
+
);
|
|
1372
3790
|
this._owner.commitMutate();
|
|
1373
3791
|
}
|
|
1374
3792
|
|
|
1375
3793
|
// Run a list mutate without the commit pipeline; see
|
|
1376
3794
|
// `MapHandleImpl._runMutate`. The ordered-map primitive drives this to
|
|
1377
|
-
// reconcile the order list in the same commit as the map edit
|
|
1378
|
-
|
|
3795
|
+
// reconcile the order list in the same commit as the map edit, and passes
|
|
3796
|
+
// no batcher: that call already runs at one of the map's batch boundaries
|
|
3797
|
+
// and reconciles only the keys that boundary carries, so its own size is
|
|
3798
|
+
// bounded by the budget the map is spending.
|
|
3799
|
+
_runMutate(fn: (draft: ListDraft<T>) => void, batcher?: MutateBatcher): void {
|
|
1379
3800
|
const l = this._anchor.resolveOrCreate();
|
|
1380
|
-
const proxy = wrapLoroListProxy(l) as T
|
|
3801
|
+
const proxy = wrapLoroListProxy(l, batcher) as ListDraft<T>;
|
|
1381
3802
|
fn(proxy);
|
|
1382
3803
|
}
|
|
1383
3804
|
|
|
1384
|
-
// Fire
|
|
1385
|
-
//
|
|
1386
|
-
|
|
3805
|
+
// Fire the whole list as ONE splice replacing everything the subscriber was
|
|
3806
|
+
// last told. Used for registration and for a checkout / return resync, which
|
|
3807
|
+
// describe no authored transition and so carry no origin.
|
|
3808
|
+
//
|
|
3809
|
+
// `removed` is what this subscriber currently believes it holds, which the
|
|
3810
|
+
// registry tracks per subscriber rather than reading off the container: a
|
|
3811
|
+
// resync swaps the source, so the container's own length says nothing about
|
|
3812
|
+
// what the subscriber was handed before it. That is what lets an app apply
|
|
3813
|
+
// every fire the same way instead of recognising a catch-up.
|
|
3814
|
+
private _fireWhole(cb: InternalListSubscriber): void {
|
|
3815
|
+
const inserted = this.read() as unknown[];
|
|
3816
|
+
const held = this._held.get(cb) ?? 0;
|
|
3817
|
+
this._held.set(cb, inserted.length);
|
|
1387
3818
|
try {
|
|
1388
|
-
cb();
|
|
1389
|
-
} catch {
|
|
1390
|
-
|
|
3819
|
+
cb({ splices: [{ at: 0, removed: held, inserted }] });
|
|
3820
|
+
} catch (err) {
|
|
3821
|
+
reportSubscriberThrow(err);
|
|
1391
3822
|
}
|
|
1392
3823
|
}
|
|
1393
3824
|
|
|
1394
|
-
// Attach the
|
|
1395
|
-
//
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
3825
|
+
// Attach the change subscription for `cb`, returning its detach. A root
|
|
3826
|
+
// list binds straight to its container; a composed one tracks the entry as
|
|
3827
|
+
// it is deleted and written again (see `MapHandleImpl._attachChange`). A
|
|
3828
|
+
// list fire carries no delta, so the tracker needs no key bookkeeping: a
|
|
3829
|
+
// re-bind is a re-read signal like any other fire.
|
|
3830
|
+
private _attachChange(cb: InternalListSubscriber): () => void {
|
|
3831
|
+
if (this._parent === null) {
|
|
3832
|
+
const l = this._anchor.resolve();
|
|
3833
|
+
return l === null ? () => {} : this._subscribeContainer(l, cb);
|
|
3834
|
+
}
|
|
3835
|
+
return trackEntryContainer<LoroMovableList, T[]>(this._parent._chainRoot(), {
|
|
3836
|
+
resolve: () => this._anchor.resolve(),
|
|
3837
|
+
read: () => this.read(),
|
|
3838
|
+
print: (value) => JSON.stringify(value),
|
|
3839
|
+
catchUp: () => this._fireWhole(cb),
|
|
3840
|
+
attach: (l) => this._subscribeContainer(l, cb),
|
|
3841
|
+
});
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
// Subscribe `cb` to one container's own changes; see
|
|
3845
|
+
// `MapHandleImpl._subscribeContainer`.
|
|
3846
|
+
private _subscribeContainer(
|
|
3847
|
+
l: LoroMovableList,
|
|
3848
|
+
cb: InternalListSubscriber,
|
|
3849
|
+
): () => void {
|
|
3850
|
+
const sub = l.subscribe((event) => {
|
|
3851
|
+
const diff =
|
|
3852
|
+
event.events[0]?.diff.type === 'list'
|
|
3853
|
+
? (event.events[0].diff.diff as readonly Delta<unknown[]>[])
|
|
3854
|
+
: [];
|
|
3855
|
+
const splices = splicesFromListDiff(diff);
|
|
3856
|
+
let held = this._held.get(cb) ?? 0;
|
|
3857
|
+
for (const splice of splices) held += splice.inserted.length - splice.removed;
|
|
3858
|
+
this._held.set(cb, held);
|
|
3859
|
+
try {
|
|
3860
|
+
cb({ splices }, { source: this._owner.originOf(event) });
|
|
3861
|
+
} catch (err) {
|
|
3862
|
+
reportSubscriberThrow(err);
|
|
3863
|
+
}
|
|
3864
|
+
});
|
|
1403
3865
|
return () => sub();
|
|
1404
3866
|
}
|
|
1405
3867
|
|
|
1406
|
-
|
|
3868
|
+
onSettled(cb: () => void): HandleUnsubscribe {
|
|
3869
|
+
this._owner.assertReadable();
|
|
3870
|
+
return this._registry.onSettled(cb);
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
subscribe(cb: ListSubscriber<T>): HandleUnsubscribe {
|
|
3874
|
+
this._owner.assertReadable();
|
|
3875
|
+
// App subscribers see the change; the internal origin is dropped here so
|
|
3876
|
+
// it never leaks into the public surface.
|
|
3877
|
+
return this._subscribeInternal((change) => cb(change as ListChange<T>));
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
// Internal: like `subscribe`, but the callback also receives what produced
|
|
3881
|
+
// the change. Only the ordered-map wrapper uses it, to stamp an `order`
|
|
3882
|
+
// frame the order list's own move produced.
|
|
3883
|
+
_subscribeInternal(cb: InternalListSubscriber): HandleUnsubscribe {
|
|
1407
3884
|
return this._registry.subscribe(
|
|
1408
3885
|
cb,
|
|
1409
3886
|
(c) => this._fireWhole(c),
|
|
@@ -1414,7 +3891,7 @@ class ListHandleImpl<T> implements ListHandle<T> {
|
|
|
1414
3891
|
// Re-point this ROOT list handle to `next` without firing (see
|
|
1415
3892
|
// MapHandleImpl._rebindAnchor). Lists carry no per-key delta, so there is
|
|
1416
3893
|
// no prior-key snapshot to take; the resync fire is a re-read signal.
|
|
1417
|
-
_rebindAnchor(next:
|
|
3894
|
+
_rebindAnchor(next: LoroMovableList): void {
|
|
1418
3895
|
(this._anchor as RootListAnchor).rebind(next);
|
|
1419
3896
|
}
|
|
1420
3897
|
|
|
@@ -1441,6 +3918,74 @@ class ListHandleImpl<T> implements ListHandle<T> {
|
|
|
1441
3918
|
// lockstep. It reuses the concrete impls (not the interfaces) to drive their
|
|
1442
3919
|
// no-commit `_runMutate` seams and the map's `_existsChecker`, so both halves
|
|
1443
3920
|
// of a structural edit commit once. Built over the SAME handles
|
|
3921
|
+
/**
|
|
3922
|
+
* Records the map holds that the order list does not name, in the map's own
|
|
3923
|
+
* order, which both peers of a merged document read identically.
|
|
3924
|
+
*
|
|
3925
|
+
* The record map and the order list are separate containers and resolve a
|
|
3926
|
+
* conflict independently, so they can disagree in either direction. One peer
|
|
3927
|
+
* deleting a record while another edits it merges to the edit surviving in the
|
|
3928
|
+
* map and the entry gone from the list, which leaves the record present and
|
|
3929
|
+
* unnamed. A reader that walked the order alone would not report it, and the
|
|
3930
|
+
* walk that re-emits a file would drop it, so an ordered read ends with
|
|
3931
|
+
* whatever the order missed rather than losing it.
|
|
3932
|
+
*/
|
|
3933
|
+
function unorderedKeys(
|
|
3934
|
+
live: Iterable<string>,
|
|
3935
|
+
named: ReadonlySet<string>,
|
|
3936
|
+
): string[] {
|
|
3937
|
+
const out: string[] = [];
|
|
3938
|
+
for (const key of live) {
|
|
3939
|
+
if (!named.has(key)) out.push(key);
|
|
3940
|
+
}
|
|
3941
|
+
return out;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
/**
|
|
3945
|
+
* The keys an ordered map presents, from the map's records and the order
|
|
3946
|
+
* list's raw contents.
|
|
3947
|
+
*
|
|
3948
|
+
* Filter to keys still in the map: the primitive keeps the two in step, but a
|
|
3949
|
+
* hand-written or stale order list must not surface a key the map no longer
|
|
3950
|
+
* has. The caller passes `records` and `raw` in rather than re-reading them,
|
|
3951
|
+
* because a subscriber needs the two reads it was derived from to tell one
|
|
3952
|
+
* commit's fires apart, and reading the map is the expensive half (on a paged
|
|
3953
|
+
* store it decodes every page).
|
|
3954
|
+
*/
|
|
3955
|
+
/**
|
|
3956
|
+
* The sequence an ordered map presents, from the order list and the map's LIVE
|
|
3957
|
+
* KEYS.
|
|
3958
|
+
*
|
|
3959
|
+
* Keys rather than records, because that is all the answer depends on: an
|
|
3960
|
+
* entry in the order list appears when the map still holds it, and a record
|
|
3961
|
+
* the list never names is appended. Deriving it from the records instead
|
|
3962
|
+
* charges a whole-store read for a question about membership, which on a
|
|
3963
|
+
* paged store decodes every page, and it runs on every add and every delete.
|
|
3964
|
+
*/
|
|
3965
|
+
function sequenceOf(
|
|
3966
|
+
live: ReadonlySet<string>,
|
|
3967
|
+
raw: readonly string[],
|
|
3968
|
+
): string[] {
|
|
3969
|
+
const seen = new Set<string>();
|
|
3970
|
+
const out: string[] = [];
|
|
3971
|
+
for (const key of raw) {
|
|
3972
|
+
if (!live.has(key)) continue;
|
|
3973
|
+
// Each key once, at its first position. The order list is a list CRDT and
|
|
3974
|
+
// keeps every insertion, so two peers concurrently adding the SAME key
|
|
3975
|
+
// resolve to one record and two entries naming it; a reader that surfaced
|
|
3976
|
+
// both would report a record this map holds once as present twice.
|
|
3977
|
+
if (seen.has(key)) continue;
|
|
3978
|
+
seen.add(key);
|
|
3979
|
+
out.push(key);
|
|
3980
|
+
}
|
|
3981
|
+
for (const key of unorderedKeys(live, seen)) out.push(key);
|
|
3982
|
+
return out;
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function sameSequence(a: readonly string[], b: readonly string[]): boolean {
|
|
3986
|
+
return a.length === b.length && a.every((key, i) => key === b[i]);
|
|
3987
|
+
}
|
|
3988
|
+
|
|
1444
3989
|
// `doc.map(mapKey)` / `doc.list(orderKey)` return, so subscriptions stay
|
|
1445
3990
|
// consistent.
|
|
1446
3991
|
class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
@@ -1448,7 +3993,9 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1448
3993
|
private readonly _map: MapHandleImpl<T>,
|
|
1449
3994
|
private readonly _order: ListHandleImpl<string>,
|
|
1450
3995
|
private readonly _owner: HandleOwner,
|
|
1451
|
-
) {
|
|
3996
|
+
) {
|
|
3997
|
+
hideOwnFields(this);
|
|
3998
|
+
}
|
|
1452
3999
|
|
|
1453
4000
|
read(): Record<string, T> {
|
|
1454
4001
|
return this._map.read();
|
|
@@ -1463,32 +4010,145 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1463
4010
|
}
|
|
1464
4011
|
|
|
1465
4012
|
keys(): string[] {
|
|
1466
|
-
|
|
1467
|
-
// but a hand-written or stale order list must not surface a key the map
|
|
1468
|
-
// no longer has. Read the map ONCE (its bulk snapshot) and test
|
|
1469
|
-
// membership against it, the same shape `ordered()` uses. A per-key
|
|
1470
|
-
// `_existsChecker` predicate would re-decode a paged store's page once
|
|
1471
|
-
// per record it holds (one gunzip per key, ~page-size times per page),
|
|
1472
|
-
// and `subscribe` calls this on every structural move, so a single edit
|
|
1473
|
-
// on a large paged doc would cost O(records) page decodes instead of
|
|
1474
|
-
// O(pages).
|
|
1475
|
-
const records = this._map.read();
|
|
1476
|
-
return this._order
|
|
1477
|
-
.read()
|
|
1478
|
-
.filter((k) => Object.prototype.hasOwnProperty.call(records, k));
|
|
4013
|
+
return sequenceOf(this._map.liveKeySet(), this._order.read());
|
|
1479
4014
|
}
|
|
1480
4015
|
|
|
1481
4016
|
ordered(): { key: string; value: T }[] {
|
|
1482
4017
|
const records = this._map.read();
|
|
4018
|
+
const seen = new Set<string>();
|
|
1483
4019
|
const out: { key: string; value: T }[] = [];
|
|
1484
4020
|
for (const key of this._order.read()) {
|
|
1485
4021
|
if (Object.prototype.hasOwnProperty.call(records, key)) {
|
|
4022
|
+
if (seen.has(key)) continue;
|
|
4023
|
+
seen.add(key);
|
|
1486
4024
|
out.push({ key, value: records[key] as T });
|
|
1487
4025
|
}
|
|
1488
4026
|
}
|
|
4027
|
+
for (const key of unorderedKeys(Object.keys(records), seen)) {
|
|
4028
|
+
out.push({ key, value: records[key] as T });
|
|
4029
|
+
}
|
|
1489
4030
|
return out;
|
|
1490
4031
|
}
|
|
1491
4032
|
|
|
4033
|
+
insert(entryKey: string, value: T, at: OrderedMapPosition): void {
|
|
4034
|
+
this._owner.assertWritable();
|
|
4035
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
4036
|
+
// Resolved before the map write, so the destination is read off the
|
|
4037
|
+
// collection the caller was looking at rather than one this call has
|
|
4038
|
+
// already added a key to.
|
|
4039
|
+
const raw = this._order.read();
|
|
4040
|
+
const target = this._insertIndex(raw, entryKey, at);
|
|
4041
|
+
this._map._runMutate((inner) => {
|
|
4042
|
+
(inner as Record<string, T>)[entryKey] = value;
|
|
4043
|
+
});
|
|
4044
|
+
this._placeKey(raw, entryKey, target);
|
|
4045
|
+
this._owner.commitMutate();
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
move(entryKey: string, to: OrderedMapPosition): void {
|
|
4049
|
+
this._owner.assertWritable();
|
|
4050
|
+
this._owner.ensureLocalUpdatesSubscribed();
|
|
4051
|
+
// A key the map does not hold gets no order entry: the list names what
|
|
4052
|
+
// the map holds, and a peer's delete of this key is the case that puts a
|
|
4053
|
+
// reader here, so writing the entry would resurrect a name for a record
|
|
4054
|
+
// that is gone.
|
|
4055
|
+
if (!this._map._existsChecker()(entryKey)) return;
|
|
4056
|
+
const raw = this._order.read();
|
|
4057
|
+
if (!this._placeKey(raw, entryKey, this._insertIndex(raw, entryKey, to)))
|
|
4058
|
+
return;
|
|
4059
|
+
this._owner.commitMutate();
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
/**
|
|
4063
|
+
* The order-list index a position resolves to.
|
|
4064
|
+
*
|
|
4065
|
+
* Resolved against the RAW list rather than the sequence a reader sees, so
|
|
4066
|
+
* the key lands beside the anchor's own entry. The two disagree only where
|
|
4067
|
+
* the list names a key the map no longer holds, and a dead neighbour still
|
|
4068
|
+
* names the right neighbourhood.
|
|
4069
|
+
*/
|
|
4070
|
+
private _insertIndex(
|
|
4071
|
+
raw: readonly string[],
|
|
4072
|
+
entryKey: string,
|
|
4073
|
+
at: OrderedMapPosition,
|
|
4074
|
+
): number {
|
|
4075
|
+
if ('index' in at) {
|
|
4076
|
+
const want = Math.trunc(Number(at.index));
|
|
4077
|
+
if (!Number.isFinite(want)) return raw.length;
|
|
4078
|
+
if (want <= 0) return 0;
|
|
4079
|
+
// An index counts the positions a reader is shown, and the raw list
|
|
4080
|
+
// holds entries no reader sees (a key the map lost, a duplicate the
|
|
4081
|
+
// sequence reports once), so the index names whichever key stands
|
|
4082
|
+
// there and the answer is that key's own entry.
|
|
4083
|
+
//
|
|
4084
|
+
// Counted with this key taken out, so an index means the position the
|
|
4085
|
+
// key ENDS at either way. Left in, a key moving down the collection
|
|
4086
|
+
// lands one short of the index named, because lifting it out shifts
|
|
4087
|
+
// everything above it.
|
|
4088
|
+
const seq = sequenceOf(this._map.liveKeySet(), raw).filter(
|
|
4089
|
+
(key) => key !== entryKey,
|
|
4090
|
+
);
|
|
4091
|
+
if (want >= seq.length) return raw.length;
|
|
4092
|
+
const found = raw.indexOf(seq[want]!);
|
|
4093
|
+
return found === -1 ? raw.length : found;
|
|
4094
|
+
}
|
|
4095
|
+
const anchor = 'before' in at ? at.before : at.after;
|
|
4096
|
+
const found = raw.indexOf(anchor);
|
|
4097
|
+
if (found === -1) return raw.length;
|
|
4098
|
+
return 'before' in at ? found : found + 1;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/**
|
|
4102
|
+
* Put `entryKey` at `target` in the order list, reporting whether that
|
|
4103
|
+
* wrote anything.
|
|
4104
|
+
*
|
|
4105
|
+
* A key the list already names is REPOSITIONED (`move`), never re-inserted:
|
|
4106
|
+
* the list stores as a `LoroMovableList`, so a move names the entry it
|
|
4107
|
+
* carries and two peers repositioning the same key converge on a list
|
|
4108
|
+
* naming it once, where a delete plus an insert commutes into a list naming
|
|
4109
|
+
* it twice.
|
|
4110
|
+
*
|
|
4111
|
+
* The plan is computed on the snapshot the caller already read, so the
|
|
4112
|
+
* draft takes the writes and no scan.
|
|
4113
|
+
*/
|
|
4114
|
+
private _placeKey(
|
|
4115
|
+
raw: readonly string[],
|
|
4116
|
+
entryKey: string,
|
|
4117
|
+
target: number,
|
|
4118
|
+
): boolean {
|
|
4119
|
+
const from = raw.indexOf(entryKey);
|
|
4120
|
+
if (from === -1) {
|
|
4121
|
+
this._order._runMutate((arr) => {
|
|
4122
|
+
arr.splice(target, 0, entryKey);
|
|
4123
|
+
});
|
|
4124
|
+
return true;
|
|
4125
|
+
}
|
|
4126
|
+
// Entries beyond the first naming this key. Two peers adding one key
|
|
4127
|
+
// leave the list naming it twice and a reader takes the first, so a
|
|
4128
|
+
// reposition that left the rest standing would put the key where the
|
|
4129
|
+
// reader is not looking. Dropping them names the key once and loses
|
|
4130
|
+
// nothing: the entry this call keeps carries it.
|
|
4131
|
+
const extras: number[] = [];
|
|
4132
|
+
for (let i = from + 1; i < raw.length; i += 1) {
|
|
4133
|
+
if (raw[i] === entryKey) extras.push(i);
|
|
4134
|
+
}
|
|
4135
|
+
// Positions after the drops: `from` is below every extra, and an
|
|
4136
|
+
// insertion index loses the extras below it. The move destination then
|
|
4137
|
+
// counts positions with the entry lifted out, which is one less than the
|
|
4138
|
+
// insertion index whenever the entry stands before it.
|
|
4139
|
+
let want = target;
|
|
4140
|
+
for (const extra of extras) if (extra < target) want -= 1;
|
|
4141
|
+
const dest = want > from ? want - 1 : want;
|
|
4142
|
+
if (dest === from && extras.length === 0) return false;
|
|
4143
|
+
this._order._runMutate((arr) => {
|
|
4144
|
+
for (let i = extras.length - 1; i >= 0; i -= 1) {
|
|
4145
|
+
arr.splice(extras[i]!, 1);
|
|
4146
|
+
}
|
|
4147
|
+
if (dest !== from) arr.move(from, dest);
|
|
4148
|
+
});
|
|
4149
|
+
return true;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
1492
4152
|
mutate(fn: (draft: Record<string, T>) => void): void {
|
|
1493
4153
|
this._owner.assertWritable();
|
|
1494
4154
|
this._owner.ensureLocalUpdatesSubscribed();
|
|
@@ -1505,38 +4165,61 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1505
4165
|
// the order entries for those same records. Left to the end instead,
|
|
1506
4166
|
// the final commit carries every key at once: measured at 229 KB for a
|
|
1507
4167
|
// 20,000-record write, an oversized op of its own.
|
|
1508
|
-
|
|
1509
|
-
|
|
4168
|
+
//
|
|
4169
|
+
// `carried` names the records whose writes the commit about to be taken
|
|
4170
|
+
// covers, and the boundary reconciles those alone. A paged store assembles
|
|
4171
|
+
// its whole overlay before it writes any of it, so by the first boundary
|
|
4172
|
+
// every key of the mutation is already in `added`, and appending all of
|
|
4173
|
+
// them there would put the whole order list in that first op and name
|
|
4174
|
+
// records the store does not hold yet. The final call passes nothing and
|
|
4175
|
+
// lands whatever is left.
|
|
4176
|
+
const reconcileOrder = (carried?: readonly string[]): void => {
|
|
4177
|
+
const covers =
|
|
4178
|
+
carried === undefined ? null : new Set<string>(carried);
|
|
4179
|
+
const appends = [...added].filter((k) => covers === null || covers.has(k));
|
|
4180
|
+
const drops = [...removed].filter((k) => covers === null || covers.has(k));
|
|
4181
|
+
if (appends.length === 0 && drops.length === 0) return;
|
|
4182
|
+
const dropped = new Set(drops);
|
|
1510
4183
|
this._order._runMutate((arr) => {
|
|
1511
|
-
if (
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
4184
|
+
if (dropped.size > 0) {
|
|
4185
|
+
// Remove exactly the entries that went, in place. Rebuilding the
|
|
4186
|
+
// list instead (truncate, then re-push what survived) makes the
|
|
4187
|
+
// whole list one write, and two peers each removing a different
|
|
4188
|
+
// record then merge to a list holding both re-insertions plus the
|
|
4189
|
+
// entries they each meant to drop. Walking down keeps the indices
|
|
4190
|
+
// below the cursor valid, and a run of adjacent removals goes in one
|
|
4191
|
+
// splice so a bulk delete costs runs rather than entries.
|
|
4192
|
+
const current = [...arr] as string[];
|
|
4193
|
+
let runEnd = -1;
|
|
4194
|
+
for (let i = current.length - 1; i >= -1; i -= 1) {
|
|
4195
|
+
const drop = i >= 0 && dropped.has(current[i]!);
|
|
4196
|
+
if (drop && runEnd === -1) runEnd = i;
|
|
4197
|
+
if (!drop && runEnd !== -1) {
|
|
4198
|
+
arr.splice(i + 1, runEnd - i);
|
|
4199
|
+
runEnd = -1;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
1515
4202
|
}
|
|
1516
|
-
for (const k of
|
|
4203
|
+
for (const k of appends) arr.push(k);
|
|
1517
4204
|
});
|
|
1518
|
-
added.
|
|
1519
|
-
removed.
|
|
4205
|
+
for (const k of appends) added.delete(k);
|
|
4206
|
+
for (const k of drops) removed.delete(k);
|
|
1520
4207
|
};
|
|
1521
4208
|
|
|
1522
|
-
const batcher =
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
})
|
|
1527
|
-
: null;
|
|
4209
|
+
const batcher = new MutateBatcher((carried) => {
|
|
4210
|
+
reconcileOrder(carried);
|
|
4211
|
+
this._owner.commitMutate();
|
|
4212
|
+
});
|
|
1528
4213
|
|
|
1529
4214
|
this._map._runMutate((inner) => {
|
|
1530
4215
|
// "is this key new" (and so must be appended to the order list) is O(1)
|
|
1531
4216
|
// per set; a repeat set of the same new key is guarded by `added`.
|
|
1532
|
-
// `exists`
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
// re-add of a pre-existing key from being mis-seen as a fresh add and
|
|
1539
|
-
// appended to the order list a second time.
|
|
4217
|
+
// `exists` answers over the key set the map held at the START of this
|
|
4218
|
+
// batch, so it is the whole test for "present before the batch" and this
|
|
4219
|
+
// batch's own writes cannot move it. `added` and `removed` carry what
|
|
4220
|
+
// the batch has done on top of it, which is what makes a delete followed
|
|
4221
|
+
// by any number of re-sets append to the order list zero times rather
|
|
4222
|
+
// than once per set.
|
|
1540
4223
|
const exists = this._map._existsChecker();
|
|
1541
4224
|
const tracking = new Proxy(
|
|
1542
4225
|
{},
|
|
@@ -1548,9 +4231,7 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1548
4231
|
set: (_t, key, value) => {
|
|
1549
4232
|
if (typeof key === 'symbol') return false;
|
|
1550
4233
|
const k = key as string;
|
|
1551
|
-
if (!added.has(k) && !exists(k)
|
|
1552
|
-
added.add(k);
|
|
1553
|
-
}
|
|
4234
|
+
if (!added.has(k) && !exists(k)) added.add(k);
|
|
1554
4235
|
// A re-add after a delete in the same batch cancels the delete.
|
|
1555
4236
|
removed.delete(k);
|
|
1556
4237
|
(inner as Record<string, unknown>)[k] = value;
|
|
@@ -1564,17 +4245,11 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1564
4245
|
// list, so just forget the addition.
|
|
1565
4246
|
added.delete(k);
|
|
1566
4247
|
} else if (exists(k)) {
|
|
1567
|
-
// Only
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
// must NOT enter `removed`: a later re-`set` of that key in the
|
|
1573
|
-
// same batch consults `removed.has(k)` to decide "re-add of a
|
|
1574
|
-
// pre-existing key" (skip the order append) vs "fresh add"
|
|
1575
|
-
// (append). A spurious `removed` entry makes the fresh add look
|
|
1576
|
-
// like a re-add, so the key lands in the record map but never in
|
|
1577
|
-
// `instance_order`, a silent divergence between the two.
|
|
4248
|
+
// Only a key the order list already named can be dropped from
|
|
4249
|
+
// it, and `exists` is exactly that set, so `removed` stays a
|
|
4250
|
+
// subset of it. Deleting a key that was already absent records
|
|
4251
|
+
// nothing, which leaves a later re-`set` of it in the same batch
|
|
4252
|
+
// reading as the fresh add it is.
|
|
1578
4253
|
removed.add(k);
|
|
1579
4254
|
}
|
|
1580
4255
|
delete (inner as Record<string, unknown>)[k];
|
|
@@ -1590,51 +4265,89 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1590
4265
|
},
|
|
1591
4266
|
) as Record<string, T>;
|
|
1592
4267
|
fn(tracking);
|
|
1593
|
-
}, batcher
|
|
4268
|
+
}, batcher);
|
|
1594
4269
|
|
|
1595
4270
|
reconcileOrder();
|
|
1596
4271
|
this._owner.commitMutate();
|
|
1597
4272
|
}
|
|
1598
4273
|
|
|
4274
|
+
/** Settles with its map. The order list is delivered in the same breath, so
|
|
4275
|
+
* one signal covers both halves and an app is never told the records are in
|
|
4276
|
+
* while their sequence is still arriving. */
|
|
4277
|
+
onSettled(cb: () => void): HandleUnsubscribe {
|
|
4278
|
+
return this._map.onSettled(cb);
|
|
4279
|
+
}
|
|
4280
|
+
|
|
1599
4281
|
subscribe(cb: MapSubscriber<T>): HandleUnsubscribe {
|
|
1600
|
-
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1603
|
-
//
|
|
1604
|
-
//
|
|
1605
|
-
//
|
|
4282
|
+
this._owner.assertReadable();
|
|
4283
|
+
// Content (`set` / `deleted`) rides the map subscription. `order` rides
|
|
4284
|
+
// whichever of the two containers moved it, and reaches the subscriber at
|
|
4285
|
+
// most once per commit: `lastOrder` is the sequence it was last handed, so
|
|
4286
|
+
// the second fire of a commit that changed both containers has nothing to
|
|
4287
|
+
// add.
|
|
4288
|
+
//
|
|
4289
|
+
// The map's fire tests the key set against `known`, which is O(changed)
|
|
4290
|
+
// and catches the ordinary add / delete without walking the order; a field
|
|
4291
|
+
// edit must not pay for a re-sequence. `known` starts empty so the initial
|
|
4292
|
+
// fire reads as an all-new move and carries the starting `order`.
|
|
4293
|
+
//
|
|
4294
|
+
// What that test cannot see reaches the ORDER LIST's own fire: a raw edit
|
|
4295
|
+
// of the order list moves the sequence while the map stands still, and a
|
|
4296
|
+
// merge can resequence keys whose membership never changed (two peers
|
|
4297
|
+
// adding the same key leave one record and two entries naming it). Both
|
|
4298
|
+
// are silent on the map, so without that subscription the app's rendered
|
|
4299
|
+
// sequence disagrees with `keys()` and no later content change corrects
|
|
4300
|
+
// it.
|
|
1606
4301
|
//
|
|
1607
|
-
// A
|
|
1608
|
-
//
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
// `instance_order`, which the membership test cannot see. The fire tells
|
|
1612
|
-
// us which it is (`resync`) directly, so on a re-sync we read the actual
|
|
1613
|
-
// order (O(order), and these fires are rare) and treat only a genuine
|
|
1614
|
-
// reorder as a move rather than every re-sync (else an empty registration
|
|
1615
|
-
// and an unchanged-order checkout would both deliver a redundant `order`).
|
|
4302
|
+
// A whole-container re-sync (registration, or a checkout / return /
|
|
4303
|
+
// hydrate swapping the source) can present the SAME key set in a different
|
|
4304
|
+
// order, which the membership test cannot see either. The fire says which
|
|
4305
|
+
// it is (`resync`), so those read the sequence directly.
|
|
1616
4306
|
const known = new Set<string>();
|
|
1617
4307
|
let lastOrder: string[] = [];
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
4308
|
+
// The order list's raw contents the last delivered sequence was derived
|
|
4309
|
+
// from. The order fire compares against it FIRST, so the fire that follows
|
|
4310
|
+
// the map's own for one commit costs a list read rather than a re-read of
|
|
4311
|
+
// the map, which on a paged store decodes every page.
|
|
4312
|
+
let lastRaw: readonly string[] = [];
|
|
4313
|
+
// The sequence as it stands, or null when the subscriber already has it.
|
|
4314
|
+
//
|
|
4315
|
+
// Derived from `known`, which the map fire has already brought up to date
|
|
4316
|
+
// and which IS this map's live key set: every fire adds what it delivered
|
|
4317
|
+
// and drops what it removed, a resync included. The alternative, asking
|
|
4318
|
+
// the map, answers the same question by materialising every record, which
|
|
4319
|
+
// on a paged store decodes every page on every add and every delete.
|
|
4320
|
+
const moved = (): string[] | null => {
|
|
4321
|
+
lastRaw = this._order.read();
|
|
4322
|
+
const order = sequenceOf(known, lastRaw);
|
|
4323
|
+
if (sameSequence(order, lastOrder)) return null;
|
|
4324
|
+
lastOrder = order;
|
|
4325
|
+
return order;
|
|
4326
|
+
};
|
|
4327
|
+
const detachMap = this._map._subscribeInternal((change, origin, resync) => {
|
|
4328
|
+
let structural = change.deleted.length > 0;
|
|
4329
|
+
for (const key of change.set.keys()) {
|
|
4330
|
+
if (!known.has(key)) structural = true;
|
|
1627
4331
|
}
|
|
1628
|
-
|
|
4332
|
+
for (const key of change.deleted) known.delete(key);
|
|
4333
|
+
for (const key of change.set.keys()) known.add(key);
|
|
4334
|
+
const order = structural || resync ? moved() : null;
|
|
4335
|
+
if (order === null) {
|
|
1629
4336
|
cb(change, origin);
|
|
1630
4337
|
return;
|
|
1631
4338
|
}
|
|
1632
|
-
for (const key of change.deleted) known.delete(key);
|
|
1633
|
-
for (const key of change.set.keys()) known.add(key);
|
|
1634
|
-
const order = orderKeys ?? this.keys();
|
|
1635
|
-
lastOrder = order;
|
|
1636
4339
|
cb({ set: change.set, deleted: change.deleted, order }, origin);
|
|
1637
4340
|
});
|
|
4341
|
+
const detachOrder = this._order._subscribeInternal((_change, origin) => {
|
|
4342
|
+
if (sameSequence(this._order.read(), lastRaw)) return;
|
|
4343
|
+
const order = moved();
|
|
4344
|
+
if (order === null) return;
|
|
4345
|
+
cb({ set: new Map<string, T>(), deleted: [], order }, origin);
|
|
4346
|
+
});
|
|
4347
|
+
return () => {
|
|
4348
|
+
detachMap();
|
|
4349
|
+
detachOrder();
|
|
4350
|
+
};
|
|
1638
4351
|
}
|
|
1639
4352
|
}
|
|
1640
4353
|
|
|
@@ -1648,19 +4361,13 @@ class OrderedMapHandleImpl<T> implements OrderedMapHandle<T> {
|
|
|
1648
4361
|
export function createRootMapHandle<T>(
|
|
1649
4362
|
root: LoroMap,
|
|
1650
4363
|
owner: HandleOwner,
|
|
1651
|
-
|
|
1652
|
-
pagingScheme?: () => number,
|
|
4364
|
+
paging?: PagedRootHooks,
|
|
1653
4365
|
): MapHandle<T> {
|
|
1654
|
-
return new MapHandleImpl<T>(
|
|
1655
|
-
rootAnchor(root),
|
|
1656
|
-
owner,
|
|
1657
|
-
pageCount ?? null,
|
|
1658
|
-
pagingScheme ?? null,
|
|
1659
|
-
);
|
|
4366
|
+
return new MapHandleImpl<T>(rootAnchor(root), owner, paging ?? null);
|
|
1660
4367
|
}
|
|
1661
4368
|
|
|
1662
4369
|
export function createRootListHandle<T>(
|
|
1663
|
-
root:
|
|
4370
|
+
root: LoroMovableList,
|
|
1664
4371
|
owner: HandleOwner,
|
|
1665
4372
|
): ListHandle<T> {
|
|
1666
4373
|
return new ListHandleImpl<T>(rootAnchor(root), owner);
|
|
@@ -1689,11 +4396,27 @@ export function rebindRootMapAnchor(
|
|
|
1689
4396
|
|
|
1690
4397
|
export function rebindRootListAnchor(
|
|
1691
4398
|
handle: ListHandle<unknown>,
|
|
1692
|
-
next:
|
|
4399
|
+
next: LoroMovableList,
|
|
1693
4400
|
): void {
|
|
1694
4401
|
(handle as unknown as ListHandleImpl<unknown>)._rebindAnchor(next);
|
|
1695
4402
|
}
|
|
1696
4403
|
|
|
4404
|
+
// The first step of the swap: capture a map handle's key set while the handle still
|
|
4405
|
+
// resolves the outgoing source in BOTH respects, its container and the paging
|
|
4406
|
+
// state it reads that container under. The Document runs this over every root
|
|
4407
|
+
// map handle before it moves its active-source pointer, because the paging
|
|
4408
|
+
// state follows that pointer while the container follows the anchor: between
|
|
4409
|
+
// the two moves a handle reads the outgoing container under the incoming
|
|
4410
|
+
// source's paging state, which yields the store's `brass:` bookkeeping keys
|
|
4411
|
+
// rather than its records, and the resync then reports those as the keys the
|
|
4412
|
+
// swap removed while every record it actually removed goes unreported.
|
|
4413
|
+
//
|
|
4414
|
+
// Lists need no counterpart: they carry no per-key delta, so a list rebind has
|
|
4415
|
+
// nothing to diff.
|
|
4416
|
+
export function snapshotRootMapKeys(handle: MapHandle<unknown>): void {
|
|
4417
|
+
(handle as unknown as MapHandleImpl<unknown>)._snapshotPriorKeys();
|
|
4418
|
+
}
|
|
4419
|
+
|
|
1697
4420
|
// Re-fire + re-attach a root handle's subscribers against its current
|
|
1698
4421
|
// container. Both concrete impls expose `_resync`, so one generic reaches
|
|
1699
4422
|
// either kind; the cast is safe because this module is the only producer of
|