j-templates 9.0.1 → 10.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 (54) hide show
  1. package/SYNTAX_PRIMER.md +181 -101
  2. package/Store/Diff/channelClient.d.ts +106 -0
  3. package/Store/Diff/channelClient.js +228 -0
  4. package/Store/Diff/defaultDiffTreeWorker.js +3 -4
  5. package/Store/Diff/diffTree.d.ts +56 -0
  6. package/Store/Diff/diffTree.js +110 -4
  7. package/Store/Diff/diffTree.types.d.ts +191 -21
  8. package/Store/Diff/diffTree.types.js +2 -10
  9. package/Store/Diff/diffTreeEndpoint.d.ts +101 -0
  10. package/Store/Diff/diffTreeEndpoint.js +173 -0
  11. package/Store/Diff/diffTreeEndpoint.types.d.ts +71 -0
  12. package/Store/Diff/diffTreeEndpoint.types.js +1 -0
  13. package/Store/Diff/diffTreeHub.d.ts +139 -0
  14. package/Store/Diff/diffTreeHub.js +346 -0
  15. package/Store/Diff/hostPublisher.d.ts +39 -0
  16. package/Store/Diff/hostPublisher.js +151 -0
  17. package/Store/Diff/index.d.ts +3 -0
  18. package/Store/Diff/index.js +3 -0
  19. package/Store/Diff/observableDiffTree.d.ts +93 -0
  20. package/Store/Diff/observableDiffTree.js +199 -0
  21. package/Store/Diff/onConnect.d.ts +33 -0
  22. package/Store/Diff/onConnect.js +83 -0
  23. package/Store/Diff/utils/diffTree.types.d.ts +9 -0
  24. package/Store/Diff/utils/homes.d.ts +12 -0
  25. package/Store/Diff/utils/homes.js +40 -0
  26. package/Store/Diff/utils/routes.d.ts +18 -0
  27. package/Store/Diff/utils/routes.js +33 -2
  28. package/Store/Store/store.d.ts +36 -4
  29. package/Store/Store/store.js +47 -6
  30. package/Store/Store/storeAsync.d.ts +80 -25
  31. package/Store/Store/storeAsync.js +102 -33
  32. package/Store/Store/storeSync.d.ts +76 -11
  33. package/Store/Store/storeSync.js +105 -12
  34. package/Store/Tree/observableNode.js +68 -13
  35. package/Store/Tree/opaque.d.ts +30 -0
  36. package/Store/Tree/opaque.js +35 -0
  37. package/Store/Tree/snapshot.js +5 -4
  38. package/Store/index.d.ts +4 -1
  39. package/Store/index.js +1 -1
  40. package/Utils/decorators.d.ts +0 -98
  41. package/Utils/decorators.js +0 -156
  42. package/Utils/index.d.ts +2 -0
  43. package/Utils/index.js +1 -0
  44. package/Utils/subscriptionManager.d.ts +78 -0
  45. package/Utils/subscriptionManager.js +162 -0
  46. package/package.json +1 -1
  47. package/Store/Diff/diffTreeWorker.d.ts +0 -6
  48. package/Store/Diff/diffTreeWorker.js +0 -22
  49. package/Store/Diff/diffTreeWriter.d.ts +0 -92
  50. package/Store/Diff/diffTreeWriter.js +0 -137
  51. package/Store/Diff/workerQueue.d.ts +0 -7
  52. package/Store/Diff/workerQueue.js +0 -29
  53. package/Store/Store/pushStore.d.ts +0 -53
  54. package/Store/Store/pushStore.js +0 -77
