j-templates 8.1.2 → 9.0.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.
Files changed (40) hide show
  1. package/SYNTAX_PRIMER.md +3 -4
  2. package/Store/Diff/diffTree.d.ts +39 -33
  3. package/Store/Diff/diffTree.js +147 -435
  4. package/Store/Diff/diffTree.types.d.ts +5 -79
  5. package/Store/Diff/diffTree.types.js +1 -3
  6. package/Store/Diff/diffTreeWorker.d.ts +4 -0
  7. package/Store/Diff/diffTreeWorker.js +17 -39
  8. package/Store/Diff/diffTreeWriter.d.ts +27 -38
  9. package/Store/Diff/diffTreeWriter.js +44 -95
  10. package/Store/Diff/index.d.ts +2 -0
  11. package/Store/Diff/index.js +2 -0
  12. package/Store/Diff/utils/diffTree.types.d.ts +113 -0
  13. package/Store/Diff/utils/diffTree.types.js +1 -0
  14. package/Store/Diff/utils/homes.d.ts +46 -0
  15. package/Store/Diff/utils/homes.js +213 -0
  16. package/Store/Diff/utils/paths.d.ts +42 -0
  17. package/Store/Diff/utils/paths.js +87 -0
  18. package/Store/Diff/utils/projections.d.ts +55 -0
  19. package/Store/Diff/utils/projections.js +290 -0
  20. package/Store/Diff/utils/routes.d.ts +27 -0
  21. package/Store/Diff/utils/routes.js +168 -0
  22. package/Store/Diff/utils/state.d.ts +56 -0
  23. package/Store/Diff/utils/state.js +155 -0
  24. package/Store/Store/pushStore.d.ts +1 -5
  25. package/Store/Store/pushStore.js +3 -22
  26. package/Store/Store/store.d.ts +10 -4
  27. package/Store/Store/store.js +25 -11
  28. package/Store/Store/storeAsync.d.ts +4 -5
  29. package/Store/Store/storeAsync.js +19 -36
  30. package/Store/Store/storeSync.d.ts +14 -9
  31. package/Store/Store/storeSync.js +27 -22
  32. package/Store/Tree/observableNode.d.ts +2 -23
  33. package/Store/Tree/observableNode.js +46 -183
  34. package/Store/Tree/snapshot.d.ts +68 -0
  35. package/Store/Tree/snapshot.js +147 -0
  36. package/Utils/json.d.ts +180 -10
  37. package/Utils/json.js +532 -67
  38. package/package.json +1 -1
  39. package/Store/Diff/diffSync.d.ts +0 -36
  40. package/Store/Diff/diffSync.js +0 -43
