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.
Files changed (57) hide show
  1. package/Node/vNode.js +2 -0
  2. package/SYNTAX_PRIMER.md +181 -101
  3. package/Store/Diff/channelClient.d.ts +106 -0
  4. package/Store/Diff/channelClient.js +228 -0
  5. package/Store/Diff/defaultDiffTreeWorker.js +3 -4
  6. package/Store/Diff/diffTree.d.ts +56 -0
  7. package/Store/Diff/diffTree.js +110 -4
  8. package/Store/Diff/diffTree.types.d.ts +191 -21
  9. package/Store/Diff/diffTree.types.js +2 -10
  10. package/Store/Diff/diffTreeEndpoint.d.ts +101 -0
  11. package/Store/Diff/diffTreeEndpoint.js +173 -0
  12. package/Store/Diff/diffTreeEndpoint.types.d.ts +71 -0
  13. package/Store/Diff/diffTreeEndpoint.types.js +1 -0
  14. package/Store/Diff/diffTreeHub.d.ts +139 -0
  15. package/Store/Diff/diffTreeHub.js +346 -0
  16. package/Store/Diff/hostPublisher.d.ts +39 -0
  17. package/Store/Diff/hostPublisher.js +151 -0
  18. package/Store/Diff/index.d.ts +3 -0
  19. package/Store/Diff/index.js +3 -0
  20. package/Store/Diff/observableDiffTree.d.ts +93 -0
  21. package/Store/Diff/observableDiffTree.js +199 -0
  22. package/Store/Diff/onConnect.d.ts +33 -0
  23. package/Store/Diff/onConnect.js +83 -0
  24. package/Store/Diff/utils/diffTree.types.d.ts +9 -0
  25. package/Store/Diff/utils/homes.d.ts +12 -0
  26. package/Store/Diff/utils/homes.js +40 -0
  27. package/Store/Diff/utils/routes.d.ts +18 -0
  28. package/Store/Diff/utils/routes.js +33 -2
  29. package/Store/Store/store.d.ts +36 -4
  30. package/Store/Store/store.js +47 -6
  31. package/Store/Store/storeAsync.d.ts +80 -25
  32. package/Store/Store/storeAsync.js +102 -33
  33. package/Store/Store/storeSync.d.ts +76 -11
  34. package/Store/Store/storeSync.js +105 -12
  35. package/Store/Tree/observableNode.js +68 -13
  36. package/Store/Tree/observableScope.d.ts +1 -0
  37. package/Store/Tree/observableScope.js +4 -0
  38. package/Store/Tree/opaque.d.ts +30 -0
  39. package/Store/Tree/opaque.js +35 -0
  40. package/Store/Tree/snapshot.js +5 -4
  41. package/Store/index.d.ts +4 -1
  42. package/Store/index.js +1 -1
  43. package/Utils/decorators.d.ts +0 -98
  44. package/Utils/decorators.js +0 -156
  45. package/Utils/index.d.ts +2 -0
  46. package/Utils/index.js +1 -0
  47. package/Utils/subscriptionManager.d.ts +78 -0
  48. package/Utils/subscriptionManager.js +162 -0
  49. package/package.json +1 -1
  50. package/Store/Diff/diffTreeWorker.d.ts +0 -6
  51. package/Store/Diff/diffTreeWorker.js +0 -22
  52. package/Store/Diff/diffTreeWriter.d.ts +0 -92
  53. package/Store/Diff/diffTreeWriter.js +0 -137
  54. package/Store/Diff/workerQueue.d.ts +0 -7
  55. package/Store/Diff/workerQueue.js +0 -29
  56. package/Store/Store/pushStore.d.ts +0 -53
  57. package/Store/Store/pushStore.js +0 -77
@@ -1,3 +1,6 @@
1
1
  export * from "./diffTree.types";
2
2
  export * from "./utils/diffTree.types";
3
3
  export { PROJECTION_PREFIX } from "./utils/paths";
