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.
@@ -1,36 +1,37 @@
1
- import { JsonDiff, JsonMerge, JsonType } from '../../Utils/json';
1
+ import { JsonDiffInto, JsonMerge, JsonType } from '../../Utils/json';
2
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';
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.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);
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
- 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
+ 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.snapshot, this.rootState, this.routeKeyFunc, routePath, path, value);
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
- 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); */
42
+ // A start past the end appends
43
+ this.Splice(path, Infinity, 0, items);
68
44
  }
69
45
  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);
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
- 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++;
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
- 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;
77
+ this.projections.Run();
236
78
  }
237
79
  /**
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.
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
- 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;
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
- const [value, found, source] = this.ReadValue(paths[x]);
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
- 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 }];
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
- * 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.
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
- 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 };
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
- * 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
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 OrderProjections(rootRoute) {
409
- const byRoute = new Map();
410
- CollectProjections(rootRoute, byRoute);
411
- const order = [];
412
- const state = new Map();
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 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);
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
- * @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
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 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)) });
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
- /** 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 !== "");
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
  }