j-templates 9.0.1 → 10.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.
Files changed (64) hide show
  1. package/SYNTAX_PRIMER.md +181 -101
  2. package/Store/Diff/channelClient.d.ts +104 -0
  3. package/Store/Diff/channelClient.js +225 -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 +186 -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 +38 -0
  16. package/Store/Diff/hostPublisher.js +147 -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 +200 -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/paths.d.ts +2 -1
  27. package/Store/Diff/utils/paths.js +3 -2
  28. package/Store/Diff/utils/routes.d.ts +18 -0
  29. package/Store/Diff/utils/routes.js +33 -2
  30. package/Store/Diff/utils/state.js +4 -3
  31. package/Store/Store/store.d.ts +41 -4
  32. package/Store/Store/store.js +62 -7
  33. package/Store/Store/storeAsync.d.ts +134 -25
  34. package/Store/Store/storeAsync.js +235 -33
  35. package/Store/Store/storeSync.d.ts +91 -11
  36. package/Store/Store/storeSync.js +107 -12
  37. package/Store/Tree/observableNode.d.ts +3 -0
  38. package/Store/Tree/observableNode.js +87 -13
  39. package/Store/Tree/observableScope.d.ts +3 -1
  40. package/Store/Tree/observableScope.js +12 -0
  41. package/Store/Tree/opaque.d.ts +30 -0
  42. package/Store/Tree/opaque.js +35 -0
  43. package/Store/Tree/snapshot.js +5 -4
  44. package/Store/index.d.ts +4 -1
  45. package/Store/index.js +1 -1
  46. package/Utils/decorators.d.ts +0 -98
  47. package/Utils/decorators.js +0 -156
  48. package/Utils/emitter.d.ts +11 -4
  49. package/Utils/emitter.js +25 -2
  50. package/Utils/index.d.ts +2 -0
  51. package/Utils/index.js +1 -0
  52. package/Utils/pattern.d.ts +14 -0
  53. package/Utils/pattern.js +20 -0
  54. package/Utils/subscriptionManager.d.ts +78 -0
  55. package/Utils/subscriptionManager.js +159 -0
  56. package/package.json +1 -1
  57. package/Store/Diff/diffTreeWorker.d.ts +0 -6
  58. package/Store/Diff/diffTreeWorker.js +0 -22
  59. package/Store/Diff/diffTreeWriter.d.ts +0 -92
  60. package/Store/Diff/diffTreeWriter.js +0 -137
  61. package/Store/Diff/workerQueue.d.ts +0 -7
  62. package/Store/Diff/workerQueue.js +0 -29
  63. package/Store/Store/pushStore.d.ts +0 -53
  64. package/Store/Store/pushStore.js +0 -77
