j-templates 8.1.2 → 8.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/SYNTAX_PRIMER.md +3 -4
- package/Store/Diff/diffTree.d.ts +65 -34
- package/Store/Diff/diffTree.js +528 -417
- package/Store/Diff/diffTree.types.d.ts +5 -79
- package/Store/Diff/diffTree.types.js +1 -3
- package/Store/Diff/diffTreeWorker.d.ts +4 -0
- package/Store/Diff/diffTreeWorker.js +17 -39
- package/Store/Diff/diffTreeWriter.d.ts +27 -38
- package/Store/Diff/diffTreeWriter.js +44 -95
- package/Store/Diff/index.d.ts +1 -0
- package/Store/Diff/index.js +1 -0
- package/Store/Diff/utils/diffTree.types.d.ts +108 -0
- package/Store/Diff/utils/diffTree.types.js +1 -0
- package/Store/Diff/utils/diffTree.utils.d.ts +79 -0
- package/Store/Diff/utils/diffTree.utils.js +398 -0
- package/Store/Store/pushStore.d.ts +1 -5
- package/Store/Store/pushStore.js +3 -22
- package/Store/Store/store.d.ts +2 -4
- package/Store/Store/store.js +12 -11
- package/Store/Store/storeAsync.d.ts +4 -5
- package/Store/Store/storeAsync.js +12 -30
- package/Store/Store/storeSync.d.ts +14 -9
- package/Store/Store/storeSync.js +24 -19
- package/Store/Tree/observableNode.d.ts +2 -23
- package/Store/Tree/observableNode.js +41 -181
- package/Store/Tree/snapshot.d.ts +70 -0
- package/Store/Tree/snapshot.js +141 -0
- package/Utils/json.d.ts +37 -7
- package/Utils/json.js +151 -13
- package/package.json +1 -1
- package/Store/Diff/diffSync.d.ts +0 -36
- package/Store/Diff/diffSync.js +0 -43
package/Store/Diff/diffTree.js
CHANGED
|
@@ -1,458 +1,569 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { PROJECTION_PREFIX } from
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
FlattenValue(root, typedObject[keys[x]], keyFunc);
|
|
1
|
+
import { JsonDiff, JsonMerge, JsonType } from '../../Utils/json';
|
|
2
|
+
import { Snapshot } from '../Tree/snapshot';
|
|
3
|
+
import { ApplyDiff, ApplyValueToPath, CreateKeyResolver, DeleteRouteState, ExpandWildcardPath, IsWildcardPath, JoinPaths, PROJECTION_PREFIX, ReadPath, ReadPathFound, ROOT_ROUTE, RouteMatches, SplitRouteAndPath, ToSnapshotPath, ValidateWildcardPath, WritePath } from './utils/diffTree.utils';
|
|
4
|
+
export class DiffTree {
|
|
5
|
+
constructor(keyFunc, routeDefinitions = {}) {
|
|
6
|
+
this.keyFunc = keyFunc;
|
|
7
|
+
this.routeDefinitions = routeDefinitions;
|
|
8
|
+
this.rootState = {};
|
|
9
|
+
this.commitedSnapshot = {};
|
|
10
|
+
// Definitions are fixed and instances are never removed, so a route path always finds the same instance
|
|
11
|
+
this.routeCache = new Map();
|
|
12
|
+
this.routeKeyFunc = (routePath) => this.GetRouteInstance(routePath).keyFunc;
|
|
13
|
+
this.resolver = CreateKeyResolver(this.rootState, this.routeKeyFunc);
|
|
14
|
+
this.snapshot = Snapshot.Create(this.resolver);
|
|
15
|
+
// Changes whenever the order is reset, so a wildcard read can tell its matches may have changed
|
|
16
|
+
this.routeVersion = 0;
|
|
17
|
+
if (!Object.hasOwn(this.routeDefinitions, ROOT_ROUTE))
|
|
18
|
+
this.routeDefinitions = { ...this.routeDefinitions, [ROOT_ROUTE]: { projections: {} } };
|
|
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);
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
27
|
+
WritePath(path, value) {
|
|
28
|
+
const [routePath, propertyPath] = SplitRouteAndPath(path);
|
|
29
|
+
if (propertyPath.startsWith(PROJECTION_PREFIX))
|
|
30
|
+
throw new Error(`${PROJECTION_PREFIX} keys are read-only.`);
|
|
31
|
+
this.GetRouteInstance(routePath);
|
|
32
|
+
// Keyed entities are local to the route written to; a key starting with "/" is a route path from the root
|
|
33
|
+
ApplyValueToPath(this.snapshot, this.rootState, this.routeKeyFunc, routePath, path, value);
|
|
34
|
+
}
|
|
35
|
+
PatchPath(path, patch) {
|
|
36
|
+
const value = this.Snapshot(path);
|
|
37
|
+
const merged = JsonMerge(value, patch);
|
|
38
|
+
this.WritePath(path, merged);
|
|
39
|
+
}
|
|
40
|
+
PushPath(path, items) {
|
|
41
|
+
const [routePath, valuePath] = SplitRouteAndPath(path);
|
|
42
|
+
if (valuePath.startsWith(PROJECTION_PREFIX))
|
|
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); */
|
|
68
|
+
}
|
|
69
|
+
SplicePath(path, start, deleteCount, items) {
|
|
70
|
+
const [routePath, valuePath] = SplitRouteAndPath(path);
|
|
71
|
+
if (valuePath.startsWith(PROJECTION_PREFIX))
|
|
72
|
+
throw new Error(`${PROJECTION_PREFIX} keys are read-only.`);
|
|
73
|
+
const combinedRoutePath = JoinPaths("", routePath);
|
|
74
|
+
const routeRoot = this.ReadPath(combinedRoutePath);
|
|
75
|
+
const routeRootSnapshot = this.Snapshot(combinedRoutePath);
|
|
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);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Deletes a route: its state, its child routes and projection outputs, and its route
|
|
108
|
+
* instances. The projection order is rebuilt on the next `RunProjections`, so deleting
|
|
109
|
+
* several routes in a row sorts once.
|
|
110
|
+
* @param routePath The route to delete, e.g. `/channel/1/`
|
|
111
|
+
* @throws When the path is the root route or has a property path, e.g. `/channel/1/name`
|
|
112
|
+
*/
|
|
113
|
+
DeleteRoute(routePath) {
|
|
114
|
+
const [route, propertyPath] = SplitRouteAndPath(routePath);
|
|
115
|
+
const fullRoute = JoinPaths("", route);
|
|
116
|
+
if (propertyPath || fullRoute === ROOT_ROUTE)
|
|
117
|
+
throw new Error(`Unable to delete ${routePath}: only a non-root route path, e.g. /channel/1/, can be deleted.`);
|
|
118
|
+
DeleteRouteState(this.snapshot, this.rootState, fullRoute);
|
|
119
|
+
RemoveRouteInstances(this.rootRoute, fullRoute);
|
|
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++;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Reads the raw value at a path, root (`value.prop`) or routed (`/channel/1/value.prop`).
|
|
128
|
+
* @returns The value, or undefined when any segment of the path doesn't exist
|
|
129
|
+
*/
|
|
130
|
+
ReadPath(path) {
|
|
131
|
+
return ReadPath(this.rootState, path);
|
|
132
|
+
}
|
|
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
|
+
*/
|
|
140
|
+
RunProjections() {
|
|
141
|
+
const order = this.projectionOrder ??= OrderProjections(this.rootRoute);
|
|
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;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Takes a new snapshot of every source in the route a projection just wrote its output to. The
|
|
239
|
+
* write cleared that route's snapshot, but nothing the projection read changed between its read
|
|
240
|
+
* and the write, so without this the next run would read everything again for nothing.
|
|
241
|
+
*/
|
|
242
|
+
RefreshSources(sources, outputRoute) {
|
|
243
|
+
for (let x = 0; x < sources.length; x++) {
|
|
244
|
+
const source = sources[x];
|
|
245
|
+
const routes = source && ("matches" in source ? source.matches : [source]);
|
|
246
|
+
for (let y = 0; routes && y < routes.length; y++)
|
|
247
|
+
if (routes[y].route === outputRoute)
|
|
248
|
+
routes[y].snapshot = Snapshot.Get(this.snapshot, routes[y].state, this.resolver, ToSnapshotPath(outputRoute));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Snapshots of the values at each path, read through the route's root object so keyed
|
|
253
|
+
* values resolve through their own copy.
|
|
254
|
+
* @param sources Filled with what each read resolved through; a read after a missing one is left undefined
|
|
255
|
+
* @param previous The sources of the last full read, so a wildcard read reuses its unchanged matches
|
|
256
|
+
* @returns The values, or undefined when any of them is missing
|
|
257
|
+
*/
|
|
258
|
+
ReadValues(paths, sources, previous) {
|
|
259
|
+
const values = new Array(paths.length);
|
|
260
|
+
for (let x = 0; x < paths.length; x++) {
|
|
261
|
+
if (IsWildcardPath(paths[x])) {
|
|
262
|
+
const source = this.ReadWildcard(paths[x], previous?.[x]);
|
|
263
|
+
values[x] = WildcardValue(source);
|
|
264
|
+
sources[x] = source;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const [value, found, source] = this.ReadValue(paths[x]);
|
|
268
|
+
sources[x] = source;
|
|
269
|
+
if (!found)
|
|
270
|
+
return undefined;
|
|
271
|
+
values[x] = value;
|
|
272
|
+
}
|
|
273
|
+
return values;
|
|
274
|
+
}
|
|
275
|
+
/** The value at a path, whether it was found, and the route it was read through, when there is one */
|
|
276
|
+
ReadValue(path) {
|
|
277
|
+
const [route, property] = SplitRouteAndPath(path);
|
|
278
|
+
const [routeState, found] = ReadPathFound(this.rootState, route);
|
|
279
|
+
if (!found || !routeState)
|
|
280
|
+
return [undefined, false, undefined];
|
|
281
|
+
const routeSnapshot = Snapshot.Get(this.snapshot, routeState, this.resolver, ToSnapshotPath(route));
|
|
282
|
+
const [value, valueFound] = ReadPathFound(routeSnapshot, property);
|
|
283
|
+
return [value, valueFound, { route, state: routeState, snapshot: routeSnapshot }];
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Reads every route a wildcard path matches. While no route instance was added or removed since
|
|
287
|
+
* the last read, the matches are the same, and a match whose route has the same snapshot keeps
|
|
288
|
+
* the value it read.
|
|
289
|
+
*/
|
|
290
|
+
ReadWildcard(path, previous) {
|
|
291
|
+
if (previous && previous.version === this.routeVersion) {
|
|
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 };
|
|
47
305
|
}
|
|
48
|
-
else
|
|
49
|
-
curr = undefined;
|
|
50
|
-
return [curr, x === parts.length];
|
|
51
306
|
}
|
|
52
307
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @param path - Array of path segments (strings for keys, numbers for indices)
|
|
56
|
-
* @param value - The value to set
|
|
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.
|
|
57
310
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
curr = curr[path[x]];
|
|
65
|
-
curr[path[x]] = value;
|
|
66
|
-
InvalidateSnapshotForValue(curr);
|
|
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;
|
|
67
317
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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;
|
|
75
335
|
}
|
|
76
336
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
81
341
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
return;
|
|
87
|
-
valueSnapshotHierarchy.delete(value);
|
|
88
|
-
for (const parent of parents)
|
|
89
|
-
if (valueSnapshotCache.has(parent))
|
|
90
|
-
InvalidateSnapshotForValue(parent);
|
|
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);
|
|
91
346
|
}
|
|
347
|
+
/** What `RunKeyed` returns when no entry's output changed and none was added or removed */
|
|
348
|
+
const UNCHANGED = Symbol("UNCHANGED");
|
|
92
349
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* resolves it to, the parallel of ObservableNode's `alias`, so every reference to the same
|
|
98
|
-
* entity shares one snapshot and a write to the entity invalidates every holder.
|
|
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
|
|
99
354
|
*/
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
snapshot[keys[x]] = ChildSnapshot(source, keyFunc, value, value[keys[x]]);
|
|
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);
|
|
117
371
|
}
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
120
378
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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]);
|
|
140
399
|
}
|
|
141
|
-
|
|
400
|
+
for (const child of route.childRoutes.values())
|
|
401
|
+
CollectProjections(child, byRoute);
|
|
142
402
|
}
|
|
143
403
|
/**
|
|
144
|
-
* Topologically sorts
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* read of an id that isn't registered) is rejected immediately instead of on first write.
|
|
148
|
-
* Also validates each projection's wildcard reads here, at construction: a wildcard must be
|
|
149
|
-
* the final read, must not have dot nesting, and can't target a projection's
|
|
150
|
-
* `$projection_<id>` key.
|
|
404
|
+
* Topologically sorts every projection under a route: a projection depends on every other
|
|
405
|
+
* projection whose output overlaps one of its reads, so that projection is sorted earlier.
|
|
406
|
+
* @throws When projections depend on each other in a cycle
|
|
151
407
|
*/
|
|
152
|
-
function
|
|
408
|
+
function OrderProjections(rootRoute) {
|
|
409
|
+
const byRoute = new Map();
|
|
410
|
+
CollectProjections(rootRoute, byRoute);
|
|
153
411
|
const order = [];
|
|
154
412
|
const state = new Map();
|
|
155
|
-
|
|
156
|
-
|
|
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")
|
|
157
428
|
return;
|
|
158
|
-
if (state.get(
|
|
159
|
-
throw new Error(`DiffTree projection cycle detected: ${stack.concat(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (root.startsWith(PROJECTION_PREFIX) && read.endsWith("*"))
|
|
172
|
-
throw `Projection keys do not support wildcard reads: ${root}`;
|
|
173
|
-
if (root.startsWith(PROJECTION_PREFIX))
|
|
174
|
-
Visit(root.slice(PROJECTION_PREFIX.length), stack.concat(id));
|
|
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);
|
|
175
442
|
}
|
|
176
|
-
state.set(
|
|
177
|
-
order.push(
|
|
443
|
+
state.set(projection, "done");
|
|
444
|
+
order.push(projection);
|
|
178
445
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
446
|
+
for (const projections of byRoute.values())
|
|
447
|
+
for (let x = 0; x < projections.length; x++)
|
|
448
|
+
Visit(projections[x], []);
|
|
182
449
|
return order;
|
|
183
450
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
*
|
|
197
|
-
* A projection whose `reads` names a root key that doesn't exist yet (a plain path never
|
|
198
|
-
* written, an entity never seen, or another projection that hasn't produced a result) is
|
|
199
|
-
* not run at all, so a projection's author never has to guard every parameter against
|
|
200
|
-
* missing data. It's tried again on every later write until every read exists. This also
|
|
201
|
-
* makes a blocked dependency propagate for free: a projection that never runs never writes
|
|
202
|
-
* `$projection_<id>`, so anything chaining off it stays blocked in turn.
|
|
203
|
-
*
|
|
204
|
-
* A trailing wildcard read is expanded against the current root keys before the projection
|
|
205
|
-
* runs: every root key matching the prefix is read and appended as its own key/value pair,
|
|
206
|
-
* so the projection is called with `(keys, ...values)` where `keys` is every resolved read
|
|
207
|
-
* path (fixed reads first, then each matched wildcard key) positional with `values`.
|
|
208
|
-
*
|
|
209
|
-
* @param source - The root tree object
|
|
210
|
-
* @param keyFunc - Optional function to extract a key from objects, for reading through roots
|
|
211
|
-
* @param projections - Dependency-sorted projection entries, from `BuildProjectionOrder`
|
|
212
|
-
* @param memo - Per-tree, cross-call memo of each projection's last-read values, by id,
|
|
213
|
-
* positional with its `reads`
|
|
214
|
-
* @returns Diff entries for every projection whose result changed
|
|
215
|
-
*/
|
|
216
|
-
function RunProjections(source, keyFunc, projections, memo) {
|
|
217
|
-
let diffResult = [];
|
|
218
|
-
for (let x = 0; x < projections.length; x++) {
|
|
219
|
-
const { id, reads, projection } = projections[x];
|
|
220
|
-
const values = [];
|
|
221
|
-
const keys = [];
|
|
222
|
-
let allPathsExist = true;
|
|
223
|
-
let y = 0;
|
|
224
|
-
for (; allPathsExist && y < reads.length && !reads[y].endsWith("*"); y++) {
|
|
225
|
-
const read = reads[y];
|
|
226
|
-
const [value, found] = GetPathValue(source, read, keyFunc);
|
|
227
|
-
allPathsExist = found;
|
|
228
|
-
keys.push(read);
|
|
229
|
-
values.push((found && CreateSnapshot(source, keyFunc, value)) || value);
|
|
230
|
-
}
|
|
231
|
-
if (y < reads.length && reads[y].endsWith("*")) {
|
|
232
|
-
const prefix = reads[y].slice(0, reads[y].length - 1);
|
|
233
|
-
const matchedKeys = Object.keys(source).filter(key => key.startsWith(prefix));
|
|
234
|
-
for (let z = 0; z < matchedKeys.length; z++) {
|
|
235
|
-
// No keyFunc passed because dot paths are not supported for wildcard paths
|
|
236
|
-
const [value, found] = GetPathValue(source, matchedKeys[z]);
|
|
237
|
-
allPathsExist = allPathsExist && found;
|
|
238
|
-
keys.push(matchedKeys[z]);
|
|
239
|
-
values.push((found && CreateSnapshot(source, keyFunc, value)) || value);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (allPathsExist) {
|
|
243
|
-
const priorValues = memo.get(id);
|
|
244
|
-
if (!ArraysEqual(priorValues, values)) {
|
|
245
|
-
const result = projection(keys, ...values);
|
|
246
|
-
memo.set(id, values);
|
|
247
|
-
const diff = UpdateSource(source, PROJECTION_PREFIX + id, result, keyFunc);
|
|
248
|
-
diffResult = diffResult.concat(diff);
|
|
249
|
-
// The same diff-and-apply UpdateSource uses for any other write, so a keyed entity
|
|
250
|
-
// embedded in a projection's result is flattened to its own root like any other write,
|
|
251
|
-
// and the diff reported here is unfiltered, the same as DiffPath returns.
|
|
252
|
-
/* diffResult.push(
|
|
253
|
-
...UpdateSource(
|
|
254
|
-
source,
|
|
255
|
-
PROJECTION_PREFIX + id,
|
|
256
|
-
result,
|
|
257
|
-
keyFunc,
|
|
258
|
-
),
|
|
259
|
-
); */
|
|
260
|
-
}
|
|
261
|
-
}
|
|
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)
|
|
456
|
+
return undefined;
|
|
457
|
+
const end = index + match.length;
|
|
458
|
+
const parts = segments.slice(index, end);
|
|
459
|
+
route = CreateRoute(parts, `/${segments.slice(0, end).join("/")}/`, match.definition, defaultKeyFunc);
|
|
460
|
+
// Kept even when a deeper segment matches no definition, so a write below an undefined
|
|
461
|
+
// child route still creates the parent routes whose projections may read it
|
|
462
|
+
parent.childRoutes.set(parts.join("/"), route);
|
|
262
463
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (keyFunc === undefined || JsonType(value) === 'value')
|
|
267
|
-
return value;
|
|
268
|
-
const key = keyFunc(value);
|
|
269
|
-
return (key && source[key]) ?? value;
|
|
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;
|
|
270
467
|
}
|
|
271
468
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* @param source - The source object
|
|
275
|
-
* @param path - Dot-separated path
|
|
276
|
-
* @param keyFunc - Function to extract key from objects
|
|
277
|
-
* @returns The resolved path (possibly shortened to root key)
|
|
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.
|
|
278
471
|
*/
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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;
|
|
286
480
|
}
|
|
287
|
-
|
|
288
|
-
for (; y >= 0 && !(JsonType(pathValues[y]) === "object" && keyFunc(pathValues[y])); y--) { }
|
|
289
|
-
if (y >= 0) {
|
|
290
|
-
const key = keyFunc(pathValues[y]);
|
|
291
|
-
parts.splice(0, y + 1, key);
|
|
292
|
-
return parts.join(".");
|
|
293
|
-
}
|
|
294
|
-
return path;
|
|
481
|
+
return undefined;
|
|
295
482
|
}
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
* @returns Diff results showing all changes made
|
|
304
|
-
*/
|
|
305
|
-
function UpdateSource(source, path, value, keyFunc) {
|
|
306
|
-
let diffResult = [];
|
|
307
|
-
if (keyFunc) {
|
|
308
|
-
const keyPath = ResolveKeyPath(source, path, keyFunc);
|
|
309
|
-
if (keyPath !== path) {
|
|
310
|
-
const keyDiffResult = UpdateSource(source, keyPath, value, keyFunc);
|
|
311
|
-
diffResult = diffResult.concat(keyDiffResult);
|
|
312
|
-
// diffResult.push(...keyDiffResult);
|
|
313
|
-
}
|
|
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);
|
|
314
490
|
}
|
|
315
|
-
const [sourceValue] = GetPathValue(source, path);
|
|
316
|
-
JsonDiff(value, sourceValue, path, diffResult);
|
|
317
|
-
if (keyFunc) {
|
|
318
|
-
let flattened = {};
|
|
319
|
-
flattened = FlattenValue(flattened, value, keyFunc);
|
|
320
|
-
flattened = JsonDeepClone(flattened);
|
|
321
|
-
const keys = Object.keys(flattened);
|
|
322
|
-
for (let x = 0; x < keys.length; x++)
|
|
323
|
-
JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], diffResult);
|
|
324
|
-
}
|
|
325
|
-
const filteredDiffResult = diffResult.filter((diff) => diff.value !== undefined);
|
|
326
|
-
for (let x = 0; x < filteredDiffResult.length; x++) {
|
|
327
|
-
SetPathValue(source, filteredDiffResult[x].path, filteredDiffResult[x].value);
|
|
328
|
-
}
|
|
329
|
-
return diffResult;
|
|
330
491
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
path: `${path}.${sourceValue.length + index}`.split('.'),
|
|
340
|
-
value
|
|
341
|
-
};
|
|
342
|
-
});
|
|
343
|
-
sourceValue.push(...items);
|
|
344
|
-
if (items.length > 0)
|
|
345
|
-
results.push({
|
|
346
|
-
path: `${path}.length`.split('.'),
|
|
347
|
-
value: sourceValue.length
|
|
348
|
-
});
|
|
349
|
-
if (keyFunc) {
|
|
350
|
-
const flattened = JsonDeepClone(FlattenValue({}, items, keyFunc));
|
|
351
|
-
const keys = Object.keys(flattened);
|
|
352
|
-
for (let x = 0; x < keys.length; x++)
|
|
353
|
-
JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], results);
|
|
354
|
-
}
|
|
355
|
-
const filteredDiffResult = results.filter((diff) => diff.value !== undefined);
|
|
356
|
-
for (let x = 0; x < filteredDiffResult.length; x++) {
|
|
357
|
-
SetPathValue(source, filteredDiffResult[x].path, filteredDiffResult[x].value);
|
|
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);
|
|
358
500
|
}
|
|
359
|
-
return
|
|
501
|
+
return counts;
|
|
360
502
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
diffResult: [],
|
|
375
|
-
};
|
|
376
|
-
spliceResult.spliceResult = sourceValue.splice(start, deleteCount, ...items);
|
|
377
|
-
InvalidateSnapshotForValue(sourceValue);
|
|
378
|
-
if (keyFunc) {
|
|
379
|
-
// Register the keyed entities the new items carry, diffed against the roots
|
|
380
|
-
// already held: a new entity is added whole, one re-inserted unchanged (an undo)
|
|
381
|
-
// produces no diff, so its readers aren't woken.
|
|
382
|
-
const flattened = JsonDeepClone(FlattenValue({}, items, keyFunc));
|
|
383
|
-
const keys = Object.keys(flattened);
|
|
384
|
-
for (let x = 0; x < keys.length; x++)
|
|
385
|
-
JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], spliceResult.diffResult);
|
|
386
|
-
}
|
|
387
|
-
const filteredDiffResult = spliceResult.diffResult.filter((diff) => diff.value !== undefined);
|
|
388
|
-
for (let x = 0; x < filteredDiffResult.length; x++) {
|
|
389
|
-
SetPathValue(source, filteredDiffResult[x].path, filteredDiffResult[x].value);
|
|
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 };
|
|
390
516
|
}
|
|
391
|
-
return
|
|
517
|
+
return undefined;
|
|
392
518
|
}
|
|
393
519
|
/**
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* thread with `ConnectWorkerToDiffTree` (see `defaultDiffTreeWorker.ts` for the no-argument
|
|
398
|
-
* case, or a custom entry file when either is needed).
|
|
520
|
+
* @param routePath The instance's full route from the tree root, e.g. `/Root/1234/`. Each projection
|
|
521
|
+
* read is resolved against it, so `name` becomes `/Root/1234/name`; a read starting with `/` is kept as-is.
|
|
522
|
+
* @param defaultKeyFunc The tree's keyFunc, used when the definition doesn't define one
|
|
399
523
|
*/
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
return UpdateSource(this.rootState, path, value, this.keyFunc);
|
|
426
|
-
}
|
|
427
|
-
PatchPath(path, patch) {
|
|
428
|
-
const value = this.Snapshot(path);
|
|
429
|
-
const merged = JsonMerge(value, patch);
|
|
430
|
-
return UpdateSource(this.rootState, path, merged, this.keyFunc);
|
|
431
|
-
}
|
|
432
|
-
PushPath(path, items) {
|
|
433
|
-
return PushSource(this.rootState, path, items, this.keyFunc);
|
|
434
|
-
}
|
|
435
|
-
SplicePath(path, start, deleteCount, items) {
|
|
436
|
-
return SpliceSource(this.rootState, path, start, deleteCount, items, this.keyFunc);
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Retrieves the current value at a path.
|
|
440
|
-
* @param path - Dot-separated path to the value
|
|
441
|
-
* @returns The value at the specified path
|
|
442
|
-
*/
|
|
443
|
-
GetPath(path) {
|
|
444
|
-
const [value] = GetPathValue(this.rootState, path, this.keyFunc);
|
|
445
|
-
return value;
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Returns an immutable, cached snapshot of the value at a path (empty string for the
|
|
449
|
-
* whole tree). Not part of `IDiffTree`; internal, for a future feature.
|
|
450
|
-
* @param path - Dot-separated path to the value
|
|
451
|
-
*/
|
|
452
|
-
Snapshot(path) {
|
|
453
|
-
const [value, found] = GetPathValue(this.rootState, path, this.keyFunc);
|
|
454
|
-
if (!found)
|
|
455
|
-
throw `Unable to get snapshot for path ${path}`;
|
|
456
|
-
return CreateSnapshot(this.rootState, this.keyFunc, value);
|
|
524
|
+
function CreateRoute(parts, routePath, definition, defaultKeyFunc) {
|
|
525
|
+
const projections = [];
|
|
526
|
+
for (const key in definition.projections) {
|
|
527
|
+
const projectionDefinition = definition.projections[key];
|
|
528
|
+
const output = JoinPaths(routePath, `${PROJECTION_PREFIX}${key}`);
|
|
529
|
+
if (IsKeyedDefinition(projectionDefinition)) {
|
|
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)) });
|
|
457
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
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** Whether a projection definition is a keyed projection: it has no `reads` */
|
|
561
|
+
function IsKeyedDefinition(definition) {
|
|
562
|
+
return definition.reads === undefined;
|
|
563
|
+
}
|
|
564
|
+
function IsParam(part) {
|
|
565
|
+
return part.startsWith("{") && part.endsWith("}");
|
|
566
|
+
}
|
|
567
|
+
function RouteSegments(route) {
|
|
568
|
+
return route.split("/").filter(part => part !== "");
|
|
458
569
|
}
|