j-templates 8.2.0 → 9.0.1
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/Node/vNode.js +2 -0
- package/Store/Diff/diffTree.d.ts +24 -49
- package/Store/Diff/diffTree.js +112 -511
- package/Store/Diff/index.d.ts +1 -0
- package/Store/Diff/index.js +1 -0
- package/Store/Diff/utils/diffTree.types.d.ts +6 -1
- package/Store/Diff/utils/homes.d.ts +46 -0
- package/Store/Diff/utils/homes.js +213 -0
- package/Store/Diff/utils/paths.d.ts +42 -0
- package/Store/Diff/utils/paths.js +87 -0
- package/Store/Diff/utils/projections.d.ts +55 -0
- package/Store/Diff/utils/projections.js +290 -0
- package/Store/Diff/utils/routes.d.ts +27 -0
- package/Store/Diff/utils/routes.js +168 -0
- package/Store/Diff/utils/state.d.ts +56 -0
- package/Store/Diff/utils/state.js +155 -0
- package/Store/Store/store.d.ts +8 -0
- package/Store/Store/store.js +13 -0
- package/Store/Store/storeAsync.js +7 -6
- package/Store/Store/storeSync.js +3 -3
- package/Store/Tree/observableNode.js +18 -15
- package/Store/Tree/observableScope.d.ts +1 -0
- package/Store/Tree/observableScope.js +4 -0
- package/Store/Tree/snapshot.d.ts +19 -21
- package/Store/Tree/snapshot.js +29 -23
- package/Utils/json.d.ts +166 -26
- package/Utils/json.js +510 -183
- package/package.json +1 -1
- package/Store/Diff/utils/diffTree.utils.d.ts +0 -79
- package/Store/Diff/utils/diffTree.utils.js +0 -398
package/Store/Diff/diffTree.js
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { JsonDiffInto, JsonMerge, JsonType } from '../../Utils/json';
|
|
2
2
|
import { Snapshot } from '../Tree/snapshot';
|
|
3
|
-
import {
|
|
3
|
+
import { ApplyValueToPath, SpliceValueAtPath } from './utils/homes';
|
|
4
|
+
import { JoinPaths, PROJECTION_PREFIX, ROOT_ROUTE, SplitRouteAndPath } from './utils/paths';
|
|
5
|
+
import { Projections } from './utils/projections';
|
|
6
|
+
import { RouteInstances } from './utils/routes';
|
|
7
|
+
import { DeleteRouteState, KeyUnchanged, ReadPath, ReadSnapshot } from './utils/state';
|
|
8
|
+
/**
|
|
9
|
+
* A tree of JSON state in routes, with projections, that tracks its changes as diffs. The work is
|
|
10
|
+
* split across `utils/`:
|
|
11
|
+
* - `homes`: writes, which keep every keyed value at its home
|
|
12
|
+
* - `state`: reading and writing route state, and snapshots of it
|
|
13
|
+
* - `routes`: route instances, created from the route definitions
|
|
14
|
+
* - `projections`: running each route instance's projections
|
|
15
|
+
* - `paths`: path strings
|
|
16
|
+
*/
|
|
4
17
|
export class DiffTree {
|
|
5
18
|
constructor(keyFunc, routeDefinitions = {}) {
|
|
6
|
-
this.keyFunc = keyFunc;
|
|
7
|
-
this.routeDefinitions = routeDefinitions;
|
|
8
19
|
this.rootState = {};
|
|
9
|
-
this.
|
|
10
|
-
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
this.rootRoute = CreateRoute([], ROOT_ROUTE, this.routeDefinitions[ROOT_ROUTE], this.keyFunc);
|
|
20
|
-
// Every other pattern in the map is a child of the root route
|
|
21
|
-
this.rootRoute.routeDefinitions = this.routeDefinitions;
|
|
22
|
-
this.rootRoute.childPartCounts = PartCounts(this.routeDefinitions);
|
|
23
|
-
// Both spellings of the root route, so the first lookup of either isn't taken for a new instance
|
|
24
|
-
this.routeCache.set("", this.rootRoute);
|
|
25
|
-
this.routeCache.set(ROOT_ROUTE, this.rootRoute);
|
|
20
|
+
this.snapshot = Snapshot.Create();
|
|
21
|
+
/** Each root key's snapshot as of the last commit */
|
|
22
|
+
this.commitedSnapshot = new Map();
|
|
23
|
+
this.routes = new RouteInstances(keyFunc, routeDefinitions);
|
|
24
|
+
this.projections = new Projections(this.snapshot, this.rootState, this.routes);
|
|
25
|
+
this.writeContext = {
|
|
26
|
+
snapshot: this.snapshot,
|
|
27
|
+
root: this.rootState,
|
|
28
|
+
routeKeyFunc: (routePath) => this.routes.Get(routePath).keyFunc
|
|
29
|
+
};
|
|
26
30
|
}
|
|
27
31
|
WritePath(path, value) {
|
|
28
|
-
|
|
29
|
-
if (propertyPath.startsWith(PROJECTION_PREFIX))
|
|
30
|
-
throw new Error(`${PROJECTION_PREFIX} keys are read-only.`);
|
|
31
|
-
this.GetRouteInstance(routePath);
|
|
32
|
+
AssertWritable(SplitRouteAndPath(path)[1]);
|
|
32
33
|
// Keyed entities are local to the route written to; a key starting with "/" is a route path from the root
|
|
33
|
-
ApplyValueToPath(this.
|
|
34
|
+
ApplyValueToPath(this.writeContext, path, value);
|
|
34
35
|
}
|
|
35
36
|
PatchPath(path, patch) {
|
|
36
37
|
const value = this.Snapshot(path);
|
|
@@ -38,70 +39,16 @@ export class DiffTree {
|
|
|
38
39
|
this.WritePath(path, merged);
|
|
39
40
|
}
|
|
40
41
|
PushPath(path, items) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
throw new Error(`${PROJECTION_PREFIX} keys are read-only.`);
|
|
44
|
-
const combinedRoutePath = JoinPaths("", routePath);
|
|
45
|
-
const routeRoot = ReadPath(this.rootState, combinedRoutePath);
|
|
46
|
-
const value = ReadPath(this.Snapshot(combinedRoutePath), valuePath);
|
|
47
|
-
if (!value)
|
|
48
|
-
throw new Error(`Array not found at path: ${path}`);
|
|
49
|
-
if (!Array.isArray(value))
|
|
50
|
-
throw new Error(`Value at path ${path} is not an array`);
|
|
51
|
-
if (items.length === 0)
|
|
52
|
-
return;
|
|
53
|
-
const diff = [];
|
|
54
|
-
for (let x = 0; x < items.length; x++)
|
|
55
|
-
diff.push({
|
|
56
|
-
type: "write",
|
|
57
|
-
path: `${valuePath}.${value.length + x}`.split('.'),
|
|
58
|
-
value: items[x]
|
|
59
|
-
});
|
|
60
|
-
diff.push({
|
|
61
|
-
type: "write",
|
|
62
|
-
path: `${valuePath}.length`.split('.'),
|
|
63
|
-
value: value.length + items.length
|
|
64
|
-
});
|
|
65
|
-
ApplyDiff(this.snapshot, this.rootState, this.routeKeyFunc, combinedRoutePath, routeRoot, diff);
|
|
66
|
-
/* const keyedValues = ApplyDiffToData(this.snapshot, this.rootState, this.routeKeyFunc, combinedRoutePath, routeRoot, diff);
|
|
67
|
-
ApplyPathValues(this.snapshot, this.rootState, keyedValues); */
|
|
42
|
+
// A start past the end appends
|
|
43
|
+
this.Splice(path, Infinity, 0, items);
|
|
68
44
|
}
|
|
69
45
|
SplicePath(path, start, deleteCount, items) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const value = ReadPath(routeRootSnapshot, valuePath);
|
|
77
|
-
if (!value)
|
|
78
|
-
throw new Error(`Array not found at path: ${path}`);
|
|
79
|
-
if (!Array.isArray(value))
|
|
80
|
-
throw new Error(`Value at path ${path} is not an array`);
|
|
81
|
-
const moveStartIndex = start + deleteCount;
|
|
82
|
-
const moveEndIndex = start + items.length;
|
|
83
|
-
const diff = [];
|
|
84
|
-
const basePath = valuePath.split('.');
|
|
85
|
-
for (let x = moveStartIndex; x < value.length; x++)
|
|
86
|
-
diff.push({
|
|
87
|
-
type: "move",
|
|
88
|
-
path: basePath,
|
|
89
|
-
sourceProp: x,
|
|
90
|
-
targetProp: moveEndIndex + (x - moveStartIndex)
|
|
91
|
-
});
|
|
92
|
-
for (let x = 0; x < items.length; x++)
|
|
93
|
-
diff.push({
|
|
94
|
-
type: "write",
|
|
95
|
-
path: basePath.concat(start + x),
|
|
96
|
-
value: items[x]
|
|
97
|
-
});
|
|
98
|
-
const actualDelete = start + deleteCount > value.length ? value.length - start : deleteCount;
|
|
99
|
-
diff.push({
|
|
100
|
-
type: "write",
|
|
101
|
-
path: basePath.concat("length"),
|
|
102
|
-
value: value.length - actualDelete + items.length
|
|
103
|
-
});
|
|
104
|
-
ApplyDiff(this.snapshot, this.rootState, this.routeKeyFunc, combinedRoutePath, routeRoot, diff);
|
|
46
|
+
this.Splice(path, start, deleteCount, items);
|
|
47
|
+
}
|
|
48
|
+
// Shared by PushPath and SplicePath, which a DiffTreeWriter guards separately
|
|
49
|
+
Splice(path, start, deleteCount, items) {
|
|
50
|
+
AssertWritable(SplitRouteAndPath(path)[1]);
|
|
51
|
+
SpliceValueAtPath(this.writeContext, path, start, deleteCount, items);
|
|
105
52
|
}
|
|
106
53
|
/**
|
|
107
54
|
* Deletes a route: its state, its child routes and projection outputs, and its route
|
|
@@ -116,12 +63,7 @@ export class DiffTree {
|
|
|
116
63
|
if (propertyPath || fullRoute === ROOT_ROUTE)
|
|
117
64
|
throw new Error(`Unable to delete ${routePath}: only a non-root route path, e.g. /channel/1/, can be deleted.`);
|
|
118
65
|
DeleteRouteState(this.snapshot, this.rootState, fullRoute);
|
|
119
|
-
|
|
120
|
-
for (const cachedPath of this.routeCache.keys())
|
|
121
|
-
if (cachedPath.startsWith(fullRoute))
|
|
122
|
-
this.routeCache.delete(cachedPath);
|
|
123
|
-
this.projectionOrder = undefined;
|
|
124
|
-
this.routeVersion++;
|
|
66
|
+
this.routes.Delete(fullRoute);
|
|
125
67
|
}
|
|
126
68
|
/**
|
|
127
69
|
* Reads the raw value at a path, root (`value.prop`) or routed (`/channel/1/value.prop`).
|
|
@@ -130,440 +72,99 @@ export class DiffTree {
|
|
|
130
72
|
ReadPath(path) {
|
|
131
73
|
return ReadPath(this.rootState, path);
|
|
132
74
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Runs every projection of every route instance, dependencies first. A projection is skipped
|
|
135
|
-
* when no route it reads through changed since its last run, when a read's parent is missing,
|
|
136
|
-
* or when every read value is the same as its last run. Outputs are written as-is to
|
|
137
|
-
* `$projection_<key>` in their route, without a diff.
|
|
138
|
-
* @throws When projections depend on each other in a cycle
|
|
139
|
-
*/
|
|
75
|
+
/** Runs every projection of every route instance; see `Projections.Run` */
|
|
140
76
|
RunProjections() {
|
|
141
|
-
|
|
142
|
-
for (let x = 0; x < order.length; x++) {
|
|
143
|
-
const projection = order[x];
|
|
144
|
-
if (this.SourcesUnchanged(projection.sources))
|
|
145
|
-
continue;
|
|
146
|
-
const previous = projection.sources;
|
|
147
|
-
const values = this.ReadValues(projection.reads, projection.sources = new Array(projection.reads.length), previous);
|
|
148
|
-
if (!values || SameValues(projection.reads, values, projection.values))
|
|
149
|
-
continue;
|
|
150
|
-
let output;
|
|
151
|
-
if (projection.keyed) {
|
|
152
|
-
output = RunKeyed(projection, projection.keyed, values[0]);
|
|
153
|
-
// Every entry kept its output, so the record it wrote last run still holds
|
|
154
|
-
if (output === UNCHANGED) {
|
|
155
|
-
projection.values = values;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
else
|
|
160
|
-
output = projection.projection(...values);
|
|
161
|
-
WritePath(this.snapshot, this.rootState, projection.output, output, false);
|
|
162
|
-
Snapshot.Register(this.snapshot, output);
|
|
163
|
-
if (projection.readsOutput)
|
|
164
|
-
// Its own output is one of its reads, so the output it just wrote isn't a change
|
|
165
|
-
projection.values = this.ReadValues(projection.reads, projection.sources = new Array(projection.reads.length), projection.sources);
|
|
166
|
-
else {
|
|
167
|
-
projection.values = values;
|
|
168
|
-
this.RefreshSources(projection.sources, SplitRouteAndPath(projection.output)[0]);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
Commit() {
|
|
173
|
-
const nextSnapshot = Snapshot.Get(this.snapshot, this.rootState, this.resolver);
|
|
174
|
-
const currentKeys = new Set(Object.keys(this.commitedSnapshot));
|
|
175
|
-
const nextKeys = Object.keys(nextSnapshot);
|
|
176
|
-
let diff = [];
|
|
177
|
-
for (let x = 0; x < nextKeys.length; x++) {
|
|
178
|
-
currentKeys.delete(nextKeys[x]);
|
|
179
|
-
diff = JsonDiff(nextSnapshot[nextKeys[x]], this.commitedSnapshot[nextKeys[x]], nextKeys[x], diff);
|
|
180
|
-
}
|
|
181
|
-
for (let key of currentKeys)
|
|
182
|
-
diff.push({
|
|
183
|
-
type: "write",
|
|
184
|
-
path: [key],
|
|
185
|
-
value: undefined
|
|
186
|
-
});
|
|
187
|
-
this.commitedSnapshot = nextSnapshot;
|
|
188
|
-
return diff;
|
|
189
|
-
}
|
|
190
|
-
Snapshot(path) {
|
|
191
|
-
const [routePath, propertyPath] = SplitRouteAndPath(path);
|
|
192
|
-
const routeState = ReadPath(this.rootState, routePath);
|
|
193
|
-
const routeSnapshot = Snapshot.Get(this.snapshot, routeState, undefined, ToSnapshotPath(routePath));
|
|
194
|
-
const snapshot = ReadPath(routeSnapshot, propertyPath);
|
|
195
|
-
return snapshot;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Finds the route instance for a route path (the portion of a path before the last `/`), creating it (and any missing
|
|
199
|
-
* parent instances) from the matching definition on first use.
|
|
200
|
-
* @returns The deepest route instance matched by a definition, or the root route when none matches
|
|
201
|
-
*/
|
|
202
|
-
GetRouteInstance(routePath) {
|
|
203
|
-
let route = this.routeCache.get(routePath);
|
|
204
|
-
if (route)
|
|
205
|
-
return route;
|
|
206
|
-
this.projectionOrder = undefined;
|
|
207
|
-
this.routeVersion++;
|
|
208
|
-
const segments = RouteSegments(routePath);
|
|
209
|
-
route = segments.length === 0 ? this.rootRoute : FindRoute(this.rootRoute, segments, 0, this.keyFunc) ?? this.rootRoute;
|
|
210
|
-
this.routeCache.set(routePath, route);
|
|
211
|
-
return route;
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Whether every route a projection's reads resolved through on its last full read still has
|
|
215
|
-
* the snapshot it read: a write anywhere under a route clears its cached snapshot, so then no
|
|
216
|
-
* read can have changed. A wildcard read also needs the same route instances to exist.
|
|
217
|
-
*/
|
|
218
|
-
SourcesUnchanged(sources) {
|
|
219
|
-
if (!sources)
|
|
220
|
-
return false;
|
|
221
|
-
for (let x = 0; x < sources.length; x++) {
|
|
222
|
-
const source = sources[x];
|
|
223
|
-
if (!source)
|
|
224
|
-
return false;
|
|
225
|
-
if ("matches" in source) {
|
|
226
|
-
if (source.version !== this.routeVersion)
|
|
227
|
-
return false;
|
|
228
|
-
for (let y = 0; y < source.matches.length; y++)
|
|
229
|
-
if (Snapshot.Peek(this.snapshot, source.matches[y].state) !== source.matches[y].snapshot)
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
else if (Snapshot.Peek(this.snapshot, source.state) !== source.snapshot)
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
return true;
|
|
77
|
+
this.projections.Run();
|
|
236
78
|
}
|
|
237
79
|
/**
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
80
|
+
* The changes since the last commit. The root is a flat record of keys, so each key's snapshot
|
|
81
|
+
* is taken and compared on its own, without building a snapshot of the whole root: a key no
|
|
82
|
+
* write touched has the same cached snapshot as the last commit, and is skipped.
|
|
241
83
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (IsWildcardPath(paths[x])) {
|
|
262
|
-
const source = this.ReadWildcard(paths[x], previous?.[x]);
|
|
263
|
-
values[x] = WildcardValue(source);
|
|
264
|
-
sources[x] = source;
|
|
84
|
+
Commit() {
|
|
85
|
+
const committed = this.commitedSnapshot;
|
|
86
|
+
const keys = Object.keys(this.rootState);
|
|
87
|
+
// Diffed key by key into one result
|
|
88
|
+
const diff = [];
|
|
89
|
+
const rootKeyFunc = this.routes.root.keyFunc;
|
|
90
|
+
const strategy = CommitStrategy(rootKeyFunc);
|
|
91
|
+
const identity = CommitIdentity(rootKeyFunc);
|
|
92
|
+
// How many of the committed keys are still in the root, to tell whether any are gone
|
|
93
|
+
let kept = 0;
|
|
94
|
+
for (let x = 0; x < keys.length; x++) {
|
|
95
|
+
const key = keys[x];
|
|
96
|
+
const had = committed.has(key);
|
|
97
|
+
had && kept++;
|
|
98
|
+
const last = committed.get(key);
|
|
99
|
+
if (had && KeyUnchanged(this.snapshot, this.rootState, key, last))
|
|
100
|
+
continue;
|
|
101
|
+
const next = Snapshot.Child(this.snapshot, this.rootState, key);
|
|
102
|
+
if (had && next === last)
|
|
265
103
|
continue;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
sources[x] = source;
|
|
269
|
-
if (!found)
|
|
270
|
-
return undefined;
|
|
271
|
-
values[x] = value;
|
|
104
|
+
JsonDiffInto(next, last, [key], diff, strategy, identity);
|
|
105
|
+
committed.set(key, next);
|
|
272
106
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
return [value, valueFound, { route, state: routeState, snapshot: routeSnapshot }];
|
|
107
|
+
if (kept < committed.size)
|
|
108
|
+
for (const key of committed.keys())
|
|
109
|
+
if (!Object.hasOwn(this.rootState, key)) {
|
|
110
|
+
committed.delete(key);
|
|
111
|
+
diff.push({
|
|
112
|
+
type: "delete",
|
|
113
|
+
path: [key]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return diff;
|
|
284
117
|
}
|
|
285
118
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* the
|
|
119
|
+
* The snapshot of the value at a path. Only the path's first property is snapshotted, not
|
|
120
|
+
* its whole route.
|
|
121
|
+
* @returns The snapshot, or undefined when any segment of the path doesn't exist
|
|
289
122
|
*/
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const matches = previous.matches.map(match => Snapshot.Peek(this.snapshot, match.state) === match.snapshot ? match : this.ReadMatch(match.segment, match.path));
|
|
293
|
-
return { version: this.routeVersion, matches };
|
|
294
|
-
}
|
|
295
|
-
const paths = ExpandWildcardPath(this.rootState, path);
|
|
296
|
-
const matches = [];
|
|
297
|
-
for (const segment in paths)
|
|
298
|
-
matches.push(this.ReadMatch(segment, paths[segment]));
|
|
299
|
-
return { version: this.routeVersion, matches };
|
|
300
|
-
}
|
|
301
|
-
ReadMatch(segment, path) {
|
|
302
|
-
const [value, found, source] = this.ReadValue(path);
|
|
303
|
-
// An expanded path's route exists, so a source is always found
|
|
304
|
-
return { ...source, segment, path, value, found };
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* The value of a wildcard read: each match's value keyed by its segment. A match missing the
|
|
309
|
-
* value is left out, so no match reads as an empty object.
|
|
310
|
-
*/
|
|
311
|
-
function WildcardValue(source) {
|
|
312
|
-
const values = {};
|
|
313
|
-
for (let x = 0; x < source.matches.length; x++)
|
|
314
|
-
if (source.matches[x].found)
|
|
315
|
-
values[source.matches[x].segment] = source.matches[x].value;
|
|
316
|
-
return values;
|
|
317
|
-
}
|
|
318
|
-
/** Whether every read value is the same as its last run; a wildcard read compares each matched value */
|
|
319
|
-
function SameValues(paths, values, lastValues) {
|
|
320
|
-
if (!lastValues)
|
|
321
|
-
return false;
|
|
322
|
-
for (let x = 0; x < values.length; x++)
|
|
323
|
-
if (values[x] !== lastValues[x] && !(IsWildcardPath(paths[x]) && SameEntries(values[x], lastValues[x])))
|
|
324
|
-
return false;
|
|
325
|
-
return true;
|
|
326
|
-
}
|
|
327
|
-
function SameEntries(values, lastValues) {
|
|
328
|
-
const keys = Object.keys(values);
|
|
329
|
-
if (keys.length !== Object.keys(lastValues).length)
|
|
330
|
-
return false;
|
|
331
|
-
for (let x = 0; x < keys.length; x++)
|
|
332
|
-
if (!Object.hasOwn(lastValues, keys[x]) || values[keys[x]] !== lastValues[keys[x]])
|
|
333
|
-
return false;
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Whether a read and a projection output overlap: the read's route matches the output's (see
|
|
338
|
-
* `RouteMatches` for wildcards), and one dot path contains the other. A read of the route's root
|
|
339
|
-
* object (`/channel/1/`) overlaps every output in that route, but not those of its child routes,
|
|
340
|
-
* which a snapshot of the route doesn't include.
|
|
341
|
-
*/
|
|
342
|
-
function PathsOverlap(read, output) {
|
|
343
|
-
const [readRoute, readProperty] = SplitRouteAndPath(read);
|
|
344
|
-
const [outputRoute, outputProperty] = SplitRouteAndPath(output);
|
|
345
|
-
return RouteMatches(readRoute, outputRoute) && PropertiesOverlap(readProperty, outputProperty);
|
|
346
|
-
}
|
|
347
|
-
/** What `RunKeyed` returns when no entry's output changed and none was added or removed */
|
|
348
|
-
const UNCHANGED = Symbol("UNCHANGED");
|
|
349
|
-
/**
|
|
350
|
-
* A keyed projection's output for a record: each entry's output, from its last run when its value
|
|
351
|
-
* is the same, and without the entries it returns undefined for. A value that isn't an object or
|
|
352
|
-
* array reads as an empty record.
|
|
353
|
-
* @returns The output, or `UNCHANGED` when it would hold the same entries as the last one written
|
|
354
|
-
*/
|
|
355
|
-
function RunKeyed(projection, state, record) {
|
|
356
|
-
const entries = new Map();
|
|
357
|
-
const output = {};
|
|
358
|
-
let changed = projection.values === undefined;
|
|
359
|
-
const keys = JsonType(record) === "value" ? [] : Object.keys(record);
|
|
360
|
-
for (let x = 0; x < keys.length; x++) {
|
|
361
|
-
const key = keys[x];
|
|
362
|
-
if (state.routeState && key.startsWith(PROJECTION_PREFIX))
|
|
363
|
-
continue;
|
|
364
|
-
const value = record[key];
|
|
365
|
-
const last = state.entries.get(key);
|
|
366
|
-
const entry = last && last.value === value ? last : { value, output: projection.projection(value, key) };
|
|
367
|
-
entries.set(key, entry);
|
|
368
|
-
changed ||= entry !== last && entry.output !== last?.output;
|
|
369
|
-
if (entry.output !== undefined)
|
|
370
|
-
SetOwnKey(output, key, entry.output);
|
|
371
|
-
}
|
|
372
|
-
// A key that's gone changes the output unless it was left out of it
|
|
373
|
-
for (const [key, last] of state.entries)
|
|
374
|
-
if (!entries.has(key) && last.output !== undefined)
|
|
375
|
-
changed = true;
|
|
376
|
-
state.entries = entries;
|
|
377
|
-
return changed ? output : UNCHANGED;
|
|
378
|
-
}
|
|
379
|
-
/** Sets a key as an own property, even `__proto__`, which plain assignment would make the prototype */
|
|
380
|
-
function SetOwnKey(target, key, value) {
|
|
381
|
-
if (key === "__proto__")
|
|
382
|
-
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
383
|
-
else
|
|
384
|
-
target[key] = value;
|
|
385
|
-
}
|
|
386
|
-
/** Whether a read's property path and an output's in a matching route overlap; see `PathsOverlap` */
|
|
387
|
-
function PropertiesOverlap(readProperty, outputProperty) {
|
|
388
|
-
return readProperty === "" ||
|
|
389
|
-
readProperty === outputProperty ||
|
|
390
|
-
readProperty.startsWith(`${outputProperty}.`) ||
|
|
391
|
-
outputProperty.startsWith(`${readProperty}.`);
|
|
392
|
-
}
|
|
393
|
-
function CollectProjections(route, byRoute) {
|
|
394
|
-
for (let x = 0; x < route.projections.length; x++) {
|
|
395
|
-
const [outputRoute] = SplitRouteAndPath(route.projections[x].output);
|
|
396
|
-
let projections = byRoute.get(outputRoute);
|
|
397
|
-
projections || byRoute.set(outputRoute, projections = []);
|
|
398
|
-
projections.push(route.projections[x]);
|
|
123
|
+
Snapshot(path) {
|
|
124
|
+
return ReadSnapshot(this.snapshot, this.rootState, path)[0];
|
|
399
125
|
}
|
|
400
|
-
for (const child of route.childRoutes.values())
|
|
401
|
-
CollectProjections(child, byRoute);
|
|
402
126
|
}
|
|
403
127
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
128
|
+
* How `Commit` diffs the root's snapshots for a Store with the same keyFunc:
|
|
129
|
+
* - An array whose first element is keyed is spliced, its elements matched by CommitIdentity.
|
|
130
|
+
* - A keyed value below a root key, with the same key as before, is skipped when the Store reads it
|
|
131
|
+
* through its root key (any key not starting with `/`): that root key's own diff carries the
|
|
132
|
+
* change. The Store doesn't read a route key through its key, so that one is diffed.
|
|
133
|
+
* - A keyed value with a different key is written.
|
|
134
|
+
* Each root key's own value is diffed, keyed or not: it's the entity's home.
|
|
407
135
|
*/
|
|
408
|
-
function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// The projections of every route a wildcard read's route matches, found once per read route
|
|
414
|
-
const wildcardCandidates = new Map();
|
|
415
|
-
const CandidatesOf = (readRoute) => {
|
|
416
|
-
let candidates = wildcardCandidates.get(readRoute);
|
|
417
|
-
if (!candidates) {
|
|
418
|
-
candidates = [];
|
|
419
|
-
for (const [outputRoute, projections] of byRoute)
|
|
420
|
-
if (RouteMatches(readRoute, outputRoute))
|
|
421
|
-
candidates.push(projections);
|
|
422
|
-
wildcardCandidates.set(readRoute, candidates);
|
|
423
|
-
}
|
|
424
|
-
return candidates;
|
|
425
|
-
};
|
|
426
|
-
function Visit(projection, stack) {
|
|
427
|
-
if (state.get(projection) === "done")
|
|
428
|
-
return;
|
|
429
|
-
if (state.get(projection) === "visiting")
|
|
430
|
-
throw new Error(`DiffTree projection cycle detected: ${stack.concat(projection.output).join(" -> ")}`);
|
|
431
|
-
state.set(projection, "visiting");
|
|
432
|
-
const next = stack.concat(projection.output);
|
|
433
|
-
// A keyed projection of its route's own state reads none of the route's projection outputs
|
|
434
|
-
for (let x = 0; !projection.keyed?.routeState && x < projection.reads.length; x++) {
|
|
435
|
-
const [readRoute, readProperty] = SplitRouteAndPath(projection.reads[x]);
|
|
436
|
-
// A wildcard read can match outputs in any number of routes; the others only in their own
|
|
437
|
-
const routeCandidates = IsWildcardPath(projection.reads[x]) ? CandidatesOf(readRoute) : [byRoute.get(readRoute) ?? []];
|
|
438
|
-
for (const candidates of routeCandidates)
|
|
439
|
-
for (let y = 0; y < candidates.length; y++)
|
|
440
|
-
if (candidates[y] !== projection && PropertiesOverlap(readProperty, SplitRouteAndPath(candidates[y].output)[1]))
|
|
441
|
-
Visit(candidates[y], next);
|
|
442
|
-
}
|
|
443
|
-
state.set(projection, "done");
|
|
444
|
-
order.push(projection);
|
|
445
|
-
}
|
|
446
|
-
for (const projections of byRoute.values())
|
|
447
|
-
for (let x = 0; x < projections.length; x++)
|
|
448
|
-
Visit(projections[x], []);
|
|
449
|
-
return order;
|
|
450
|
-
}
|
|
451
|
-
function FindRoute(parent, segments, index, defaultKeyFunc) {
|
|
452
|
-
let route = FindChildRoute(parent, segments, index);
|
|
453
|
-
if (!route) {
|
|
454
|
-
const match = MatchRouteDefinition(parent.routeDefinitions, segments, index);
|
|
455
|
-
if (!match)
|
|
136
|
+
function CommitStrategy(keyFunc) {
|
|
137
|
+
return keyFunc && function (newValue, oldValue, path) {
|
|
138
|
+
if (Array.isArray(newValue))
|
|
139
|
+
return newValue.length > 0 && JsonType(newValue[0]) !== "value" && keyFunc(newValue[0]) !== undefined ? "splice" : undefined;
|
|
140
|
+
if (path.length <= 1)
|
|
456
141
|
return undefined;
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
const nextIndex = index + route.parts.length;
|
|
465
|
-
// Segments below the deepest defined route belong to it
|
|
466
|
-
return nextIndex === segments.length ? route : FindRoute(route, segments, nextIndex, defaultKeyFunc) ?? route;
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* The existing child instance the segments from `index` name, looked up by key for each number
|
|
470
|
-
* of segments a child pattern spans, so finding one doesn't scan its siblings.
|
|
471
|
-
*/
|
|
472
|
-
function FindChildRoute(parent, segments, index) {
|
|
473
|
-
for (let x = 0; x < parent.childPartCounts.length; x++) {
|
|
474
|
-
const count = parent.childPartCounts[x];
|
|
475
|
-
if (index + count > segments.length)
|
|
476
|
-
continue;
|
|
477
|
-
const route = parent.childRoutes.get(segments.slice(index, index + count).join("/"));
|
|
478
|
-
if (route)
|
|
479
|
-
return route;
|
|
480
|
-
}
|
|
481
|
-
return undefined;
|
|
482
|
-
}
|
|
483
|
-
/** Removes every route instance at or below a route path from the instances under `route` */
|
|
484
|
-
function RemoveRouteInstances(route, routePath) {
|
|
485
|
-
for (const [key, child] of route.childRoutes) {
|
|
486
|
-
if (child.path.startsWith(routePath))
|
|
487
|
-
route.childRoutes.delete(key);
|
|
488
|
-
else if (routePath.startsWith(child.path))
|
|
489
|
-
RemoveRouteInstances(child, routePath);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
/** The distinct numbers of segments the patterns in a definition map span, in definition order */
|
|
493
|
-
function PartCounts(definitions) {
|
|
494
|
-
const counts = [];
|
|
495
|
-
for (const pattern in definitions) {
|
|
496
|
-
const count = RouteSegments(pattern).length;
|
|
497
|
-
// The root pattern has no segments and is never a child
|
|
498
|
-
if (count > 0 && !counts.includes(count))
|
|
499
|
-
counts.push(count);
|
|
500
|
-
}
|
|
501
|
-
return counts;
|
|
502
|
-
}
|
|
503
|
-
function MatchRouteDefinition(definitions, segments, index) {
|
|
504
|
-
if (!definitions)
|
|
505
|
-
return undefined;
|
|
506
|
-
for (const pattern in definitions) {
|
|
507
|
-
const patternParts = RouteSegments(pattern);
|
|
508
|
-
// The root pattern has no segments and would match anything
|
|
509
|
-
if (patternParts.length === 0 || patternParts.length > segments.length - index)
|
|
510
|
-
continue;
|
|
511
|
-
let x = 0;
|
|
512
|
-
for (; x < patternParts.length && (IsParam(patternParts[x]) || patternParts[x] === segments[index + x]); x++)
|
|
513
|
-
;
|
|
514
|
-
if (x === patternParts.length)
|
|
515
|
-
return { definition: definitions[pattern], length: patternParts.length };
|
|
516
|
-
}
|
|
517
|
-
return undefined;
|
|
142
|
+
const key = keyFunc(newValue);
|
|
143
|
+
if (key === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (key !== (JsonType(oldValue) === "value" ? undefined : keyFunc(oldValue)))
|
|
146
|
+
return "write";
|
|
147
|
+
return key.startsWith(ROOT_ROUTE) ? undefined : "skip";
|
|
148
|
+
};
|
|
518
149
|
}
|
|
519
150
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
151
|
+
* What `Commit` matches a spliced array's elements by:
|
|
152
|
+
* - An entity the Store reads by its root key (any key not starting with `/`) is matched by that
|
|
153
|
+
* key. Editing it leaves the array unchanged, and its root key's own diff carries the change. An
|
|
154
|
+
* entity whose key changed doesn't match, so it's deleted and the new one inserted.
|
|
155
|
+
* - Anything else, route-key entities included, is matched by ===: the Store doesn't read a route
|
|
156
|
+
* key through its key, so a changed one must be replaced.
|
|
523
157
|
*/
|
|
524
|
-
function
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
const { read, projection } = projectionDefinition;
|
|
531
|
-
if (read !== undefined && IsWildcardPath(read))
|
|
532
|
-
throw new Error(`Invalid keyed projection read ${read}: a keyed projection reads one record, without a wildcard.`);
|
|
533
|
-
// Its route's own state, without the outputs, so its own output isn't one of its reads
|
|
534
|
-
const routeRead = JoinPaths(routePath, read ?? "");
|
|
535
|
-
projections.push({
|
|
536
|
-
key,
|
|
537
|
-
reads: [routeRead],
|
|
538
|
-
projection,
|
|
539
|
-
output,
|
|
540
|
-
readsOutput: read !== undefined && PathsOverlap(routeRead, output),
|
|
541
|
-
keyed: { entries: new Map(), routeState: read === undefined }
|
|
542
|
-
});
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
const { reads, projection } = projectionDefinition;
|
|
546
|
-
reads.forEach(ValidateWildcardPath);
|
|
547
|
-
const routeReads = reads.map(read => JoinPaths(routePath, read));
|
|
548
|
-
projections.push({ key, reads: routeReads, projection, output, readsOutput: routeReads.some(read => PathsOverlap(read, output)) });
|
|
549
|
-
}
|
|
550
|
-
return {
|
|
551
|
-
path: routePath,
|
|
552
|
-
parts,
|
|
553
|
-
projections,
|
|
554
|
-
childRoutes: new Map(),
|
|
555
|
-
childPartCounts: PartCounts(definition.routes),
|
|
556
|
-
keyFunc: definition.keyFunc ?? defaultKeyFunc,
|
|
557
|
-
routeDefinitions: definition.routes
|
|
158
|
+
function CommitIdentity(keyFunc) {
|
|
159
|
+
return keyFunc && function (value) {
|
|
160
|
+
if (JsonType(value) === "value")
|
|
161
|
+
return value;
|
|
162
|
+
const key = keyFunc(value);
|
|
163
|
+
return key === undefined || key.startsWith(ROOT_ROUTE) ? value : key;
|
|
558
164
|
};
|
|
559
165
|
}
|
|
560
|
-
/**
|
|
561
|
-
function
|
|
562
|
-
|
|
563
|
-
}
|
|
564
|
-
function IsParam(part) {
|
|
565
|
-
return part.startsWith("{") && part.endsWith("}");
|
|
566
|
-
}
|
|
567
|
-
function RouteSegments(route) {
|
|
568
|
-
return route.split("/").filter(part => part !== "");
|
|
166
|
+
/** Projection outputs are written only by RunProjections */
|
|
167
|
+
function AssertWritable(propertyPath) {
|
|
168
|
+
if (propertyPath.startsWith(PROJECTION_PREFIX))
|
|
169
|
+
throw new Error(`${PROJECTION_PREFIX} keys are read-only.`);
|
|
569
170
|
}
|