4
+ export { FollowRoutes } from "./utils/routes";
5
+ export * from "./diffTreeEndpoint.types";
6
+ export { DiffTreeEndpoint } from "./diffTreeEndpoint";
@@ -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,199 @@
1
+ import { SubscriptionManager } from "../../Utils/subscriptionManager";
2
+ import { DiffTree } from "./diffTree";
3
+ import { FollowRoutes } from "./utils/routes";
4
+ /**
5
+ * A `DiffTree` that delivers every `Commit()` to its subscribers: the tree, observable.
6
+ *
7
+ * - **`Commit()` diffs and delivers; it doesn't run projections.** Call `RunProjections()` first
8
+ * when the change needs them. A commit without it delivers the changed roots with the projection
9
+ * outputs as they were, which is how to send a small change (a flag) ahead of a long write.
10
+ * - **Each subscription names its roots and form** (`ISubscribeOptions`). The diff is computed
11
+ * once per commit and shaped for each: diff records, or whole changed roots. A commit with no
12
+ * changes delivers nothing.
13
+ * - **A new subscription gets the committed state first**, inside `Subscribe`, then every commit.
14
+ * Uncommitted writes arrive with the next commit, like everyone else's.
15
+ * - **Delivery is synchronous**, inside `Commit()`. A subscriber may write to another tree, but
16
+ * a change to this one, or a commit, during its delivery throws. Values are shared with the
17
+ * tree, never cloned: treat them as read-only.
18
+ * - When a subscriber throws, the others still get the commit, and `Commit()` throws the first error.
19
+ *
20
+ * @example
21
+ * const tree = new ObservableDiffTree({ keyFunc, routes });
22
+ * const stop = tree.Subscribe({ roots: ["loading", "$projection_*"] }, post => store.Apply(post.diff));
23
+ * tree.WritePath("loading", true);
24
+ * tree.Commit();
25
+ */
26
+ export class ObservableDiffTree extends DiffTree {
27
+ /**
28
+ * @param config The tree's endpoint, keyFunc, routes and opaque keys (`IObservableDiffTreeConfig`).
29
+ * @throws When a keyFunc is given with an endpoint (the endpoint's is the tree's), or a route of
30
+ * the tree's own is one of the endpoint's follow routes.
31
+ */
32
+ constructor(config = {}) {
33
+ const { endpoint, routes = {} } = config;
34
+ if (endpoint && config.keyFunc)
35
+ throw new Error(`ObservableDiffTree: the endpoint ${endpoint.name} gives the tree its keyFunc; leave it out of the config.`);
36
+ const follow = endpoint ? FollowRoutes(Object.fromEntries(endpoint.remotes.map(remote => [remote.source, remote.keyFunc]))) : {};
37
+ for (const route of Object.keys(follow))
38
+ if (route in routes)
39
+ throw new Error(`ObservableDiffTree: the route ${route} is ${endpoint.name}'s follow route for a source; rename it.`);
40
+ const keyFunc = endpoint ? endpoint.keyFunc : config.keyFunc;
41
+ super(keyFunc, { ...routes, ...follow });
42
+ this.subscriptions = new SubscriptionManager();
43
+ this.seq = 0;
44
+ /** How many deliveries are running: changes and commits throw while it isn't 0 */
45
+ this.delivering = 0;
46
+ this.Endpoint = endpoint;
47
+ this.KeyFunc = keyFunc;
48
+ config.opaque && this.MarkOpaque(config.opaque);
49
+ }
50
+ /** The number of the last commit that changed something; 0 before the first */
51
+ get Seq() {
52
+ return this.seq;
53
+ }
54
+ /**
55
+ * Diffs the tree since the last commit and delivers the changes to every subscription whose
56
+ * roots they touch.
57
+ * @returns The diff.
58
+ * @throws During a delivery from this tree, or the first error a subscriber threw.
59
+ */
60
+ Commit() {
61
+ this.BeforeChange();
62
+ const diff = super.Commit();
63
+ if (diff.length === 0)
64
+ return diff;
65
+ const seq = ++this.seq;
66
+ const changed = new Set();
67
+ const deleted = new Set();
68
+ for (let x = 0; x < diff.length; x++) {
69
+ const record = diff[x];
70
+ const key = record.path[0];
71
+ changed.add(key);
72
+ record.type === "delete" && record.path.length === 1 && deleted.add(key);
73
+ }
74
+ const errors = [];
75
+ for (const { id, subscription, keys } of this.subscriptions.Matching(changed)) {
76
+ // An earlier subscriber's delivery may have ended this one
77
+ if (!this.subscriptions.Has(id))
78
+ continue;
79
+ let delivery;
80
+ if (subscription.form === "diff") {
81
+ const matched = this.subscriptions.MatchesAll(id) ? diff : FilterDiff(diff, new Set(keys));
82
+ delivery = { seq, diff: matched };
83
+ }
84
+ else {
85
+ const roots = {};
86
+ const gone = [];
87
+ for (const key of keys)
88
+ deleted.has(key) ? gone.push(key) : roots[key] = this.Committed(key);
89
+ delivery = { seq, roots, deleted: gone };
90
+ }
91
+ try {
92
+ this.Deliver(subscription, delivery);
93
+ }
94
+ catch (err) {
95
+ errors.push(err);
96
+ }
97
+ }
98
+ if (errors.length > 0)
99
+ throw errors[0];
100
+ return diff;
101
+ }
102
+ Subscribe(options, callback) {
103
+ const subscription = { form: options.form ?? "diff", callback };
104
+ const id = this.subscriptions.Subscribe(options.roots, subscription, { group: options.group });
105
+ const keys = this.subscriptions.MatchingKeys(id, this.CommittedKeys());
106
+ const delivery = subscription.form === "diff"
107
+ ? { seq: this.seq, diff: keys.map(key => ({ type: "write", path: [key], value: this.Committed(key) })) }
108
+ : { seq: this.seq, roots: Object.fromEntries(keys.map(key => [key, this.Committed(key)])), deleted: [] };
109
+ try {
110
+ keys.length > 0 && this.Deliver(subscription, delivery);
111
+ }
112
+ catch (err) {
113
+ this.subscriptions.Unsubscribe(id);
114
+ throw err;
115
+ }
116
+ return () => { this.subscriptions.Unsubscribe(id); };
117
+ }
118
+ /**
119
+ * Waits for the first delivery in which every key has a committed value: at once when they all
120
+ * have one, otherwise the commit that adds the last. The values come from one commit.
121
+ * @param keys Exact root keys; no patterns.
122
+ * @param options `signal` cancels the wait, rejecting with its reason.
123
+ * @returns Each key's committed value.
124
+ */
125
+ Once(keys, options = {}) {
126
+ const { signal } = options;
127
+ return new Promise((resolve, reject) => {
128
+ if (keys.some(key => key.endsWith("*")))
129
+ return reject(new Error(`Once takes exact keys, not patterns: ${keys.join(", ")}`));
130
+ if (signal?.aborted)
131
+ return reject(signal.reason);
132
+ let done = false;
133
+ let stop;
134
+ const onAbort = () => {
135
+ finish();
136
+ reject(signal.reason);
137
+ };
138
+ const finish = () => {
139
+ done = true;
140
+ stop?.();
141
+ signal?.removeEventListener("abort", onAbort);
142
+ };
143
+ stop = this.Subscribe({ roots: keys, form: "snapshot" }, () => {
144
+ if (done)
145
+ return;
146
+ const values = {};
147
+ for (const key of keys) {
148
+ const value = this.Committed(key);
149
+ if (value === undefined)
150
+ return;
151
+ values[key] = value;
152
+ }
153
+ finish();
154
+ resolve(values);
155
+ });
156
+ // Resolved by the first delivery, before `stop` was set
157
+ if (done)
158
+ stop();
159
+ else
160
+ signal?.addEventListener("abort", onAbort, { once: true });
161
+ });
162
+ }
163
+ /** Ends every subscription made with this `group`; see `ISubscribeOptions.group` */
164
+ Disconnect(group) {
165
+ this.subscriptions.Disconnect(group);
166
+ }
167
+ /** How many subscriptions name a root key exactly now; see `SubscriptionManager.SubscriberCount` */
168
+ SubscriberCount(key) {
169
+ return this.subscriptions.SubscriberCount(key);
170
+ }
171
+ /**
172
+ * Calls back when a root key matching a pattern loses its last exact subscription. It only
173
+ * reports: whether and when to delete the key is the caller's. It runs inside the unsubscribe,
174
+ * which may be inside a delivery, so a callback that deletes the key does it later (a timer).
175
+ * @param pattern An exact key or a pattern ending in `*`.
176
+ * @returns A function that removes the callback.
177
+ */
178
+ OnReleased(pattern, callback) {
179
+ return this.subscriptions.OnReleased(pattern, callback);
180
+ }
181
+ Deliver(subscription, delivery) {
182
+ this.delivering++;
183
+ try {
184
+ subscription.callback(delivery);
185
+ }
186
+ finally {
187
+ this.delivering--;
188
+ }
189
+ }
190
+ /** A subscriber can't change or commit the tree delivering to it */
191
+ BeforeChange() {
192
+ if (this.delivering > 0)
193
+ throw new Error("ObservableDiffTree: a subscriber can't write to or commit the tree delivering to it.");
194
+ }
195
+ }
196
+ /** The records whose root is one of `keys` */
197
+ function FilterDiff(diff, keys) {
198
+ return diff.filter(record => keys.has(record.path[0]));
199
+ }
@@ -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);
@@ -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,22 +1,46 @@
1
1
  import { JsonDiffResult } from "../../Utils/json";