@@ -0,0 +1,93 @@
1
+ import { JsonDiffResult } from "../../Utils/json";
2
+ import { DiffTree } from "./diffTree";
3
+ import { IDiffPost, IObservableDiffTreeConfig, ISnapshotUpdate, ISubscribeOptions } from "./diffTree.types";
4
+ import type { IDiffTreeEndpoint } from "./diffTreeEndpoint.types";
5
+ import type { IKeyFunc } from "./utils/diffTree.types";
6
+ /**
7
+ * A `DiffTree` that delivers every `Commit()` to its subscribers: the tree, observable.
8
+ *
9
+ * - **`Commit()` diffs and delivers; it doesn't run projections.** Call `RunProjections()` first
10
+ * when the change needs them. A commit without it delivers the changed roots with the projection
11
+ * outputs as they were, which is how to send a small change (a flag) ahead of a long write.
12
+ * - **Each subscription names its roots and form** (`ISubscribeOptions`). The diff is computed
13
+ * once per commit and shaped for each: diff records, or whole changed roots. A commit with no
14
+ * changes delivers nothing.
15
+ * - **A new subscription gets the committed state first**, inside `Subscribe`, then every commit.
16
+ * Uncommitted writes arrive with the next commit, like everyone else's.
17
+ * - **Delivery is synchronous**, inside `Commit()`. A subscriber may write to another tree, but
18
+ * a change to this one, or a commit, during its delivery throws. Values are shared with the
19
+ * tree, never cloned: treat them as read-only.
20
+ * - When a subscriber throws, the others still get the commit, and `Commit()` throws the first error.
21
+ *
22
+ * @example
23
+ * const tree = new ObservableDiffTree({ keyFunc, routes });
24
+ * const stop = tree.Subscribe({ roots: ["loading", "$projection_*"] }, post => store.Apply(post.diff));
25
+ * tree.WritePath("loading", true);
26
+ * tree.Commit();
27
+ */
28
+ export declare class ObservableDiffTree extends DiffTree {
29
+ private subscriptions;
30
+ private seq;
31
+ /** How many deliveries are running: changes and commits throw while it isn't 0 */
32
+ private delivering;
33
+ /** The endpoint the tree was built for, which `DiffTreeEndpoint.Host` checks */
34
+ readonly Endpoint?: IDiffTreeEndpoint;
35
+ /** How the tree keys its data: its config's, or its endpoint's. A `StoreSync` given the tree takes it. */
36
+ readonly KeyFunc?: IKeyFunc;
37
+ /**
38
+ * @param config The tree's endpoint, keyFunc, routes and opaque keys (`IObservableDiffTreeConfig`).
39
+ * @throws When a keyFunc is given with an endpoint (the endpoint's is the tree's), or a route of
40
+ * the tree's own is one of the endpoint's follow routes.
41
+ */
42
+ constructor(config?: IObservableDiffTreeConfig);
43
+ /** The number of the last commit that changed something; 0 before the first */
44
+ get Seq(): number;
45
+ /**
46
+ * Diffs the tree since the last commit and delivers the changes to every subscription whose
47
+ * roots they touch.
48
+ * @returns The diff.
49
+ * @throws During a delivery from this tree, or the first error a subscriber threw.
50
+ */
51
+ Commit(): JsonDiffResult;
52
+ /**
53
+ * Subscribes to the tree's commits. The committed state of the matching roots is delivered
54
+ * first, before this returns, in the subscription's form: a `write` record per root, or every
55
+ * root; nothing when no root matches. Every later commit that touches them follows. A
56
+ * subscriber that held roots from an earlier subscription clears them before subscribing again.
57
+ * @returns A function that ends the subscription.
58
+ * @throws The first delivery's error, after ending the subscription.
59
+ */
60
+ Subscribe(options: ISubscribeOptions & {
61
+ form: "snapshot";
62
+ }, callback: (update: ISnapshotUpdate) => void): () => void;
63
+ Subscribe(options: ISubscribeOptions & {
64
+ form?: "diff";
65
+ }, callback: (post: IDiffPost) => void): () => void;
66
+ /**
67
+ * Waits for the first delivery in which every key has a committed value: at once when they all
68
+ * have one, otherwise the commit that adds the last. The values come from one commit.
69
+ * @param keys Exact root keys; no patterns.
70
+ * @param options `signal` cancels the wait, rejecting with its reason.
71
+ * @returns Each key's committed value.
72
+ */
73
+ Once(keys: readonly string[], options?: {
74
+ signal?: AbortSignal;
75
+ }): Promise<{
76
+ [key: string]: unknown;
77
+ }>;
78
+ /** Ends every subscription made with this `group`; see `ISubscribeOptions.group` */
79
+ Disconnect(group: unknown): void;
80
+ /** How many subscriptions name a root key exactly now; see `SubscriptionManager.SubscriberCount` */
81
+ SubscriberCount(key: string): number;
82
+ /**
83
+ * Calls back when a root key matching a pattern loses its last exact subscription. It only
84
+ * reports: whether and when to delete the key is the caller's. It runs inside the unsubscribe,
85
+ * which may be inside a delivery, so a callback that deletes the key does it later (a timer).
86
+ * @param pattern An exact key or a pattern ending in `*`.
87
+ * @returns A function that removes the callback.
88
+ */
89
+ OnReleased(pattern: string, callback: (key: string) => void): () => void;
90
+ private Deliver;
91
+ /** A subscriber can't change or commit the tree delivering to it */
92
+ protected BeforeChange(): void;
93
+ }
@@ -0,0 +1,200 @@
1
+ import { IsPattern } from "../../Utils/pattern";
2
+ import { SubscriptionManager } from "../../Utils/subscriptionManager";
3
+ import { DiffTree } from "./diffTree";
4
+ import { FollowRoutes } from "./utils/routes";
5
+ /**
6
+ * A `DiffTree` that delivers every `Commit()` to its subscribers: the tree, observable.
7
+ *
8
+ * - **`Commit()` diffs and delivers; it doesn't run projections.** Call `RunProjections()` first
9
+ * when the change needs them. A commit without it delivers the changed roots with the projection
10
+ * outputs as they were, which is how to send a small change (a flag) ahead of a long write.
11
+ * - **Each subscription names its roots and form** (`ISubscribeOptions`). The diff is computed
12
+ * once per commit and shaped for each: diff records, or whole changed roots. A commit with no
13
+ * changes delivers nothing.
14
+ * - **A new subscription gets the committed state first**, inside `Subscribe`, then every commit.
15
+ * Uncommitted writes arrive with the next commit, like everyone else's.
16
+ * - **Delivery is synchronous**, inside `Commit()`. A subscriber may write to another tree, but
17
+ * a change to this one, or a commit, during its delivery throws. Values are shared with the
18
+ * tree, never cloned: treat them as read-only.
19
+ * - When a subscriber throws, the others still get the commit, and `Commit()` throws the first error.
20
+ *
21
+ * @example
22
+ * const tree = new ObservableDiffTree({ keyFunc, routes });
23
+ * const stop = tree.Subscribe({ roots: ["loading", "$projection_*"] }, post => store.Apply(post.diff));
24
+ * tree.WritePath("loading", true);
25
+ * tree.Commit();
26
+ */
27
+ export class ObservableDiffTree extends DiffTree {
28
+ /**
29
+ * @param config The tree's endpoint, keyFunc, routes and opaque keys (`IObservableDiffTreeConfig`).
30
+ * @throws When a keyFunc is given with an endpoint (the endpoint's is the tree's), or a route of
31
+ * the tree's own is one of the endpoint's follow routes.
32
+ */
33
+ constructor(config = {}) {
34
+ const { endpoint, routes = {} } = config;
35
+ if (endpoint && config.keyFunc)
36
+ throw new Error(`ObservableDiffTree: the endpoint ${endpoint.name} gives the tree its keyFunc; leave it out of the config.`);
37
+ const follow = endpoint ? FollowRoutes(Object.fromEntries(endpoint.remotes.map(remote => [remote.source, remote.keyFunc]))) : {};
38
+ for (const route of Object.keys(follow))
39
+ if (route in routes)
40
+ throw new Error(`ObservableDiffTree: the route ${route} is ${endpoint.name}'s follow route for a source; rename it.`);
41
+ const keyFunc = endpoint ? endpoint.keyFunc : config.keyFunc;
42
+ super(keyFunc, { ...routes, ...follow });
43
+ this.subscriptions = new SubscriptionManager();
44
+ this.seq = 0;
45
+ /** How many deliveries are running: changes and commits throw while it isn't 0 */
46
+ this.delivering = 0;
47
+ this.Endpoint = endpoint;
48
+ this.KeyFunc = keyFunc;
49
+ config.opaque && this.MarkOpaque(config.opaque);
50
+ }
51
+ /** The number of the last commit that changed something; 0 before the first */
52
+ get Seq() {
53
+ return this.seq;
54
+ }
55
+ /**
56
+ * Diffs the tree since the last commit and delivers the changes to every subscription whose
57
+ * roots they touch.
58
+ * @returns The diff.
59
+ * @throws During a delivery from this tree, or the first error a subscriber threw.
60
+ */
61
+ Commit() {
62
+ this.BeforeChange();
63
+ const diff = super.Commit();
64
+ if (diff.length === 0)
65
+ return diff;
66
+ const seq = ++this.seq;
67
+ const changed = new Set();
68
+ const deleted = new Set();
69
+ for (let x = 0; x < diff.length; x++) {
70
+ const record = diff[x];
71
+ const key = record.path[0];
72
+ changed.add(key);
73
+ record.type === "delete" && record.path.length === 1 && deleted.add(key);
74
+ }
75
+ const errors = [];
76
+ for (const { id, subscription, keys } of this.subscriptions.Matching(changed)) {
77
+ // An earlier subscriber's delivery may have ended this one
78
+ if (!this.subscriptions.Has(id))
79
+ continue;
80
+ let delivery;
81
+ if (subscription.form === "diff") {
82
+ const matched = this.subscriptions.MatchesAll(id) ? diff : FilterDiff(diff, new Set(keys));
83
+ delivery = { seq, diff: matched };
84
+ }
85
+ else {
86
+ const roots = {};
87
+ const gone = [];
88
+ for (const key of keys)
89
+ deleted.has(key) ? gone.push(key) : roots[key] = this.Committed(key);
90
+ delivery = { seq, roots, deleted: gone };
91
+ }
92
+ try {
93
+ this.Deliver(subscription, delivery);
94
+ }
95
+ catch (err) {
96
+ errors.push(err);
97
+ }
98
+ }
99
+ if (errors.length > 0)
100
+ throw errors[0];
101
+ return diff;
102
+ }
103
+ Subscribe(options, callback) {
104
+ const subscription = { form: options.form ?? "diff", callback };
105
+ const id = this.subscriptions.Subscribe(options.roots, subscription, { group: options.group });
106
+ const keys = this.subscriptions.MatchingKeys(id, this.CommittedKeys());
107
+ const delivery = subscription.form === "diff"
108
+ ? { seq: this.seq, diff: keys.map(key => ({ type: "write", path: [key], value: this.Committed(key) })) }
109
+ : { seq: this.seq, roots: Object.fromEntries(keys.map(key => [key, this.Committed(key)])), deleted: [] };
110
+ try {
111
+ keys.length > 0 && this.Deliver(subscription, delivery);
112
+ }
113
+ catch (err) {
114
+ this.subscriptions.Unsubscribe(id);
115
+ throw err;
116
+ }
117
+ return () => { this.subscriptions.Unsubscribe(id); };
118
+ }
119
+ /**
120
+ * Waits for the first delivery in which every key has a committed value: at once when they all
121
+ * have one, otherwise the commit that adds the last. The values come from one commit.
122
+ * @param keys Exact root keys; no patterns.
123
+ * @param options `signal` cancels the wait, rejecting with its reason.
124
+ * @returns Each key's committed value.
125
+ */
126
+ Once(keys, options = {}) {
127
+ const { signal } = options;
128
+ return new Promise((resolve, reject) => {
129
+ if (keys.some(IsPattern))
130
+ return reject(new Error(`Once takes exact keys, not patterns: ${keys.join(", ")}`));
131
+ if (signal?.aborted)
132
+ return reject(signal.reason);
133
+ let done = false;
134
+ let stop;
135
+ const onAbort = () => {
136
+ finish();
137
+ reject(signal.reason);
138
+ };
139
+ const finish = () => {
140
+ done = true;
141
+ stop?.();
142
+ signal?.removeEventListener("abort", onAbort);
143
+ };
144
+ stop = this.Subscribe({ roots: keys, form: "snapshot" }, () => {
145
+ if (done)
146
+ return;
147
+ const values = {};
148
+ for (const key of keys) {
149
+ const value = this.Committed(key);
150
+ if (value === undefined)
151
+ return;
152
+ values[key] = value;
153
+ }
154
+ finish();
155
+ resolve(values);
156
+ });
157
+ // Resolved by the first delivery, before `stop` was set
158
+ if (done)
159
+ stop();
160
+ else
161
+ signal?.addEventListener("abort", onAbort, { once: true });
162
+ });
163
+ }
164
+ /** Ends every subscription made with this `group`; see `ISubscribeOptions.group` */
165
+ Disconnect(group) {
166
+ this.subscriptions.Disconnect(group);
167
+ }
168
+ /** How many subscriptions name a root key exactly now; see `SubscriptionManager.SubscriberCount` */
169
+ SubscriberCount(key) {
170
+ return this.subscriptions.SubscriberCount(key);
171
+ }
172
+ /**
173
+ * Calls back when a root key matching a pattern loses its last exact subscription. It only
174
+ * reports: whether and when to delete the key is the caller's. It runs inside the unsubscribe,
175
+ * which may be inside a delivery, so a callback that deletes the key does it later (a timer).
176
+ * @param pattern An exact key or a pattern ending in `*`.
177
+ * @returns A function that removes the callback.
178
+ */
179
+ OnReleased(pattern, callback) {
180
+ return this.subscriptions.OnReleased(pattern, callback);
181
+ }
182
+ Deliver(subscription, delivery) {
183
+ this.delivering++;
184
+ try {
185
+ subscription.callback(delivery);
186
+ }
187
+ finally {
188
+ this.delivering--;
189
+ }
190
+ }
191
+ /** A subscriber can't change or commit the tree delivering to it */
192
+ BeforeChange() {
193
+ if (this.delivering > 0)
194
+ throw new Error("ObservableDiffTree: a subscriber can't write to or commit the tree delivering to it.");
195
+ }
196
+ }
197
+ /** The records whose root is one of `keys` */
198
+ function FilterDiff(diff, keys) {
199
+ return diff.filter(record => keys.has(record.path[0]));
200
+ }
@@ -0,0 +1,33 @@
1
+ import { IChannel } from "./diffTree.types";
2
+ type ConnectHandler = (port: MessagePort) => (() => void) | void;
3
+ /**
4
+ * Handles a worker's ports to a source it reads from, given by a `DiffTreeHub` (`Register(name,
5
+ * factory, { sources })`). The handler runs for each port: when the worker starts, and again when the source
6
+ * restarts. Before it runs for a new port, the cleanup it returned for the last one runs and that
7
+ * port is closed. A port that arrives before the handler is registered is kept for it, once the
8
+ * worker listens: `HostPublisher` starts listening, and so does the first `OnConnect`.
9
+ *
10
+ * The source must be one the worker was registered with (`sources`). The hub tells the worker its
11
+ * sources when it starts: `OnConnect` for another source throws then, or, when it was called
12
+ * before they arrived (while the worker's module first runs), throws as the worker's uncaught
13
+ * error when they do, which the main thread sees as the `Worker`'s `error` event.
14
+ *
15
+ * @example
16
+ * // trace worker
17
+ * OnConnect("engine", port => {
18
+ * tree.DeleteRoute("/engine/");
19
+ * return Subscribe(port, { roots: ["index"], form: "snapshot" }, update => { ... });
20
+ * });
21
+ * @param source The source worker's registered name.
22
+ * @param handler Subscribes over the port; may return a function that ends what it started.
23
+ * @param scope The worker's own channel; `self` when omitted.
24
+ * @returns A function that runs the current cleanup and removes the handler.
25
+ * @throws When the hub has told the worker its sources, and `source` isn't one.
26
+ */
27
+ export declare function OnConnect(source: string, handler: ConnectHandler, scope?: IChannel): () => void;
28
+ /**
29
+ * Starts keeping the `jt-connect` ports that arrive on a worker's channel, so a port that arrives
30
+ * before its `OnConnect` handler is registered is kept for it. `HostPublisher` calls it.
31
+ */
32
+ export declare function ListenForConnections(scope: IChannel): void;
33
+ export {};
@@ -0,0 +1,83 @@
1
+ const registries = new WeakMap();
2
+ /**
3
+ * Handles a worker's ports to a source it reads from, given by a `DiffTreeHub` (`Register(name,
4
+ * factory, { sources })`). The handler runs for each port: when the worker starts, and again when the source
5
+ * restarts. Before it runs for a new port, the cleanup it returned for the last one runs and that
6
+ * port is closed. A port that arrives before the handler is registered is kept for it, once the
7
+ * worker listens: `HostPublisher` starts listening, and so does the first `OnConnect`.
8
+ *
9
+ * The source must be one the worker was registered with (`sources`). The hub tells the worker its
10
+ * sources when it starts: `OnConnect` for another source throws then, or, when it was called
11
+ * before they arrived (while the worker's module first runs), throws as the worker's uncaught
12
+ * error when they do, which the main thread sees as the `Worker`'s `error` event.
13
+ *
14
+ * @example
15
+ * // trace worker
16
+ * OnConnect("engine", port => {
17
+ * tree.DeleteRoute("/engine/");
18
+ * return Subscribe(port, { roots: ["index"], form: "snapshot" }, update => { ... });
19
+ * });
20
+ * @param source The source worker's registered name.
21
+ * @param handler Subscribes over the port; may return a function that ends what it started.
22
+ * @param scope The worker's own channel; `self` when omitted.
23
+ * @returns A function that runs the current cleanup and removes the handler.
24
+ * @throws When the hub has told the worker its sources, and `source` isn't one.
25
+ */
26
+ export function OnConnect(source, handler, scope = globalThis) {
27
+ const connections = Connections(scope);
28
+ if (connections.declared && !connections.declared.has(source))
29
+ throw UndeclaredError([source], connections.declared);
30
+ connections.cleanups.get(source)?.();
31
+ connections.cleanups.delete(source);
32
+ connections.handlers.set(source, handler);
33
+ const port = connections.ports.get(source);
34
+ port && Run(connections, source, port);
35
+ return () => {
36
+ if (connections.handlers.get(source) !== handler)
37
+ return;
38
+ connections.cleanups.get(source)?.();
39
+ connections.cleanups.delete(source);
40
+ connections.handlers.delete(source);
41
+ };
42
+ }
43
+ /**
44
+ * Starts keeping the `jt-connect` ports that arrive on a worker's channel, so a port that arrives
45
+ * before its `OnConnect` handler is registered is kept for it. `HostPublisher` calls it.
46
+ */
47
+ export function ListenForConnections(scope) {
48
+ Connections(scope);
49
+ }
50
+ function Connections(scope) {
51
+ let connections = registries.get(scope);
52
+ if (!connections) {
53
+ const created = connections = { ports: new Map(), handlers: new Map(), cleanups: new Map() };
54
+ registries.set(scope, created);
55
+ scope.addEventListener("message", (event) => {
56
+ const message = event.data;
57
+ if (message?.type === "jt-sources") {
58
+ const declared = created.declared = new Set(message.sources);
59
+ const undeclared = Array.from(created.handlers.keys()).filter(source => !declared.has(source));
60
+ if (undeclared.length > 0)
61
+ throw UndeclaredError(undeclared, declared);
62
+ return;
63
+ }
64
+ if (message?.type !== "jt-connect")
65
+ return;
66
+ const { source, port } = message;
67
+ created.cleanups.get(source)?.();
68
+ created.cleanups.delete(source);
69
+ created.ports.get(source)?.close();
70
+ created.ports.set(source, port);
71
+ created.handlers.has(source) && Run(created, source, port);
72
+ });
73
+ }
74
+ return connections;
75
+ }
76
+ function UndeclaredError(sources, declared) {
77
+ const list = declared.size > 0 ? Array.from(declared).join(", ") : "none";
78
+ return new Error(`OnConnect: ${sources.join(", ")} isn't a source this worker was registered with (sources: ${list}); add it to the worker's sources in DiffTreeHub.Register.`);
79
+ }
80
+ function Run(connections, source, port) {
81
+ const cleanup = connections.handlers.get(source)(port);
82
+ cleanup && connections.cleanups.set(source, cleanup);
83
+ }
@@ -95,6 +95,13 @@ export type IRouteDefinition = {
95
95
  projections: IProjectionDefinitions;
96
96
  keyFunc?: IKeyFunc;
97
97
  routes?: IRouteDefinitions;
98
+ /**
99
+ * A follow route: it holds another tree's roots, written only by `ApplyDiff` from a subscription
100
+ * to that tree, under the source's own keys. It has no projections of its own, so a source's
101
+ * projection output (`$projection_<key>`) is held as it is, and any other write to it throws.
102
+ * Projections elsewhere read it as any route. See `FollowRoutes`.
103
+ */
104
+ follow?: boolean;
98
105
  };
