j-templates 8.1.1 → 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.
@@ -1,448 +1,569 @@
1
- import { ArraysEqual } from "../../Utils/array";
2
- import { JsonDeepClone, JsonDiff, JsonMerge, JsonType } from "../../Utils/json";
3
- import { PROJECTION_PREFIX } from "./diffTree.types";
4
- /**
5
- * Flattens nested objects/arrays, extracting keyed objects to root.
6
- * @param root - Root object to store flattened values
7
- * @param value - Value to flatten
8
- * @param keyFunc - Function to extract key from objects
9
- * @returns The root object with flattened values
10
- */
11
- function FlattenValue(root, value, keyFunc) {
12
- const type = JsonType(value);
13
- switch (type) {
14
- case "array":
15
- const typedArray = value;
16
- for (let x = 0; x < typedArray.length; x++)
17
- FlattenValue(root, typedArray[x], keyFunc);
18
- break;
19
- case "object":
20
- const typedObject = value;
21
- const key = keyFunc(typedObject);
22
- if (key)
23
- root[key] = typedObject;
24
- const keys = Object.keys(typedObject);
25
- for (let x = 0; x < keys.length; x++)
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
- return root;
29
- }
30
- /**
31
- * Retrieves a value from a source object using a dot-separated path.
32
- * @param source - The source object
33
- * @param path - Dot-separated path to the value (empty string returns source)
34
- * @returns The value at the specified path
35
- */
36
- function GetPathValue(source, path, keyFunc) {
37
- if (path === "")
38
- return [source, true];
39
- const parts = path.split(".");
40
- let curr = source;
41
- let x = 0;
42
- for (; curr && x < parts.length - 1; x++)
43
- curr = ResolveKeyed(source, curr[parts[x]], keyFunc);
44
- if (curr && Object.hasOwn(curr, parts[x])) {
45
- curr = ResolveKeyed(source, curr[parts[x]], keyFunc);
46
- x++;
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
- * Sets a value in a source object at the specified path.
54
- * @param source - The source object
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 SetPathValue(source, path, value) {
59
- if (path.length === 0)
60
- return;
61
- let curr = source;
62
- let x = 0;
63
- for (; x < path.length - 1; x++)
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
- const valueSnapshotCache = new WeakMap();
69
- const valueSnapshotHierarchy = new WeakMap();
70
- function CreateSnapshotLink(child, parent) {
71
- let parents = valueSnapshotHierarchy.get(child);
72
- if (!parents)
73
- valueSnapshotHierarchy.set(child, (parents = new Set()));
74
- parents.add(parent);
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
- * Clears the cached snapshot for a raw value that was just written to, and climbs to
78
- * every parent whose own cached snapshot embedded it, so a write to a deeply nested value
79
- * invalidates every cached ancestor that still holds a snapshot. Mirrors ObservableNode's
80
- * `InvalidateSnapshotForValue`.
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 InvalidateSnapshotForValue(value) {
83
- valueSnapshotCache.delete(value);
84
- const parents = valueSnapshotHierarchy.get(value);
85
- if (parents === undefined)
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
- * An immutable snapshot of a raw tree value (arrays and plain objects; other values are
94
- * returned as-is), cached per raw value and reused until a write invalidates it. Mirrors
95
- * ObservableNode's `CreateSnapshot`: a rebuild makes new containers but reuses the cached
96
- * snapshots of unchanged children. A keyed child is read through the root entity `keyFunc`
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 CreateSnapshot(source, keyFunc, value) {
101
- if (JsonType(value) === "value")
102
- return value;
103
- const cached = valueSnapshotCache.get(value);
104
- if (cached !== undefined)
105
- return cached;
106
- let snapshot;
107
- if (Array.isArray(value)) {
108
- snapshot = new Array(value.length);
109
- for (let x = 0; x < value.length; x++)
110
- snapshot[x] = ChildSnapshot(source, keyFunc, value, value[x]);
111
- }
112
- else {
113
- snapshot = {};
114
- const keys = Object.keys(value);
115
- for (let x = 0; x < keys.length; x++)
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
- valueSnapshotCache.set(value, snapshot);
119
- return snapshot;
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
- * A child's snapshot, linking the child to its parent so a write to the child invalidates
123
- * the parent. A keyed child's snapshot comes from its root entity (`source[key]`), so the
124
- * root is linked too; the parent's own embedded copy stays linked because a positional
125
- * write (one that changes which entity a slot holds) mutates that copy in place.
126
- */
127
- function ChildSnapshot(source, keyFunc, parent, child) {
128
- if (JsonType(child) === "value")
129
- return child;
130
- CreateSnapshotLink(child, parent);
131
- if (keyFunc) {
132
- const key = keyFunc(child);
133
- if (key) {
134
- const root = source[key];
135
- if (root !== undefined && root !== child) {
136
- CreateSnapshotLink(root, parent);
137
- return CreateSnapshot(source, keyFunc, root);
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
- return CreateSnapshot(source, keyFunc, child);
400
+ for (const child of route.childRoutes.values())
401
+ CollectProjections(child, byRoute);
142
402
  }
143
403
  /**
144
- * Topologically sorts projections by their declared `reads` into a single array, run
145
- * sequentially: a read of another projection's `$projection_<id>` output is a dependency
146
- * edge, so that projection is sorted earlier. Runs once, at construction, so a cycle (or a
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 BuildProjectionOrder(projections) {
408
+ function OrderProjections(rootRoute) {
409
+ const byRoute = new Map();
410
+ CollectProjections(rootRoute, byRoute);
153
411
  const order = [];
154
412
  const state = new Map();
155
- function Visit(id, stack) {
156
- if (state.get(id) === "done")
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(id) === "visiting")
159
- throw new Error(`DiffTree projection cycle detected: ${stack.concat(id).join(" -> ")}`);
160
- const def = projections[id];
161
- if (!def)
162
- throw new Error(`DiffTree projection "${id}" is not defined`);
163
- state.set(id, "visiting");
164
- for (let x = 0; x < def.reads.length; x++) {
165
- const read = def.reads[x];
166
- const root = read.split(".", 1)[0];
167
- if (root.endsWith("*") && x !== def.reads.length - 1)
168
- throw `Wildcard reads only supported as the final read: ${root}`;
169
- if (root.endsWith("*") && root.includes("."))
170
- throw `Wildcard reads do not support dot nesting: ${root}`;
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(id, "done");
177
- order.push({ id, ...def });
443
+ state.set(projection, "done");
444
+ order.push(projection);
178
445
  }
179
- const ids = Object.keys(projections);
180
- for (let x = 0; x < ids.length; x++)
181
- Visit(ids[x], []);
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
- * Runs every registered projection, sequentially in the dependency order
186
- * `BuildProjectionOrder` computed, skipping ones whose declared `reads` all still resolve
187
- * to the same cached snapshot as last time (so nothing they read has changed), and returns
188
- * the diff of every projection whose result changed. A projection's result is stored at
189
- * `$projection_<id>` the same way a `DiffPath` write is: diffed against what was there
190
- * before and applied path by path, so a change to one field of a projection's result
191
- * produces one small diff entry rather than replacing the whole thing.
192
- *
193
- * Because `order` runs dependencies before dependents, a projection that reads another's
194
- * `$projection_<id>` output (chaining) always sees that projection's up-to-date result
195
- * within the same pass.
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
- const 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
- // The same diff-and-apply UpdateSource uses for any other write, so a keyed entity
248
- // embedded in a projection's result is flattened to its own root like any other write,
249
- // and the diff reported here is unfiltered, the same as DiffPath returns.
250
- diffResult.push(...UpdateSource(source, PROJECTION_PREFIX + id, result, keyFunc));
251
- }
252
- }
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);
253
463
  }
254
- return diffResult;
255
- }
256
- function ResolveKeyed(source, value, keyFunc) {
257
- if (keyFunc === undefined || JsonType(value) === 'value')
258
- return value;
259
- const key = keyFunc(value);
260
- 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;
261
467
  }
262
468
  /**
263
- * Resolves a path to its keyed root object if applicable.
264
- * Searches up the path to find an object with a key from keyFunc.
265
- * @param source - The source object
266
- * @param path - Dot-separated path
267
- * @param keyFunc - Function to extract key from objects
268
- * @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.
269
471
  */
270
- function ResolveKeyPath(source, path, keyFunc) {
271
- const parts = path.split(".");
272
- const pathValues = new Array(parts.length - 1);
273
- let curr = source;
274
- for (let x = 0; x < parts.length - 1; x++) {
275
- curr = curr[parts[x]];
276
- pathValues[x] = curr;
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;
277
480
  }
278
- let y = pathValues.length - 1;
279
- for (; y >= 0 && !(JsonType(pathValues[y]) === "object" && keyFunc(pathValues[y])); y--) { }
280
- if (y >= 0) {
281
- const key = keyFunc(pathValues[y]);
282
- parts.splice(0, y + 1, key);
283
- return parts.join(".");
284
- }
285
- return path;
481
+ return undefined;
286
482
  }
287
- /**
288
- * Updates the source with a new value at the specified path and computes diffs.
289
- * Also updates any keyed objects that were nested in the value.
290
- * @param source - The source object to update
291
- * @param path - Dot-separated path to update
292
- * @param value - The new value
293
- * @param keyFunc - Optional function to extract keys from objects
294
- * @returns Diff results showing all changes made
295
- */
296
- function UpdateSource(source, path, value, keyFunc) {
297
- const diffResult = [];
298
- if (keyFunc) {
299
- const keyPath = ResolveKeyPath(source, path, keyFunc);
300
- if (keyPath !== path) {
301
- const keyDiffResult = UpdateSource(source, keyPath, value, keyFunc);
302
- diffResult.push(...keyDiffResult);
303
- }
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);
304
490
  }
305
- const [sourceValue] = GetPathValue(source, path);
306
- JsonDiff(value, sourceValue, path, diffResult);
307
- if (keyFunc) {
308
- let flattened = {};
309
- flattened = FlattenValue(flattened, value, keyFunc);
310
- flattened = JsonDeepClone(flattened);
311
- const keys = Object.keys(flattened);
312
- for (let x = 0; x < keys.length; x++)
313
- JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], diffResult);
314
- }
315
- const filteredDiffResult = diffResult.filter((diff) => diff.value !== undefined);
316
- for (let x = 0; x < filteredDiffResult.length; x++) {
317
- SetPathValue(source, filteredDiffResult[x].path, filteredDiffResult[x].value);
318
- }
319
- return diffResult;
320
491
  }
321
- function PushSource(source, path, items, keyFunc) {
322
- const [sourceValue, found] = GetPathValue(source, path);
323
- if (!found)
324
- throw `Value not found at path ${path}`;
325
- if (!Array.isArray(sourceValue))
326
- throw `Value found at path ${path} is not an array`;
327
- const results = items.map(function (value, index) {
328
- return {
329
- path: `${path}.${sourceValue.length + index}`.split('.'),
330
- value
331
- };
332
- });
333
- sourceValue.push(...items);
334
- if (items.length > 0)
335
- results.push({
336
- path: `${path}.length`.split('.'),
337
- value: sourceValue.length
338
- });
339
- if (keyFunc) {
340
- const flattened = JsonDeepClone(FlattenValue({}, items, keyFunc));
341
- const keys = Object.keys(flattened);
342
- for (let x = 0; x < keys.length; x++)
343
- JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], results);
344
- }
345
- const filteredDiffResult = results.filter((diff) => diff.value !== undefined);
346
- for (let x = 0; x < filteredDiffResult.length; x++) {
347
- 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);
348
500
  }
349
- return results;
501
+ return counts;
350
502
  }
351
- function SpliceSource(source, path, start, deleteCount, items, keyFunc) {
352
- const [sourceValue, found] = GetPathValue(source, path);
353
- if (!found)
354
- throw `Value not found at path ${path}`;
355
- if (!Array.isArray(sourceValue))
356
- throw `Value found at path ${path} is not an array`;
357
- deleteCount ??= sourceValue.length - start;
358
- const spliceResult = {
359
- path: path.split("."),
360
- start,
361
- items,
362
- deleteCount,
363
- spliceResult: null,
364
- diffResult: [],
365
- };
366
- spliceResult.spliceResult = sourceValue.splice(start, deleteCount, ...items);
367
- InvalidateSnapshotForValue(sourceValue);
368
- if (keyFunc) {
369
- // Register the keyed entities the new items carry, diffed against the roots
370
- // already held: a new entity is added whole, one re-inserted unchanged (an undo)
371
- // produces no diff, so its readers aren't woken.
372
- const flattened = JsonDeepClone(FlattenValue({}, items, keyFunc));
373
- const keys = Object.keys(flattened);
374
- for (let x = 0; x < keys.length; x++)
375
- JsonDiff(flattened[keys[x]], source[keys[x]], keys[x], spliceResult.diffResult);
376
- }
377
- const filteredDiffResult = spliceResult.diffResult.filter((diff) => diff.value !== undefined);
378
- for (let x = 0; x < filteredDiffResult.length; x++) {
379
- 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 };
380
516
  }
381
- return spliceResult;
517
+ return undefined;
382
518
  }
383
519
  /**
384
- * Diff tree implementation. Maintains root state and computes diffs for path/value updates.
385
- * Used directly by `DiffSync`, and is the building block for a `StoreAsync` worker entry
386
- * file: construct one with the desired `keyFunc`/`projections` and connect it to the worker
387
- * thread with `ConnectWorkerToDiffTree` (see `defaultDiffTreeWorker.ts` for the no-argument
388
- * 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
389
523
  */
390
- export class DiffTree {
391
- /**
392
- * Creates a DiffTree instance.
393
- * @param keyFunc - Optional function to extract a key from objects
394
- * @param projections - Optional map of derived-value projections, keyed by id. Cycles
395
- * and reads of an unregistered projection id are rejected here, at construction.
396
- */
397
- constructor(keyFunc, projections) {
398
- this.keyFunc = keyFunc;
399
- this.rootState = {};
400
- this.projectionMemo = new Map();
401
- this.projectionOrder = projections ? BuildProjectionOrder(projections) : [];
402
- }
403
- RunProjections() {
404
- if (this.projectionOrder.length === 0)
405
- return [];
406
- return RunProjections(this.rootState, this.keyFunc, this.projectionOrder, this.projectionMemo);
407
- }
408
- /**
409
- * Computes the diff between a new value and the current value at a path.
410
- * @param path - Dot-separated path to the value
411
- * @param value - The new value to compare
412
- * @returns Diff results showing changes
413
- */
414
- DiffPath(path, value) {
415
- return UpdateSource(this.rootState, path, value, this.keyFunc);
416
- }
417
- PatchPath(path, patch) {
418
- const value = this.Snapshot(path);
419
- const merged = JsonMerge(value, patch);
420
- return UpdateSource(this.rootState, path, merged, this.keyFunc);
421
- }
422
- PushPath(path, items) {
423
- return PushSource(this.rootState, path, items, this.keyFunc);
424
- }
425
- SplicePath(path, start, deleteCount, items) {
426
- return SpliceSource(this.rootState, path, start, deleteCount, items, this.keyFunc);
427
- }
428
- /**
429
- * Retrieves the current value at a path.
430
- * @param path - Dot-separated path to the value
431
- * @returns The value at the specified path
432
- */
433
- GetPath(path) {
434
- const [value] = GetPathValue(this.rootState, path, this.keyFunc);
435
- return value;
436
- }
437
- /**
438
- * Returns an immutable, cached snapshot of the value at a path (empty string for the
439
- * whole tree). Not part of `IDiffTree`; internal, for a future feature.
440
- * @param path - Dot-separated path to the value
441
- */
442
- Snapshot(path) {
443
- const [value, found] = GetPathValue(this.rootState, path, this.keyFunc);
444
- if (!found)
445
- throw `Unable to get snapshot for path ${path}`;
446
- 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)) });
447
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 !== "");
448
569
  }