j-templates 9.0.0 → 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.
- package/Node/vNode.js +2 -0
- package/SYNTAX_PRIMER.md +181 -101
- package/Store/Diff/channelClient.d.ts +106 -0
- package/Store/Diff/channelClient.js +228 -0
- package/Store/Diff/defaultDiffTreeWorker.js +3 -4
- package/Store/Diff/diffTree.d.ts +56 -0
- package/Store/Diff/diffTree.js +110 -4
- package/Store/Diff/diffTree.types.d.ts +191 -21
- package/Store/Diff/diffTree.types.js +2 -10
- package/Store/Diff/diffTreeEndpoint.d.ts +101 -0
- package/Store/Diff/diffTreeEndpoint.js +173 -0
- package/Store/Diff/diffTreeEndpoint.types.d.ts +71 -0
- package/Store/Diff/diffTreeEndpoint.types.js +1 -0
- package/Store/Diff/diffTreeHub.d.ts +139 -0
- package/Store/Diff/diffTreeHub.js +346 -0
- package/Store/Diff/hostPublisher.d.ts +39 -0
- package/Store/Diff/hostPublisher.js +151 -0
- package/Store/Diff/index.d.ts +3 -0
- package/Store/Diff/index.js +3 -0
- package/Store/Diff/observableDiffTree.d.ts +93 -0
- package/Store/Diff/observableDiffTree.js +199 -0
- package/Store/Diff/onConnect.d.ts +33 -0
- package/Store/Diff/onConnect.js +83 -0
- package/Store/Diff/utils/diffTree.types.d.ts +9 -0
- package/Store/Diff/utils/homes.d.ts +12 -0
- package/Store/Diff/utils/homes.js +40 -0
- package/Store/Diff/utils/routes.d.ts +18 -0
- package/Store/Diff/utils/routes.js +33 -2
- package/Store/Store/store.d.ts +36 -4
- package/Store/Store/store.js +47 -6
- package/Store/Store/storeAsync.d.ts +80 -25
- package/Store/Store/storeAsync.js +102 -33
- package/Store/Store/storeSync.d.ts +76 -11
- package/Store/Store/storeSync.js +105 -12
- package/Store/Tree/observableNode.js +68 -13
- package/Store/Tree/observableScope.d.ts +1 -0
- package/Store/Tree/observableScope.js +4 -0
- package/Store/Tree/opaque.d.ts +30 -0
- package/Store/Tree/opaque.js +35 -0
- package/Store/Tree/snapshot.js +5 -4
- package/Store/index.d.ts +4 -1
- package/Store/index.js +1 -1
- package/Utils/decorators.d.ts +0 -98
- package/Utils/decorators.js +0 -156
- package/Utils/index.d.ts +2 -0
- package/Utils/index.js +1 -0
- package/Utils/subscriptionManager.d.ts +78 -0
- package/Utils/subscriptionManager.js +162 -0
- package/package.json +1 -1
- package/Store/Diff/diffTreeWorker.d.ts +0 -6
- package/Store/Diff/diffTreeWorker.js +0 -22
- package/Store/Diff/diffTreeWriter.d.ts +0 -92
- package/Store/Diff/diffTreeWriter.js +0 -137
- package/Store/Diff/workerQueue.d.ts +0 -7
- package/Store/Diff/workerQueue.js +0 -29
- package/Store/Store/pushStore.d.ts +0 -53
- 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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
ConnectWorkerToDiffTree(diffTree, self);
|
|
1
|
+
import { ObservableDiffTree } from "./observableDiffTree";
|
|
2
|
+
import { HostPublisher } from "./hostPublisher";
|
|
3
|
+
HostPublisher(new ObservableDiffTree(), self);
|
package/Store/Diff/diffTree.d.ts
CHANGED
|
@@ -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
|
package/Store/Diff/diffTree.js
CHANGED
|
@@ -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
|
|
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.
|