@@ -0,0 +1,228 @@
1
+ /** Ids are unique across clients, so several can share one channel */
2
+ let nextId = 1;
3
+ /**
4
+ * The client side of a `HostPublisher` channel: it sends batches and subscriptions, and routes
5
+ * the host's posts and acks back by id. Messages for ids it didn't send are left alone, so
6
+ * other clients and the app's own messages share the channel.
7
+ */
8
+ export class ChannelClient {
9
+ constructor(channel) {
10
+ this.channel = channel;
11
+ this.subscriptions = new Map();
12
+ this.batches = new Map();
13
+ this.listener = (event) => this.Receive(event.data);
14
+ channel.addEventListener("message", this.listener);
15
+ channel.start?.();
16
+ }
17
+ /**
18
+ * Sends a batch of calls.
19
+ * @returns A promise that resolves on the batch's `jt-ack`, after the commit's posts have been
20
+ * delivered, or rejects with the batch's error.
21
+ */
22
+ Batch(calls) {
23
+ return new Promise((resolve, reject) => {
24
+ const id = nextId++;
25
+ this.batches.set(id, { calls, resolve, reject });
26
+ this.channel.postMessage({ type: "jt-batch", id, calls });
27
+ });
28
+ }
29
+ /** Rejects the batches waiting for an ack with `error`; this client stops waiting for them */
30
+ RejectBatches(error) {
31
+ const batches = Array.from(this.batches.values());
32
+ this.batches.clear();
33
+ batches.forEach(batch => batch.reject(error));
34
+ }
35
+ /** See `DiffTree.MarkOpaque` */
36
+ MarkOpaque(keys) {
37
+ this.channel.postMessage({ type: "jt-markopaque", keys });
38
+ }
39
+ /**
40
+ * Subscribes to the host's tree. Deliveries arrive as the host posts them.
41
+ * @param onSubscribed Called once the host has posted the first state, with its error when
42
+ * subscribing failed.
43
+ * @returns The subscription's id.
44
+ */
45
+ Subscribe(options, callback, onSubscribed) {
46
+ const sub = nextId++;
47
+ this.subscriptions.set(sub, { callback, onSubscribed });
48
+ this.channel.postMessage({ type: "jt-subscribe", sub, roots: options.roots, form: options.form ?? "diff" });
49
+ return sub;
50
+ }
51
+ Unsubscribe(sub) {
52
+ if (this.subscriptions.delete(sub))
53
+ this.channel.postMessage({ type: "jt-unsubscribe", sub });
54
+ }
55
+ /**
56
+ * Ends this client's subscriptions and stops listening. Batches waiting for an ack are left
57
+ * unsettled. The channel stays open.
58
+ */
59
+ Destroy() {
60
+ for (const sub of Array.from(this.subscriptions.keys()))
61
+ this.Unsubscribe(sub);
62
+ this.batches.clear();
63
+ this.channel.removeEventListener("message", this.listener);
64
+ }
65
+ Receive(message) {
66
+ switch (message?.type) {
67
+ case "jt-post":
68
+ this.subscriptions.get(message.sub)?.callback(message);
69
+ break;
70
+ case "jt-ack": {
71
+ const batch = this.batches.get(message.id);
72
+ if (!batch)
73
+ break;
74
+ this.batches.delete(message.id);
75
+ "error" in message && message.error !== undefined ? batch.reject(message.error) : batch.resolve();
76
+ break;
77
+ }
78
+ case "jt-subscribed": {
79
+ const subscription = this.subscriptions.get(message.sub);
80
+ if (!subscription)
81
+ break;
82
+ if ("error" in message && message.error !== undefined)
83
+ this.subscriptions.delete(message.sub);
84
+ subscription.onSubscribed?.(message.error);
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ }
90
+ /**
91
+ * A subscription to a tree served on a channel that outlives the channel: when the tree is
92
+ * replaced (a restarted worker, given on a new port), `Connect` subscribes to the new one, and its
93
+ * first state also deletes the roots this subscription delivered that the new tree doesn't hold. A
94
+ * subscriber that applies every delivery, a store or a tree, then holds the new tree's state, and
95
+ * a wildcard read of it reads nothing the old tree left behind.
96
+ *
97
+ * - The first state after a reconnect is one delivery: the new tree's roots, then a `delete` record
98
+ * (in the `"diff"` form) or a `deleted` key (in the `"snapshot"` form) for each root gone, so
99
+ * nothing applied in between holds both. With no roots in the new tree, it's the deletes alone.
100
+ * - A root is delivered while it was written by the last delivery that touched it, and gone once a
101
+ * delivery deletes it, as a `HostPublisher`'s deliveries say.
102
+ *
103
+ * @example
104
+ * const engine = new ChannelSubscription({ roots: ["run", "$projection_*"] }, (post, first) => {
105
+ * tree.ApplyDiff(post.diff, "/engine/");
106
+ * tree.RunProjections();
107
+ * tree.Commit();
108
+ * });
109
+ * OnConnect("engine", port => engine.Connect(port));
110
+ */
111
+ export class ChannelSubscription {
112
+ /**
113
+ * @param options The roots, and the form, as `ObservableDiffTree.Subscribe` takes them.
114
+ * @param callback Each delivery, and whether it's the first state of a `Connect`.
115
+ */
116
+ constructor(options, callback) {
117
+ this.options = options;
118
+ this.callback = callback;
119
+ /** The source's root keys delivered and not deleted */
120
+ this.delivered = new Set();
121
+ this.ownClient = false;
122
+ }
123
+ /**
124
+ * Subscribes over a channel, or a client of one, in place of the last: the first state arrives
125
+ * as one delivery, with the deletes for the roots the last tree had and this one hasn't.
126
+ * @param onSubscribed Called once the first state is out, with the error when subscribing failed.
127
+ * @returns What ends this connection's subscription.
128
+ */
129
+ Connect(channel, onSubscribed) {
130
+ this.Stop();
131
+ const client = this.client = channel instanceof ChannelClient ? channel : new ChannelClient(channel);
132
+ this.ownClient = !(channel instanceof ChannelClient);
133
+ const previous = this.delivered;
134
+ this.delivered = new Set();
135
+ const snapshot = this.options.form === "snapshot";
136
+ // After a tree that delivered something, the first state waits for jt-subscribed to take the deletes
137
+ const hold = previous.size > 0;
138
+ let first;
139
+ let subscribed = false;
140
+ const sub = this.sub = client.Subscribe(this.options, delivery => {
141
+ if (subscribed || !hold) {
142
+ this.Track(delivery, snapshot);
143
+ this.callback(delivery, !subscribed);
144
+ return;
145
+ }
146
+ first = delivery;
147
+ }, error => {
148
+ subscribed = true;
149
+ if (error === undefined && hold) {
150
+ const delivery = first ?? (snapshot ? { seq: 0, roots: {}, deleted: [] } : { seq: 0, diff: [] });
151
+ this.Track(delivery, snapshot);
152
+ for (const key of previous)
153
+ if (!this.delivered.has(key))
154
+ snapshot ? delivery.deleted.push(key) : delivery.diff.push({ type: "delete", path: [key] });
155
+ (first !== undefined || (snapshot ? delivery.deleted.length > 0 : delivery.diff.length > 0)) && this.callback(delivery, true);
156
+ }
157
+ onSubscribed?.(error);
158
+ });
159
+ return () => this.sub === sub && this.Stop();
160
+ }
161
+ /** Ends the subscription on the current channel; what it delivered is kept for the next `Connect` */
162
+ Stop() {
163
+ if (this.client && this.sub !== undefined)
164
+ this.ownClient ? this.client.Destroy() : this.client.Unsubscribe(this.sub);
165
+ this.client = undefined;
166
+ this.sub = undefined;
167
+ }
168
+ Track(delivery, snapshot) {
169
+ if (snapshot) {
170
+ for (const key of Object.keys(delivery.roots))
171
+ this.delivered.add(key);
172
+ for (const key of delivery.deleted)
173
+ this.delivered.delete(key);
174
+ return;
175
+ }
176
+ for (const record of delivery.diff) {
177
+ const key = String(record.path[0]);
178
+ record.type === "delete" && record.path.length === 1 ? this.delivered.delete(key) : this.delivered.add(key);
179
+ }
180
+ }
181
+ }
182
+ export function Subscribe(channel, options, callback) {
183
+ const client = new ChannelClient(channel);
184
+ client.Subscribe(options, callback, error => error !== undefined && client.Destroy());
185
+ return () => client.Destroy();
186
+ }
187
+ /**
188
+ * Waits on a channel for the first delivery in which every key has a value: at once when they
189
+ * all have one, otherwise the commit that adds the last. See `ObservableDiffTree.Once`.
190
+ * @param keys Exact root keys; no patterns.
191
+ * @param options `signal` cancels the wait, rejecting with its reason.
192
+ * @returns Each key's value.
193
+ */
194
+ export function Once(channel, keys, options = {}) {
195
+ const { signal } = options;
196
+ return new Promise((resolve, reject) => {
197
+ if (keys.some(key => key.endsWith("*")))
198
+ return reject(new Error(`Once takes exact keys, not patterns: ${keys.join(", ")}`));
199
+ if (signal?.aborted)
200
+ return reject(signal.reason);
201
+ // Updates carry only the roots that changed, so the values seen so far are kept
202
+ const values = {};
203
+ const client = new ChannelClient(channel);
204
+ const onAbort = () => {
205
+ finish();
206
+ reject(signal.reason);
207
+ };
208
+ const finish = () => {
209
+ client.Destroy();
210
+ signal?.removeEventListener("abort", onAbort);
211
+ };
212
+ client.Subscribe({ roots: keys, form: "snapshot" }, (update) => {
213
+ Object.assign(values, update.roots);
214
+ for (const key of update.deleted)
215
+ delete values[key];
216
+ if (keys.every(key => values[key] !== undefined)) {
217
+ finish();
218
+ resolve(Object.fromEntries(keys.map(key => [key, values[key]])));
219
+ }
220
+ }, error => {
221
+ if (error !== undefined) {
222
+ finish();
223
+ reject(error);
224
+ }
225
+ });
226
+ signal?.addEventListener("abort", onAbort, { once: true });
227
+ });
228
+ }
@@ -1,4 +1,3 @@
1
- import { DiffTree } from "./diffTree";
2
- import { ConnectWorkerToDiffTree } from "./diffTreeWorker";
3
- const diffTree = new DiffTree();
4
- ConnectWorkerToDiffTree(diffTree, self);
1
+ import { ObservableDiffTree } from "./observableDiffTree";
2
+ import { HostPublisher } from "./hostPublisher";
3
+ HostPublisher(new ObservableDiffTree(), self);
@@ -16,6 +16,7 @@ export declare class DiffTree {
16
16
  private projections;
17
17
  /** Each root key's snapshot as of the last commit */
18
18
  private commitedSnapshot;
19
+ private opaque;
19
20
  private writeContext;
20
21
  constructor(keyFunc?: IKeyFunc, routeDefinitions?: IRouteDefinitions);
21
22
  WritePath(path: string, value: any): void;
@@ -23,6 +24,32 @@ export declare class DiffTree {
23
24
  PushPath(path: string, items: unknown[]): void;
24
25
  SplicePath(path: string, start: number, deleteCount: number, items: any[]): void;
25
26
  private Splice;
27
+ /**
28
+ * Applies another tree's diff records, a delivery of its commit, as writes to this tree, in
29
+ * order: each `write` as `WritePath`, each `splice` as `SplicePath` (moves included), each
30
+ * `delete` as removing the property, or the root key. The next `Commit` diffs them as it does any
31
+ * write, and the projections that read them rerun on `RunProjections`.
32
+ *
33
+ * Into the root route, the records' root keys are this tree's root keys, and a projection output
34
+ * (`$projection_<key>`) can't be written. Into a follow route (`FollowRoutes`), which only this
35
+ * writes, they're held under the source's own keys, projection outputs included, and a route is
36
+ * never posted: the source's data stays in this tree, and only what its projections derive from it
37
+ * reaches this tree's subscribers.
38
+ *
39
+ * Give the route the source's keyFunc, or one that gives its entities the same keys: the source's
40
+ * commit carries a change to an entity once, as its own root's records, and skips it wherever
41
+ * else it appears. Here, as there, each of those places is a slot holding the entity's home, so
42
+ * it reads the change too. Without the keyFunc, the copy of an entity inside another root would
43
+ * keep its first value.
44
+ * @param records A diff, e.g. an `IDiffPost`'s, from a subscription in the `"diff"` form
45
+ * @param route The route to apply them to: the root route (`/`, the default) or a follow route,
46
+ * e.g. `/engine/`
47
+ * @throws When a record writes a projection output in the root route, the route isn't the root
48
+ * or a follow route, or a record's parent doesn't exist
49
+ */
50
+ ApplyDiff(records: JsonDiffResult, route?: string): void;
51
+ /** A write's route isn't a follow route, which only `ApplyDiff` writes */
52
+ private AssertLocal;
26
53
  /**
27
54
  * Deletes a route: its state, its child routes and projection outputs, and its route
28
55
  * instances. The projection order is rebuilt on the next `RunProjections`, so deleting
@@ -31,13 +58,42 @@ export declare class DiffTree {
31
58
  * @throws When the path is the root route or has a property path, e.g. `/channel/1/name`
32
59
  */
33
60
  DeleteRoute(routePath: string): void;
61
+ /**
62
+ * Deletes a root key, a written root that isn't a projection output. The next `Commit` carries
63
+ * a `delete` at `[key]`. Deleting a key that doesn't exist does nothing.
64
+ * @param key The root key, e.g. `whatif_14`
65
+ * @throws When the key is a path (it has a `.` or `/`) or a projection output
66
+ */
67
+ DeleteRoot(key: string): void;
68
+ /**
69
+ * A root key's value as of the last `Commit`: its snapshot then, shared and never changed. A
70
+ * key written since and not yet committed reads as it was.
71
+ * @param key The root key.
72
+ * @returns The value, or undefined when the key wasn't in the last commit
73
+ */
74
+ Committed(key: string): unknown;
75
+ /** The root keys as of the last `Commit`, in the order they were first committed */
76
+ CommittedKeys(): string[];
34
77
  /**
35
78
  * Reads the raw value at a path, root (`value.prop`) or routed (`/channel/1/value.prop`).
36
79
  * @returns The value, or undefined when any segment of the path doesn't exist
37
80
  */
38
81
  ReadPath(path: string): any;
82
+ /**
83
+ * Marks root keys, a written root or a root route projection's `$projection_<key>`, as opaque:
84
+ * `Commit` writes an opaque key's value whole when it changes, never a diff inside it. A Store
85
+ * with the same keys in its `opaque` option holds each of them whole.
86
+ * @param keys The root keys.
87
+ */
88
+ MarkOpaque(keys: readonly string[]): void;
39
89
  /** Runs every projection of every route instance; see `Projections.Run` */
40
90
  RunProjections(): void;
91
+ /**
92
+ * Runs before every change to the tree's state: each write, splice, applied diff, deleted route
93
+ * or root, and projection run. A subclass that refuses changes at times (`ObservableDiffTree`,
94
+ * during a delivery) throws here.
95
+ */
96
+ protected BeforeChange(): void;
41
97
  /**
42
98
  * The changes since the last commit. The root is a flat record of keys, so each key's snapshot
43
99
  * is taken and compared on its own, without building a snapshot of the whole root: a key no
@@ -1,10 +1,10 @@
1
1
  import { JsonDiffInto, JsonMerge, JsonType } from '../../Utils/json';
2
2
  import { Snapshot } from '../Tree/snapshot';
3
- import { ApplyValueToPath, SpliceValueAtPath } from './utils/homes';
3
+ import { ApplyRecords, ApplyValueToPath, SpliceValueAtPath } from './utils/homes';
4
4
  import { JoinPaths, PROJECTION_PREFIX, ROOT_ROUTE, SplitRouteAndPath } from './utils/paths';
5
5
  import { Projections } from './utils/projections';
6
6
  import { RouteInstances } from './utils/routes';
7
- import { DeleteRouteState, KeyUnchanged, ReadPath, ReadSnapshot } from './utils/state';
7
+ import { DeleteRouteState, KeyUnchanged, ReadPath, ReadSnapshot, Remove } from './utils/state';
8
8
  /**
9
9
  * A tree of JSON state in routes, with projections, that tracks its changes as diffs. The work is
10
10
  * split across `utils/`:
@@ -20,6 +20,7 @@ export class DiffTree {
20
20
  this.snapshot = Snapshot.Create();
21
21
  /** Each root key's snapshot as of the last commit */
22
22
  this.commitedSnapshot = new Map();
23
+ this.opaque = new Set();
23
24
  this.routes = new RouteInstances(keyFunc, routeDefinitions);
24
25
  this.projections = new Projections(this.snapshot, this.rootState, this.routes);
25
26
  this.writeContext = {
@@ -29,7 +30,9 @@ export class DiffTree {
29
30
  };
30
31
  }
31
32
  WritePath(path, value) {
33
+ this.BeforeChange();
32
34
  AssertWritable(SplitRouteAndPath(path)[1]);
35
+ this.AssertLocal(path);
33
36
  // Keyed entities are local to the route written to; a key starting with "/" is a route path from the root
34
37
  ApplyValueToPath(this.writeContext, path, value);
35
38
  }
@@ -45,11 +48,57 @@ export class DiffTree {
45
48
  SplicePath(path, start, deleteCount, items) {
46
49
  this.Splice(path, start, deleteCount, items);
47
50
  }
48
- // Shared by PushPath and SplicePath, which a DiffTreeWriter guards separately
51
+ // Shared by PushPath and SplicePath
49
52
  Splice(path, start, deleteCount, items) {
53
+ this.BeforeChange();
50
54
  AssertWritable(SplitRouteAndPath(path)[1]);
55
+ this.AssertLocal(path);
51
56
  SpliceValueAtPath(this.writeContext, path, start, deleteCount, items);
52
57
  }
58
+ /**
59
+ * Applies another tree's diff records, a delivery of its commit, as writes to this tree, in
60
+ * order: each `write` as `WritePath`, each `splice` as `SplicePath` (moves included), each
61
+ * `delete` as removing the property, or the root key. The next `Commit` diffs them as it does any
62
+ * write, and the projections that read them rerun on `RunProjections`.
63
+ *
64
+ * Into the root route, the records' root keys are this tree's root keys, and a projection output
65
+ * (`$projection_<key>`) can't be written. Into a follow route (`FollowRoutes`), which only this
66
+ * writes, they're held under the source's own keys, projection outputs included, and a route is
67
+ * never posted: the source's data stays in this tree, and only what its projections derive from it
68
+ * reaches this tree's subscribers.
69
+ *
70
+ * Give the route the source's keyFunc, or one that gives its entities the same keys: the source's
71
+ * commit carries a change to an entity once, as its own root's records, and skips it wherever
72
+ * else it appears. Here, as there, each of those places is a slot holding the entity's home, so
73
+ * it reads the change too. Without the keyFunc, the copy of an entity inside another root would
74
+ * keep its first value.
75
+ * @param records A diff, e.g. an `IDiffPost`'s, from a subscription in the `"diff"` form
76
+ * @param route The route to apply them to: the root route (`/`, the default) or a follow route,
77
+ * e.g. `/engine/`
78
+ * @throws When a record writes a projection output in the root route, the route isn't the root
79
+ * or a follow route, or a record's parent doesn't exist
80
+ */
81
+ ApplyDiff(records, route = ROOT_ROUTE) {
82
+ this.BeforeChange();
83
+ const fullRoute = JoinPaths("", route);
84
+ if (fullRoute === ROOT_ROUTE) {
85
+ for (let x = 0; x < records.length; x++) {
86
+ const key = String(records[x].path[0]);
87
+ AssertWritable(key);
88
+ if (key.startsWith(ROOT_ROUTE))
89
+ throw new Error(`Unable to apply a record at ${key}: a diff's records are in the root route`);
90
+ }
91
+ }
92
+ else if (!this.routes.Get(fullRoute).follow || this.routes.Get(fullRoute).path !== fullRoute)
93
+ throw new Error(`Unable to apply a diff at ${fullRoute}: only the root route or a follow route (FollowRoutes) takes one.`);
94
+ ApplyRecords(this.writeContext, records, fullRoute);
95
+ }
96
+ /** A write's route isn't a follow route, which only `ApplyDiff` writes */
97
+ AssertLocal(path) {
98
+ const route = SplitRouteAndPath(path)[0];
99
+ if (route !== "" && this.routes.Get(JoinPaths("", route)).follow)
100
+ throw new Error(`Unable to write ${path}: a follow route is written only by ApplyDiff, from its subscription.`);
101
+ }
53
102
  /**
54
103
  * Deletes a route: its state, its child routes and projection outputs, and its route
55
104
  * instances. The projection order is rebuilt on the next `RunProjections`, so deleting
@@ -58,6 +107,7 @@ export class DiffTree {
58
107
  * @throws When the path is the root route or has a property path, e.g. `/channel/1/name`
59
108
  */
60
109
  DeleteRoute(routePath) {
110
+ this.BeforeChange();
61
111
  const [route, propertyPath] = SplitRouteAndPath(routePath);
62
112
  const fullRoute = JoinPaths("", route);
63
113
  if (propertyPath || fullRoute === ROOT_ROUTE)
@@ -65,6 +115,33 @@ export class DiffTree {
65
115
  DeleteRouteState(this.snapshot, this.rootState, fullRoute);
66
116
  this.routes.Delete(fullRoute);
67
117
  }
118
+ /**
119
+ * Deletes a root key, a written root that isn't a projection output. The next `Commit` carries
120
+ * a `delete` at `[key]`. Deleting a key that doesn't exist does nothing.
121
+ * @param key The root key, e.g. `whatif_14`
122
+ * @throws When the key is a path (it has a `.` or `/`) or a projection output
123
+ */
124
+ DeleteRoot(key) {
125
+ this.BeforeChange();
126
+ if (key.includes(".") || key.includes("/"))
127
+ throw new Error(`Unable to delete ${key}: only a root key, e.g. whatif_14, can be deleted.`);
128
+ AssertWritable(key);
129
+ if (Object.hasOwn(this.rootState, key))
130
+ Remove(this.snapshot, this.rootState, key);
131
+ }
132
+ /**
133
+ * A root key's value as of the last `Commit`: its snapshot then, shared and never changed. A
134
+ * key written since and not yet committed reads as it was.
135
+ * @param key The root key.
136
+ * @returns The value, or undefined when the key wasn't in the last commit
137
+ */
138
+ Committed(key) {
139
+ return this.commitedSnapshot.get(key);
140
+ }
141
+ /** The root keys as of the last `Commit`, in the order they were first committed */
142
+ CommittedKeys() {
143
+ return Array.from(this.commitedSnapshot.keys());
144
+ }
68
145
  /**
69
146
  * Reads the raw value at a path, root (`value.prop`) or routed (`/channel/1/value.prop`).
70
147
  * @returns The value, or undefined when any segment of the path doesn't exist
@@ -72,10 +149,27 @@ export class DiffTree {
72
149
  ReadPath(path) {
73
150
  return ReadPath(this.rootState, path);
74
151
  }
152
+ /**
153
+ * Marks root keys, a written root or a root route projection's `$projection_<key>`, as opaque:
154
+ * `Commit` writes an opaque key's value whole when it changes, never a diff inside it. A Store
155
+ * with the same keys in its `opaque` option holds each of them whole.
156
+ * @param keys The root keys.
157
+ */
158
+ MarkOpaque(keys) {
159
+ for (let x = 0; x < keys.length; x++)
160
+ this.opaque.add(keys[x]);
161
+ }
75
162
  /** Runs every projection of every route instance; see `Projections.Run` */
76
163
  RunProjections() {
164
+ this.BeforeChange();
77
165
  this.projections.Run();
78
166
  }
167
+ /**
168
+ * Runs before every change to the tree's state: each write, splice, applied diff, deleted route
169
+ * or root, and projection run. A subclass that refuses changes at times (`ObservableDiffTree`,
170
+ * during a delivery) throws here.
171
+ */
172
+ BeforeChange() { }
79
173
  /**
80
174
  * The changes since the last commit. The root is a flat record of keys, so each key's snapshot
81
175
  * is taken and compared on its own, without building a snapshot of the whole root: a key no
@@ -87,7 +181,7 @@ export class DiffTree {
87
181
  // Diffed key by key into one result
88
182
  const diff = [];
89
183
  const rootKeyFunc = this.routes.root.keyFunc;
90
- const strategy = CommitStrategy(rootKeyFunc);
184
+ const strategy = OpaqueStrategy(this.opaque, CommitStrategy(rootKeyFunc));
91
185
  const identity = CommitIdentity(rootKeyFunc);
92
186
  // How many of the committed keys are still in the root, to tell whether any are gone
93
187
  let kept = 0;
@@ -124,6 +218,18 @@ export class DiffTree {
124
218
  return ReadSnapshot(this.snapshot, this.rootState, path)[0];
125
219
  }
126
220
  }
221
+ /**
222
+ * A root key marked opaque is written whole; anything else is left to `strategy`.
223
+ */
224
+ function OpaqueStrategy(opaque, strategy) {
225
+ if (opaque.size === 0)
226
+ return strategy;
227
+ return function (newValue, oldValue, path) {
228
+ if (path.length === 1 && opaque.has(path[0]))
229
+ return "write";
230
+ return strategy?.(newValue, oldValue, path);
231
+ };
232
+ }
127
233
  /**
128
234
  * How `Commit` diffs the root's snapshots for a Store with the same keyFunc:
129
235
  * - An array whose first element is keyed is spliced, its elements matched by CommitIdentity.