2
+ import type { IKeyFunc } from "../Diff/utils/diffTree.types";
3
+ /** Options shared by `StoreSync` and `StoreAsync` */
4
+ export interface IStoreOptions {
5
+ /**
6
+ * Gives a value its key, so the store resolves an object written with an id to its own root. For a
7
+ * store whose tree has none to give: a `StoreAsync` over a plain channel (pass the worker tree's
8
+ * key logic), or a `StoreSync` with a tree of its own. A lease's keyFunc is the endpoint's, and a
9
+ * tree's its own; giving one there too throws.
10
+ */
11
+ keyFunc?: IKeyFunc;
12
+ /**
13
+ * Root keys the store holds whole, as an `Opaque`: a written root or a projection's
14
+ * `$projection_<key>`. `Get` returns such a root's raw value, never proxied, the same reference
15
+ * until the root is written again, and only that write updates its readers. Treat the value as
16
+ * read-only: a change made inside it notifies no one. An opaque root can't be read through a
17
+ * key (`keyFunc`). Its tree must write each one whole instead of diffing inside it
18
+ * (`DiffTree.MarkOpaque`): a store with a tree of its own marks it; for a hub's tree, give the
19
+ * tree the same keys (`IObservableDiffTreeConfig.opaque`) from a file both import. Write the raw
20
+ * value: the store boxes it, and an `Opaque` written to a store isn't opened.
21
+ */
22
+ opaque?: readonly string[];
23
+ }
2
24
  /**
3
25
  * Base class for reading observable data: holds root objects as ObservableNode instances
4
- * and applies diff results to them. Subclasses decide where the diffs come from: `Store`
5
- * adds the write methods, and `PushStore` takes them from a `DiffTreeWriter`.
26
+ * and applies diff results to them. Subclasses decide where the diffs come from: `StoreSync`
27
+ * and `StoreAsync` apply what an `ObservableDiffTree` delivers to them.
6
28
  *
7
29
  * @see Store
8
- * @see PushStore
9
30
  */