99
106
  export type IRouteDefinitions = {
100
107
  [route: string]: IRouteDefinition;
@@ -110,4 +117,6 @@ export type IRoute = {
110
117
  childPartCounts: number[];
111
118
  keyFunc?: IKeyFunc;
112
119
  routeDefinitions?: IRouteDefinitions;
120
+ /** A follow route, written only by `ApplyDiff`; see `IRouteDefinition.follow` */
121
+ follow: boolean;
113
122
  };
@@ -1,3 +1,4 @@
1
+ import { JsonDiffResult } from "../../../Utils/json";
1
2
  import { ISnapshotState } from "../../Tree/snapshot";
2
3
  type KeyFunc = {
3
4
  (value: any): string | undefined;
@@ -43,4 +44,15 @@ export declare function ApplyValueToPath(context: WriteContext, path: string, va
43
44
  * @throws When there's no array at the path
44
45
  */
45
46
  export declare function SpliceValueAtPath(context: WriteContext, path: string, start: number, deleteCount: number, items: readonly unknown[]): void;
47
+ /**
48
+ * Applies another tree's diff records to a route, in order, as writes: a `write` as
49
+ * ApplyValueToPath writes it, a `splice` as SpliceValueAtPath splices, its inserted values
50
+ * flattened, and a `delete` by removing the property its path ends with, a root key's from the
51
+ * route's state. The records' paths are parts, relative to the route, so a key holding `.` or `/`
52
+ * is applied as it is. Records from a tree with the same keyFunc never write inside a slot: the
53
+ * change to an entity comes as its own root's records.
54
+ * @param route A full route path, e.g. `/` or `/engine/`
55
+ * @throws When a record's parent doesn't exist, or a splice's path holds no array
56
+ */
57
+ export declare function ApplyRecords(context: WriteContext, records: JsonDiffResult, route: string): void;
46
58
  export {};
@@ -74,6 +74,46 @@ export function SpliceValueAtPath(context, path, start, deleteCount, items) {
74
74
  JsonApplySplices(array, [{ index, delete: actualDelete, insert }], KeepValue);
75
75
  Snapshot.Invalidate(context.snapshot, array);
76
76
  }
77
+ /**
78
+ * Applies another tree's diff records to a route, in order, as writes: a `write` as
79
+ * ApplyValueToPath writes it, a `splice` as SpliceValueAtPath splices, its inserted values
80
+ * flattened, and a `delete` by removing the property its path ends with, a root key's from the
81
+ * route's state. The records' paths are parts, relative to the route, so a key holding `.` or `/`
82
+ * is applied as it is. Records from a tree with the same keyFunc never write inside a slot: the
83
+ * change to an entity comes as its own root's records.
84
+ * @param route A full route path, e.g. `/` or `/engine/`
85
+ * @throws When a record's parent doesn't exist, or a splice's path holds no array
86
+ */
87
+ export function ApplyRecords(context, records, route) {
88
+ const routeRoot = RouteState(context.root, route, true);
89
+ const keyFunc = context.routeKeyFunc(route);
90
+ for (let x = 0; x < records.length; x++) {
91
+ const record = records[x];
92
+ const parts = record.path.map(String);
93
+ if (record.type === "write") {
94
+ WriteValue(context, route, parts, record.value);
95
+ continue;
96
+ }
97
+ if (record.type === "delete") {
98
+ const parent = ReadPathParts(routeRoot, parts, parts.length - 1);
99
+ if (JsonType(parent) !== "value" && Object.hasOwn(parent, parts[parts.length - 1]))
100
+ Remove(context.snapshot, parent, parts[parts.length - 1]);
101
+ continue;
102
+ }
103
+ const array = ReadPathParts(routeRoot, parts, parts.length);
104
+ if (!Array.isArray(array))
105
+ throw new Error(`Unable to apply a splice at ${parts.join(PROP_DELIMETER)}: no array there`);
106
+ const targetPath = JoinPaths(route, parts.join(PROP_DELIMETER));
107
+ const splices = record.splices.map(splice => ({
108
+ ...splice,
109
+ insert: splice.insert.map(item => item.type === SPLICE_ACTION.INSERT
110
+ ? { type: SPLICE_ACTION.INSERT, value: Flatten(context, keyFunc, route, targetPath, item.value) }
111
+ : item)
112
+ }));
113
+ JsonApplySplices(array, splices, KeepValue);
114
+ Snapshot.Invalidate(context.snapshot, array);
115
+ }
116
+ }
77
117
  /** A written path's route from the root (`/` for the root route) and its property path */
78
118
  function WriteRoute(path) {
79
119
  const [route, property] = SplitRouteAndPath(path);
@@ -3,11 +3,12 @@
3
3
  * path `value.item` in the route `/channel/1/`. A path without a route (`value.item`) is in the
4
4
  * root route.
5
5
  */
6
+ import { WILDCARD } from "../../../Utils/pattern";
6
7
  export declare const ROOT_ROUTE = "/";
7
8
  export declare const PROJECTION_PREFIX = "$projection_";
8
9
  export declare const ROUTE_DELIMETER = "/";
9
10
  export declare const PROP_DELIMETER = ".";
10
- export declare const WILDCARD = "*";
11
+ export { WILDCARD };
11
12
  export declare function SplitRouteAndPath(path: string): readonly [string, string];
12
13
  /**
13
14
  * A path's route and property path. Only a path starting with `/` has a route: any other path
@@ -3,11 +3,12 @@
3
3
  * path `value.item` in the route `/channel/1/`. A path without a route (`value.item`) is in the
4
4
  * root route.
5
5
  */
6
+ import { PatternMatches, WILDCARD } from "../../../Utils/pattern";
6
7
  export const ROOT_ROUTE = "/";
7
8
  export const PROJECTION_PREFIX = "$projection_";
8
9
  export const ROUTE_DELIMETER = "/";
9
10
  export const PROP_DELIMETER = ".";
10
- export const WILDCARD = "*";
11
+ export { WILDCARD };
11
12
  export function SplitRouteAndPath(path) {
12
13
  const lastSlash = path.lastIndexOf(ROUTE_DELIMETER);
13
14
  const route = path.slice(0, lastSlash + 1);
@@ -56,7 +57,7 @@ export function RouteMatches(pattern, route) {
56
57
  if (patternParts.length !== routeParts.length)
57
58
  return false;
58
59
  for (let x = 0; x < patternParts.length; x++)
59
- if (patternParts[x].endsWith(WILDCARD) ? !routeParts[x].startsWith(patternParts[x].slice(0, -1)) : patternParts[x] !== routeParts[x])
60
+ if (!PatternMatches(patternParts[x], routeParts[x]))
60
61
  return false;
61
62
  return true;
62
63
  }
@@ -25,3 +25,21 @@ export declare class RouteInstances {
25
25
  */
26
26
  Delete(route: string): void;
27
27
  }
28
+ /**
29
+ * The follow routes for the trees a tree follows, one per source by name: `/<name>/`, holding the
30
+ * source's roots under their own keys, each keyed entity at its home in the route (its key is
31
+ * joined to the route, so it can't meet the tree's own). Give each source's keyFunc: the trees that
32
+ * pass data identify it the same way, or a slot holding an entity elsewhere in the source keeps its
33
+ * first value here. Merge them into the tree's route definitions; a tree built from an endpoint
34
+ * (`IObservableDiffTreeConfig.endpoint`) has its sources' added for it.
35
+ * @example
36
+ * const tree = new ObservableDiffTree({ routes: {
37
+ * "/": { projections: { ... } },
38
+ * ...FollowRoutes({ project: entityKey, engine: undefined }),
39
+ * } });
40
+ * // a subscription to the project's tree applies its posts at /project/
41
+ * subscription.Connect(port); // callback: post => tree.ApplyDiff(post.diff, "/project/")
42
+ */
43
+ export declare function FollowRoutes(sources: {
44
+ [name: string]: IKeyFunc | undefined;
45
+ }): IRouteDefinitions;
@@ -1,4 +1,4 @@
1
- import { IsWildcardPath, JoinPaths, PathsOverlap, PROJECTION_PREFIX, ROOT_ROUTE, RouteSegments, ValidateWildcardPath } from "./paths";
1
+ import { IsWildcardPath, JoinPaths, PathsOverlap, PROJECTION_PREFIX, ROOT_ROUTE, ROUTE_DELIMETER, RouteSegments, ValidateWildcardPath } from "./paths";
2
2
  /**
3
3
  * A DiffTree's route instances: one per route path a definition matches, created from the
4
4
  * definition on first use, each with its own projections and keyFunc.
@@ -124,6 +124,9 @@ function MatchRouteDefinition(definitions, segments, index) {
124
124
  * @param defaultKeyFunc The tree's keyFunc, used when the definition doesn't define one
125
125
  */
126
126
  function CreateRoute(parts, routePath, definition, defaultKeyFunc) {
127
+ const follow = definition.follow === true;
128
+ if (follow && Object.keys(definition.projections).length > 0)
129
+ throw new Error(`Invalid follow route ${routePath}: a follow route holds another tree's roots and has no projections of its own.`);
127
130
  const projections = [];
128
131
  for (const key in definition.projections) {
129
132
  const projectionDefinition = definition.projections[key];
@@ -156,9 +159,37 @@ function CreateRoute(parts, routePath, definition, defaultKeyFunc) {
156
159
  childRoutes: new Map(),
157
160
  childPartCounts: PartCounts(definition.routes),
158
161
  keyFunc: definition.keyFunc ?? defaultKeyFunc,
159
- routeDefinitions: definition.routes
162
+ routeDefinitions: definition.routes,
163
+ follow
160
164
  };
161
165
  }
166
+ /** A source's keyFunc when it has none: a follow route doesn't take the tree's. */
167
+ const NoKey = () => undefined;
168
+ /**
169
+ * The follow routes for the trees a tree follows, one per source by name: `/<name>/`, holding the
170
+ * source's roots under their own keys, each keyed entity at its home in the route (its key is
171
+ * joined to the route, so it can't meet the tree's own). Give each source's keyFunc: the trees that
172
+ * pass data identify it the same way, or a slot holding an entity elsewhere in the source keeps its
173
+ * first value here. Merge them into the tree's route definitions; a tree built from an endpoint
174
+ * (`IObservableDiffTreeConfig.endpoint`) has its sources' added for it.
175
+ * @example
176
+ * const tree = new ObservableDiffTree({ routes: {
177
+ * "/": { projections: { ... } },
178
+ * ...FollowRoutes({ project: entityKey, engine: undefined }),
179
+ * } });
180
+ * // a subscription to the project's tree applies its posts at /project/
181
+ * subscription.Connect(port); // callback: post => tree.ApplyDiff(post.diff, "/project/")
182
+ */
183
+ export function FollowRoutes(sources) {
184
+ const definitions = {};
185
+ for (const name of Object.keys(sources)) {
186
+ if (name === "" || name.includes(ROUTE_DELIMETER) || IsParam(name))
187
+ throw new Error(`Invalid follow route name ${name}: one route segment, not a parameter.`);
188
+ // A route without a keyFunc takes the tree's: a source without one is given one that keys nothing
189
+ definitions[`${ROUTE_DELIMETER}${name}`] = { projections: {}, keyFunc: sources[name] ?? NoKey, follow: true };
190
+ }
191
+ return definitions;
192
+ }
162
193
  /** Whether a projection definition is a keyed projection: it has no `reads` */
163
194
  function IsKeyedDefinition(definition) {
164
195
  return definition.reads === undefined;
@@ -1,6 +1,7 @@
1
1
  import { JsonType } from "../../../Utils/json";
2
2
  import { Snapshot } from "../../Tree/snapshot";
3
- import { PROP_DELIMETER, ROUTE_DELIMETER, RouteAndProperty, SplitRouteAndPath, WILDCARD } from "./paths";
3
+ import { PROP_DELIMETER, ROUTE_DELIMETER, RouteAndProperty, SplitRouteAndPath } from "./paths";
4
+ import { IsPattern, PatternPrefix } from "../../../Utils/pattern";
4
5
  /**
5
6
  * The raw state of a DiffTree's routes. A route's state hangs off its parent route's state under
6
7
  * `ROUTE_STATE`, keyed by segment, so `Object.keys` of a route's state lists only its own values.
@@ -141,8 +142,8 @@ function InvalidateState(snapshot, value) {
141
142
  export function ExpandWildcardPath(root, path) {
142
143
  const [route, property] = SplitRouteAndPath(path);
143
144
  const parts = route.split(ROUTE_DELIMETER);
144
- const index = parts.findIndex(part => part.endsWith(WILDCARD));
145
- const prefix = parts[index].slice(0, -1);
145
+ const index = parts.findIndex(part => IsPattern(part));
146
+ const prefix = PatternPrefix(parts[index]);
146
147
  const parent = RouteState(root, parts.slice(0, index).join(ROUTE_DELIMETER) + ROUTE_DELIMETER);
147
148
  const children = parent?.[ROUTE_STATE] ?? {};
148
149
  const paths = {};