@@ -1,458 +1,170 @@
1
- import { ArraysEqual } from "../../Utils/array";
2
- import { JsonDeepClone, JsonDiff, JsonMerge, JsonType } from "../../Utils/json";
3
- import { PROJECTION_PREFIX } from "./diffTree.types";
1
+ import { JsonDiffInto, JsonMerge, JsonType } from '../../Utils/json';
2
+ import { Snapshot } from '../Tree/snapshot';
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';
4
8
  /**
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
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
10
16
  */
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);
27
- }
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++;
47
- }
48
- else
49
- curr = undefined;
50
- return [curr, x === parts.length];
51
- }
52
- /**
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
57
- */
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);
67
- }
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);
75
- }
76
- /**
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`.
81
- */
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);
91
- }
92
- /**
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.
99
- */
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]]);
117
- }
118
- valueSnapshotCache.set(value, snapshot);
119
- return snapshot;
120
- }
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
- }
140
- }
141
- return CreateSnapshot(source, keyFunc, child);
142
- }
143
- /**
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.
151
- */
152
- function BuildProjectionOrder(projections) {
153
- const order = [];
154
- const state = new Map();
155
- function Visit(id, stack) {
156
- if (state.get(id) === "done")
157
- 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));
175
- }
176
- state.set(id, "done");
177
- order.push({ id, ...def });
178
- }
179
- const ids = Object.keys(projections);
180
- for (let x = 0; x < ids.length; x++)
181
- Visit(ids[x], []);
182
- return order;
183
- }
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
- 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
- }
262
- }
263
- return diffResult;
264
- }
265
- function ResolveKeyed(source, value, keyFunc) {
266
- if (keyFunc === undefined || JsonType(value) === 'value')
267
- return value;
268
- const key = keyFunc(value);
269
- return (key && source[key]) ?? value;
270
- }
271
- /**
272
- * Resolves a path to its keyed root object if applicable.
273
- * Searches up the path to find an object with a key from keyFunc.
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)
278
- */
279
- function ResolveKeyPath(source, path, keyFunc) {
280
- const parts = path.split(".");
281
- const pathValues = new Array(parts.length - 1);
282
- let curr = source;
283
- for (let x = 0; x < parts.length - 1; x++) {
284
- curr = curr[parts[x]];
285
- pathValues[x] = curr;
286
- }
287
- let y = pathValues.length - 1;
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;
295
- }
296
- /**
297
- * Updates the source with a new value at the specified path and computes diffs.
298
- * Also updates any keyed objects that were nested in the value.
299
- * @param source - The source object to update
300
- * @param path - Dot-separated path to update
301
- * @param value - The new value
302
- * @param keyFunc - Optional function to extract keys from objects
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
- }
314
- }
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);
17
+ export class DiffTree {
18
+ constructor(keyFunc, routeDefinitions = {}) {
19
+ this.rootState = {};
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
+ };
324
30
  }
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);
31
+ WritePath(path, value) {
32
+ AssertWritable(SplitRouteAndPath(path)[1]);
33
+ // Keyed entities are local to the route written to; a key starting with "/" is a route path from the root
34
+ ApplyValueToPath(this.writeContext, path, value);
328
35
  }
329
- return diffResult;
330
- }
331
- function PushSource(source, path, items, keyFunc) {
332
- const [sourceValue, found] = GetPathValue(source, path);
333
- if (!found)
334
- throw `Value not found at path ${path}`;
335
- if (!Array.isArray(sourceValue))
336
- throw `Value found at path ${path} is not an array`;
337
- const results = items.map(function (value, index) {
338
- return {
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);
36
+ PatchPath(path, patch) {
37
+ const value = this.Snapshot(path);
38
+ const merged = JsonMerge(value, patch);
39
+ this.WritePath(path, merged);
354
40
  }
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);
41
+ PushPath(path, items) {
42
+ // A start past the end appends
43
+ this.Splice(path, Infinity, 0, items);
358
44
  }
359
- return results;
360
- }
361
- function SpliceSource(source, path, start, deleteCount, items, keyFunc) {
362
- const [sourceValue, found] = GetPathValue(source, path);
363
- if (!found)
364
- throw `Value not found at path ${path}`;
365
- if (!Array.isArray(sourceValue))
366
- throw `Value found at path ${path} is not an array`;
367
- deleteCount ??= sourceValue.length - start;
368
- const spliceResult = {
369
- path: path.split("."),
370
- start,
371
- items,
372
- deleteCount,
373
- spliceResult: null,
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);
45
+ SplicePath(path, start, deleteCount, items) {
46
+ this.Splice(path, start, deleteCount, items);
386
47
  }
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);
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);
390
52
  }
391
- return spliceResult;
392
- }
393
- /**
394
- * Diff tree implementation. Maintains root state and computes diffs for path/value updates.
395
- * Used directly by `DiffSync`, and is the building block for a `StoreAsync` worker entry
396
- * file: construct one with the desired `keyFunc`/`projections` and connect it to the worker
397
- * thread with `ConnectWorkerToDiffTree` (see `defaultDiffTreeWorker.ts` for the no-argument
398
- * case, or a custom entry file when either is needed).
399
- */
400
- export class DiffTree {
401
53
  /**
402
- * Creates a DiffTree instance.
403
- * @param keyFunc - Optional function to extract a key from objects
404
- * @param projections - Optional map of derived-value projections, keyed by id. Cycles
405
- * and reads of an unregistered projection id are rejected here, at construction.
54
+ * Deletes a route: its state, its child routes and projection outputs, and its route
55
+ * instances. The projection order is rebuilt on the next `RunProjections`, so deleting
56
+ * several routes in a row sorts once.
57
+ * @param routePath The route to delete, e.g. `/channel/1/`
58
+ * @throws When the path is the root route or has a property path, e.g. `/channel/1/name`
406
59
  */
407
- constructor(keyFunc, projections) {
408
- this.keyFunc = keyFunc;
409
- this.rootState = {};
410
- this.projectionMemo = new Map();
411
- this.projectionOrder = projections ? BuildProjectionOrder(projections) : [];
412
- }
413
- RunProjections() {
414
- if (this.projectionOrder.length === 0)
415
- return [];
416
- return RunProjections(this.rootState, this.keyFunc, this.projectionOrder, this.projectionMemo);
60
+ DeleteRoute(routePath) {
61
+ const [route, propertyPath] = SplitRouteAndPath(routePath);
62
+ const fullRoute = JoinPaths("", route);
63
+ if (propertyPath || fullRoute === ROOT_ROUTE)
64
+ throw new Error(`Unable to delete ${routePath}: only a non-root route path, e.g. /channel/1/, can be deleted.`);
65
+ DeleteRouteState(this.snapshot, this.rootState, fullRoute);
66
+ this.routes.Delete(fullRoute);
417
67
  }
418
68
  /**
419
- * Computes the diff between a new value and the current value at a path.
420
- * @param path - Dot-separated path to the value
421
- * @param value - The new value to compare
422
- * @returns Diff results showing changes
69
+ * Reads the raw value at a path, root (`value.prop`) or routed (`/channel/1/value.prop`).
70
+ * @returns The value, or undefined when any segment of the path doesn't exist
423
71
  */
424
- DiffPath(path, value) {
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);
72
+ ReadPath(path) {
73
+ return ReadPath(this.rootState, path);
434
74
  }
435
- SplicePath(path, start, deleteCount, items) {
436
- return SpliceSource(this.rootState, path, start, deleteCount, items, this.keyFunc);
75
+ /** Runs every projection of every route instance; see `Projections.Run` */
76
+ RunProjections() {
77
+ this.projections.Run();
437
78
  }
438
79
  /**
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
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.
442
83
  */
443
- GetPath(path) {
444
- const [value] = GetPathValue(this.rootState, path, this.keyFunc);
445
- return value;
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)
103
+ continue;
104
+ JsonDiffInto(next, last, [key], diff, strategy, identity);
105
+ committed.set(key, next);
106
+ }
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;
446
117
  }
447
118
  /**
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
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
451
122
  */
452
123
  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);
124
+ return ReadSnapshot(this.snapshot, this.rootState, path)[0];
457
125
  }
458
126
  }
127
+ /**
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.
135
+ */
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)
141
+ 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
+ };
149
+ }
150
+ /**
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.
157
+ */
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;
164
+ };
165
+ }
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.`);
170
+ }