10
31
  export declare abstract class ReadStore {
11
32
  protected keyFunc?: (value: any) => string | undefined;
12
33
  private rootMap;
13
34
  private createNode;
35
+ /** Root keys held whole, see `IStoreOptions.opaque` */
36
+ protected readonly opaque: ReadonlySet<string | number>;
14
37
  /**
15
38
  * Creates an instance of ReadStore.
16
39
  * @param keyFunc Optional function to generate a key for a given data value.
17
40
  * When provided, enables alias functionality where values can reference other root objects.
41
+ * @param opaque Root keys held whole, see `IStoreOptions.opaque`.
18
42
  */
19
- constructor(keyFunc?: (value: any) => string | undefined);
43
+ constructor(keyFunc?: (value: any) => string | undefined, opaque?: readonly string[]);
20
44
  /**
21
45
  * Retrieves a value from the store by id.
22
46
  * @template O - The type of the value to retrieve
@@ -45,6 +69,14 @@ export declare abstract class ReadStore {
45
69
  * @protected
46
70
  */
47
71
  protected UpdateRootMap(results: JsonDiffResult): void;
72
+ /**
73
+ * Applies whole-root writes by diffing each one into the root the store already holds, so
74
+ * readers of an unchanged part aren't notified and keep its objects. Anything else, an opaque
75
+ * root, or a root the store doesn't hold, is applied as it is.
76
+ * @param results - Diff results, e.g. a tree's first state after reconnecting
77
+ * @protected
78
+ */
79
+ protected UpdateRootMapInPlace(results: JsonDiffResult): void;
48
80
  /**
49
81
  * Updates a specific root object with diff results.
50
82
  * Creates the root object if it doesn't exist.