j-templates 10.0.0 → 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.
package/SYNTAX_PRIMER.md CHANGED
@@ -626,7 +626,7 @@ const store = new StoreSync(new ObservableDiffTree({ keyFunc, routes, ...layoutC
626
626
  const layout = store.Get<Layout>("$projection_layout"); // the raw value: no proxies inside it
627
627
  ```
628
628
 
629
- - The key can be a written root or a projection's `$projection_<key>`. The tree writes it whole whenever it changes (`DiffTree.MarkOpaque`): a `StoreSync` marks the empty tree it creates (`new StoreSync(undefined, { opaque })`), and a tree it's given has the keys in its config. `StoreAsync` sends the keys to its worker ahead of the first change. A store on a hub's tree needs the tree to write the key whole too: give both the same keys from a file both import (`new ObservableDiffTree({ endpoint, routes, ...searchConfig })`, `new StoreAsync(hub.Open("search"), searchConfig)`).
629
+ - The key can be a written root or a projection's `$projection_<key>`. The tree writes it whole whenever it changes (`DiffTree.MarkOpaque`): a `StoreSync` marks the empty tree it creates (`new StoreSync(undefined, { opaque })`), and a tree it's given has the keys in its config. A store never marks a tree: a `StoreAsync` needs its worker's tree to have the keys in its config too. A store on a hub's tree needs the tree to write the key whole too: give both the same keys from a file both import (`new ObservableDiffTree({ endpoint, routes, ...searchConfig })`, `new StoreAsync(hub.Open("search"), searchConfig)`).
630
630
  - `Get` returns the same reference until the root changes, and only that change updates its readers. A write that changes nothing updates no one.
631
631
  - The value is the store's own: treat it as read-only. A change made inside it notifies no one, and under `StoreSync` it is also the `DiffTree`'s committed snapshot.
632
632
  - Write the raw value. Don't pass an `Opaque` to `Write`, `Patch`, `Push` or `Splice`, at any depth: the store boxes the root itself, and a box written to a store isn't opened (under `StoreAsync` it arrives as a plain `{ value }`).
@@ -18,8 +18,6 @@ export declare class ChannelClient {
18
18
  Batch(calls: IDiffMethodBatch): Promise<void>;
19
19
  /** Rejects the batches waiting for an ack with `error`; this client stops waiting for them */
20
20
  RejectBatches(error: unknown): void;
21
- /** See `DiffTree.MarkOpaque` */
22
- MarkOpaque(keys: readonly string[]): void;
23
21
  /**
24
22
  * Subscribes to the host's tree. Deliveries arrive as the host posts them.
25
23
  * @param onSubscribed Called once the host has posted the first state, with its error when
@@ -1,3 +1,4 @@
1
+ import { IsPattern } from "../../Utils/pattern";
1
2
  /** Ids are unique across clients, so several can share one channel */
2
3
  let nextId = 1;
3
4
  /**
@@ -32,10 +33,6 @@ export class ChannelClient {
32
33
  this.batches.clear();
33
34
  batches.forEach(batch => batch.reject(error));
34
35
  }
35
- /** See `DiffTree.MarkOpaque` */
36
- MarkOpaque(keys) {
37
- this.channel.postMessage({ type: "jt-markopaque", keys });
38
- }
39
36
  /**
40
37
  * Subscribes to the host's tree. Deliveries arrive as the host posts them.
41
38
  * @param onSubscribed Called once the host has posted the first state, with its error when
@@ -194,7 +191,7 @@ export function Subscribe(channel, options, callback) {
194
191
  export function Once(channel, keys, options = {}) {
195
192
  const { signal } = options;
196
193
  return new Promise((resolve, reject) => {
197
- if (keys.some(key => key.endsWith("*")))
194
+ if (keys.some(IsPattern))
198
195
  return reject(new Error(`Once takes exact keys, not patterns: ${keys.join(", ")}`));
199
196
  if (signal?.aborted)
200
197
  return reject(signal.reason);
@@ -103,11 +103,6 @@ export type IHostRequest =
103
103
  | {
104
104
  type: "jt-disconnect";
105
105
  }
106
- /** See `DiffTree.MarkOpaque` */
107
- | {
108
- type: "jt-markopaque";
109
- keys: readonly string[];
110
- }
111
106
  /** From a `DiffTreeHub`, on the worker's own channel: serve `port`, a lease's channel, as `id` */
112
107
  | {
113
108
  type: "jt-serve";
@@ -22,7 +22,6 @@ export interface IHost {
22
22
  * - `jt-subscribe` / `jt-unsubscribe`: a subscription for the channel. Its first state and every
23
23
  * commit are posted as `jt-post`, then `jt-subscribed` once the first state is out.
24
24
  * - `jt-disconnect`: ends every subscription on the channel.
25
- * - `jt-markopaque`: see `DiffTree.MarkOpaque`.
26
25
  * - `jt-serve` / `jt-unserve`: from a `DiffTreeHub`, a lease's port to serve, or to stop serving.
27
26
  *
28
27
  * The worker's own work commits on the same publisher, and its commits reach every subscriber
@@ -12,7 +12,6 @@ import { ListenForConnections } from "./onConnect";
12
12
  * - `jt-subscribe` / `jt-unsubscribe`: a subscription for the channel. Its first state and every
13
13
  * commit are posted as `jt-post`, then `jt-subscribed` once the first state is out.
14
14
  * - `jt-disconnect`: ends every subscription on the channel.
15
- * - `jt-markopaque`: see `DiffTree.MarkOpaque`.
16
15
  * - `jt-serve` / `jt-unserve`: from a `DiffTreeHub`, a lease's port to serve, or to stop serving.
17
16
  *
18
17
  * The worker's own work commits on the same publisher, and its commits reach every subscriber
@@ -65,9 +64,6 @@ export function HostPublisher(publisher, scope) {
65
64
  publisher.Disconnect(channel);
66
65
  subscriptions.clear();
67
66
  break;
68
- case "jt-markopaque":
69
- publisher.MarkOpaque(message.keys);
70
- break;
71
67
  case "jt-serve":
72
68
  leases.set(message.id, Serve(message.port));
73
69
  break;
@@ -1,3 +1,4 @@
1
+ import { IsPattern } from "../../Utils/pattern";
1
2
  import { SubscriptionManager } from "../../Utils/subscriptionManager";
2
3
  import { DiffTree } from "./diffTree";
3
4
  import { FollowRoutes } from "./utils/routes";
@@ -125,7 +126,7 @@ export class ObservableDiffTree extends DiffTree {
125
126
  Once(keys, options = {}) {
126
127
  const { signal } = options;
127
128
  return new Promise((resolve, reject) => {
128
- if (keys.some(key => key.endsWith("*")))
129
+ if (keys.some(IsPattern))
129
130
  return reject(new Error(`Once takes exact keys, not patterns: ${keys.join(", ")}`));
130
131
  if (signal?.aborted)
131
132
  return reject(signal.reason);
@@ -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
  }
@@ -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 = {};
@@ -1,5 +1,6 @@
1
1
  import { JsonDiffResult } from "../../Utils/json";
2
2
  import type { IKeyFunc } from "../Diff/utils/diffTree.types";
3
+ import { EmitterEvent } from "../../Utils/emitter";
3
4
  /** Options shared by `StoreSync` and `StoreAsync` */
4
5
  export interface IStoreOptions {
5
6
  /**
@@ -15,8 +16,9 @@ export interface IStoreOptions {
15
16
  * until the root is written again, and only that write updates its readers. Treat the value as
16
17
  * read-only: a change made inside it notifies no one. An opaque root can't be read through a
17
18
  * 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
19
+ * (`DiffTree.MarkOpaque`): a store with a tree of its own marks it; any other tree, a hub's or a
20
+ * worker's, takes the same keys in its config (`IObservableDiffTreeConfig.opaque`), from a file
21
+ * both import. A store never marks a tree: opaque is local. Write the raw
20
22
  * value: the store boxes it, and an `Opaque` written to a store isn't opened.
21
23
  */
22
24
  opaque?: readonly string[];
@@ -34,13 +36,15 @@ export declare abstract class ReadStore {
34
36
  private createNode;
35
37
  /** Root keys held whole, see `IStoreOptions.opaque` */
36
38
  protected readonly opaque: ReadonlySet<string | number>;
39
+ protected readonly watch: readonly string[];
40
+ protected readonly watchCallback?: (rootProperty: string, event: EmitterEvent) => void;
37
41
  /**
38
42
  * Creates an instance of ReadStore.
39
43
  * @param keyFunc Optional function to generate a key for a given data value.
40
44
  * When provided, enables alias functionality where values can reference other root objects.
41
45
  * @param opaque Root keys held whole, see `IStoreOptions.opaque`.
42
46
  */
43
- constructor(keyFunc?: (value: any) => string | undefined, opaque?: readonly string[]);
47
+ constructor(keyFunc?: (value: any) => string | undefined, opaque?: readonly string[], watch?: readonly string[], watchCallback?: (rootKey: string, event: EmitterEvent) => void);
44
48
  /**
45
49
  * Retrieves a value from the store by id.
46
50
  * @template O - The type of the value to retrieve
@@ -86,6 +90,7 @@ export declare abstract class ReadStore {
86
90
  * @private
87
91
  */
88
92
  private UpdateRootObject;
93
+ private CreateRootNode;
89
94
  }
90
95
  /**
91
96
  * Base class for observable data store management.
@@ -1,6 +1,7 @@
1
1
  import { JsonDeepClone, JsonDiff, JsonType } from "../../Utils/json";
2
2
  import { ObservableNode } from "../Tree/observableNode";
3
3
  import { Opaque } from "../Tree/opaque";
4
+ import { PatternMatches } from "../../Utils/pattern";
4
5
  /**
5
6
  * Base class for reading observable data: holds root objects as ObservableNode instances
6
7
  * and applies diff results to them. Subclasses decide where the diffs come from: `StoreSync`
@@ -15,10 +16,14 @@ export class ReadStore {
15
16
  * When provided, enables alias functionality where values can reference other root objects.
16
17
  * @param opaque Root keys held whole, see `IStoreOptions.opaque`.
17
18
  */
18
- constructor(keyFunc, opaque = []) {
19
+ constructor(keyFunc, opaque = [], watch = [], watchCallback) {
19
20
  this.keyFunc = keyFunc;
20
21
  this.rootMap = new Map();
21
22
  this.opaque = new Set(opaque);
23
+ this.watch = watch;
24
+ if (this.watch.length > 0 && !watchCallback)
25
+ throw new Error("watchCallback must be provided when passing watch properties");
26
+ this.watchCallback = watchCallback;
22
27
  const aliasFunc = keyFunc &&
23
28
  ((value, reactive = true) => {
24
29
  const key = keyFunc(value);
@@ -43,7 +48,7 @@ export class ReadStore {
43
48
  Get(id, defaultValue) {
44
49
  let result = this.rootMap.get(id);
45
50
  if (result === undefined) {
46
- result = this.createNode({ [id]: this.opaque.has(id) ? Box(defaultValue) : defaultValue });
51
+ result = this.CreateRootNode(id, defaultValue);
47
52
  this.rootMap.set(id, result);
48
53
  }
49
54
  return result[id];
@@ -109,7 +114,7 @@ export class ReadStore {
109
114
  if (results[0].type !== "write" || results[0].path.length > 1)
110
115
  throw `Unable to initialize root path ${rootPath} with initial path ${results[0].path}`;
111
116
  const rootData = JsonDeepClone(results[0].value);
112
- const newRootObject = this.createNode({ [rootPath]: rootData }, false);
117
+ const newRootObject = this.CreateRootNode(rootPath, rootData, false);
113
118
  this.rootMap.set(rootPath, newRootObject);
114
119
  // Later entries for the same root (e.g. route summaries written one instance at a time)
115
120
  // apply to the root just created
@@ -119,6 +124,15 @@ export class ReadStore {
119
124
  }
120
125
  ObservableNode.ApplyDiff(rootObject, results);
121
126
  }
127
+ CreateRootNode(rootPath, data, unwrap) {
128
+ const result = this.createNode({ [rootPath]: this.opaque.has(rootPath) ? Box(data) : data }, unwrap);
129
+ const rootPathStr = rootPath.toString();
130
+ if (this.watch.some(pattern => PatternMatches(pattern, rootPathStr)))
131
+ ObservableNode.WatchProperty(result, rootPath, (event) => {
132
+ this.watchCallback(rootPathStr, event);
133
+ });
134
+ return result;
135
+ }
122
136
  }
123
137
  /**
124
138
  * Base class for observable data store management.
@@ -8,6 +8,16 @@ export interface IStoreAsyncOptions extends IStoreOptions {
8
8
  * `keyFunc`, include the entity roots the received roots refer to.
9
9
  */
10
10
  roots?: readonly string[];
11
+ /**
12
+ * Root keys the store receives only while something reads them: exact keys and patterns ending
13
+ * in `*`. A demand root is subscribed on its own once a reactive scope reads it with `Get` (or
14
+ * `Hold` holds it), and released `demandDebounce` ms after its last reader goes: unsubscribed and
15
+ * deleted from the store. Until its first post arrives it reads as its default. Needs `roots`:
16
+ * a store that receives every root has none left to demand. A key `roots` names is a `roots` root.
17
+ */
18
+ demand?: readonly string[];
19
+ /** How long a demand root no one reads stays subscribed, in ms; default 1000 */
20
+ demandDebounce?: number;
11
21
  }
12
22
  /**
13
23
  * StoreAsync class extends the base Store class to provide asynchronous data management operations.
@@ -23,6 +33,11 @@ export interface IStoreAsyncOptions extends IStoreOptions {
23
33
  * posts, so the change has been applied when it resolves.
24
34
  * - A failed batch rejects only its own changes; later batches carry on.
25
35
  *
36
+ * **Demand roots** (`IStoreAsyncOptions.demand`) are received while they're read: the first reactive
37
+ * read of one subscribes to it alone, and its first post brings the tree's state of it; a grace
38
+ * period after the last reader goes, it's unsubscribed and deleted from the store. A read outside
39
+ * a scope neither subscribes nor keeps one; `Hold` does, for code that reads it that way.
40
+ *
26
41
  * **Over a lease** (`DiffTreeHub.Open`), the store takes its keyFunc from the lease's endpoint, owns
27
42
  * the lease, and releases it when it's destroyed. When the hub restarts the worker,
28
43
  * the store starts over with the new tree, as when it first subscribed: the tree is the source of
@@ -76,6 +91,18 @@ export declare class StoreAsync extends Store {
76
91
  private currentBatchPromise;
77
92
  /** Batches not yet acknowledged, for `Flush` */
78
93
  private inFlight;
94
+ /** Demand roots a reactive scope reads now, from their watchers */
95
+ private readonly demandRead;
96
+ /** `Hold`s, by demand root */
97
+ private readonly demandHolds;
98
+ /** A subscription for each demand root the store holds */
99
+ private readonly demandSubscriptions;
100
+ /** The release timers of demand roots no one reads, by key */
101
+ private readonly demandReleasing;
102
+ /** Demand roots whose readers or holds changed since the last flush */
103
+ private readonly demandChanged;
104
+ private demandFlushQueued;
105
+ private destroyed;
79
106
  /**
80
107
  * Creates an instance of StoreAsync and subscribes to the tree.
81
108
  * @param channel A channel to an `ObservableDiffTree` served by `HostPublisher`: a `Worker` (e.g. a
@@ -85,8 +112,8 @@ export declare class StoreAsync extends Store {
85
112
  * (`DiffTreeHub.OpenLocal`) is for a `StoreSync`, and doesn't compile here.
86
113
  * @param options `keyFunc`, `roots` and `opaque` (`IStoreAsyncOptions`). `keyFunc` is for a plain
87
114
  * channel: pass the key logic the worker's tree was built with. A lease's is the endpoint's.
88
- * `opaque` is sent to the tree before the store subscribes; over a lease, share it with the tree
89
- * (`IObservableDiffTreeConfig.opaque`) from a file both import.
115
+ * `opaque` is the store's own: the tree must write those keys whole too, from its config
116
+ * (`IObservableDiffTreeConfig.opaque`), so share them from a file both import.
90
117
  * @throws When `keyFunc` is given with a lease, whose endpoint has its own.
91
118
  */
92
119
  constructor(channel: IChannel | IWorkerLease, options?: IStoreAsyncOptions);
@@ -99,6 +126,33 @@ export declare class StoreAsync extends Store {
99
126
  */
100
127
  private Restarted;
101
128
  private Connect;
129
+ /**
130
+ * Holds a demand root without reading it reactively, for code that reads it outside a scope: the
131
+ * store subscribes to it as for a reader, and keeps it until every hold is released. Its first
132
+ * post arrives later; until then it reads as its default.
133
+ * @returns What releases this hold; calling it again does nothing.
134
+ * @throws When the key is neither a demand root nor one of the store's `roots`. A `roots` root is
135
+ * always received: its hold does nothing.
136
+ */
137
+ Hold(key: string): () => void;
138
+ private IsRootsKey;
139
+ private IsDemandKey;
140
+ /**
141
+ * A demand root's watcher: it gained its first reactive reader or lost its last. This runs inside
142
+ * a scope's update, so it only records the change for the next flush.
143
+ */
144
+ private DemandWatched;
145
+ private DemandChanged;
146
+ /**
147
+ * Subscribes the demand roots read or held now and not yet subscribed, and starts the release of
148
+ * the ones no one reads or holds. A root read and dropped since the last flush is never subscribed.
149
+ */
150
+ private FlushDemand;
151
+ private IsDemanded;
152
+ private SubscribeDemand;
153
+ private ReleaseLater;
154
+ /** Unsubscribes a demand root and deletes it from the store, so no later reader sees it stale */
155
+ private ReleaseDemand;
102
156
  /** Applies a post. A tree's first state is diffed into the roots in place, its deletes with it. */
103
157
  private Apply;
104
158
  private Schedule;
@@ -1,6 +1,11 @@
1
1
  import { AsyncQueue } from "../../Utils/asyncQueue";
2
+ import { EmitterEvent } from "../../Utils/emitter";
3
+ import { PatternMatches } from "../../Utils/pattern";
4
+ import { _queueMicrotask, _setTimeout } from "../../Utils/scheduling";
2
5
  import { ChannelClient, ChannelSubscription } from "../Diff/channelClient";
3
6
  import { RootOf, Store } from "./store";
7
+ /** How long a demand root no one reads stays subscribed, by default */
8
+ const DEMAND_GRACE = 1000;
4
9
  /**
5
10
  * StoreAsync class extends the base Store class to provide asynchronous data management operations.
6
11
  * This class handles writing, patching, pushing, and splicing data in an asynchronous manner.
@@ -15,6 +20,11 @@ import { RootOf, Store } from "./store";
15
20
  * posts, so the change has been applied when it resolves.
16
21
  * - A failed batch rejects only its own changes; later batches carry on.
17
22
  *
23
+ * **Demand roots** (`IStoreAsyncOptions.demand`) are received while they're read: the first reactive
24
+ * read of one subscribes to it alone, and its first post brings the tree's state of it; a grace
25
+ * period after the last reader goes, it's unsubscribed and deleted from the store. A read outside
26
+ * a scope neither subscribes nor keeps one; `Hold` does, for code that reads it that way.
27
+ *
18
28
  * **Over a lease** (`DiffTreeHub.Open`), the store takes its keyFunc from the lease's endpoint, owns
19
29
  * the lease, and releases it when it's destroyed. When the hub restarts the worker,
20
30
  * the store starts over with the new tree, as when it first subscribed: the tree is the source of
@@ -61,22 +71,36 @@ export class StoreAsync extends Store {
61
71
  * (`DiffTreeHub.OpenLocal`) is for a `StoreSync`, and doesn't compile here.
62
72
  * @param options `keyFunc`, `roots` and `opaque` (`IStoreAsyncOptions`). `keyFunc` is for a plain
63
73
  * channel: pass the key logic the worker's tree was built with. A lease's is the endpoint's.
64
- * `opaque` is sent to the tree before the store subscribes; over a lease, share it with the tree
65
- * (`IObservableDiffTreeConfig.opaque`) from a file both import.
74
+ * `opaque` is the store's own: the tree must write those keys whole too, from its config
75
+ * (`IObservableDiffTreeConfig.opaque`), so share them from a file both import.
66
76
  * @throws When `keyFunc` is given with a lease, whose endpoint has its own.
67
77
  */
68
78
  constructor(channel, options = {}) {
69
79
  const lease = IsLease(channel) ? channel : undefined;
70
80
  if (lease && options.keyFunc)
71
81
  throw new Error(`StoreAsync: the endpoint ${lease.name} gives the store its keyFunc; leave it out of the options.`);
82
+ if (options.demand && options.demand.length > 0 && !options.roots)
83
+ throw new Error("StoreAsync: demand roots need roots: a store that receives every root has none left to demand.");
72
84
  const keyFunc = lease ? lease.endpoint.keyFunc : options.keyFunc;
73
- super(keyFunc, options.opaque);
85
+ super(keyFunc, options.opaque, options.demand ?? [], (key, event) => this.DemandWatched(key, event));
74
86
  this.writeKeySet = new Set();
75
87
  this.asyncQueue = new AsyncQueue();
76
88
  this.currentBatch = [];
77
89
  this.currentBatchPromise = Promise.resolve();
78
90
  /** Batches not yet acknowledged, for `Flush` */
79
91
  this.inFlight = new Set();
92
+ /** Demand roots a reactive scope reads now, from their watchers */
93
+ this.demandRead = new Set();
94
+ /** `Hold`s, by demand root */
95
+ this.demandHolds = new Map();
96
+ /** A subscription for each demand root the store holds */
97
+ this.demandSubscriptions = new Map();
98
+ /** The release timers of demand roots no one reads, by key */
99
+ this.demandReleasing = new Map();
100
+ /** Demand roots whose readers or holds changed since the last flush */
101
+ this.demandChanged = new Set();
102
+ this.demandFlushQueued = false;
103
+ this.destroyed = false;
80
104
  this.options = options;
81
105
  this.lease = lease;
82
106
  if (lease)
@@ -97,11 +121,113 @@ export class StoreAsync extends Store {
97
121
  this.client.Destroy();
98
122
  this.client = this.Connect(port);
99
123
  this.subscription.Connect(this.client);
124
+ for (const subscription of this.demandSubscriptions.values())
125
+ subscription.Connect(this.client);
100
126
  }
101
127
  Connect(channel) {
102
- const client = new ChannelClient(channel);
103
- this.options.opaque && this.options.opaque.length > 0 && client.MarkOpaque(this.options.opaque);
104
- return client;
128
+ return new ChannelClient(channel);
129
+ }
130
+ /**
131
+ * Holds a demand root without reading it reactively, for code that reads it outside a scope: the
132
+ * store subscribes to it as for a reader, and keeps it until every hold is released. Its first
133
+ * post arrives later; until then it reads as its default.
134
+ * @returns What releases this hold; calling it again does nothing.
135
+ * @throws When the key is neither a demand root nor one of the store's `roots`. A `roots` root is
136
+ * always received: its hold does nothing.
137
+ */
138
+ Hold(key) {
139
+ if (this.IsRootsKey(key))
140
+ return () => { };
141
+ if (!this.IsDemandKey(key))
142
+ throw new Error(`StoreAsync: ${key} isn't a demand root or one of the store's roots.`);
143
+ this.demandHolds.set(key, (this.demandHolds.get(key) ?? 0) + 1);
144
+ this.DemandChanged(key);
145
+ let held = true;
146
+ return () => {
147
+ if (!held)
148
+ return;
149
+ held = false;
150
+ const holds = (this.demandHolds.get(key) ?? 1) - 1;
151
+ holds > 0 ? this.demandHolds.set(key, holds) : this.demandHolds.delete(key);
152
+ this.DemandChanged(key);
153
+ };
154
+ }
155
+ IsRootsKey(key) {
156
+ return !this.options.roots || this.options.roots.some(pattern => PatternMatches(pattern, key));
157
+ }
158
+ IsDemandKey(key) {
159
+ return !this.IsRootsKey(key) && (this.options.demand ?? []).some(pattern => PatternMatches(pattern, key));
160
+ }
161
+ /**
162
+ * A demand root's watcher: it gained its first reactive reader or lost its last. This runs inside
163
+ * a scope's update, so it only records the change for the next flush.
164
+ */
165
+ DemandWatched(key, event) {
166
+ if (this.IsRootsKey(key))
167
+ return;
168
+ event === EmitterEvent.SUBSCRIBED ? this.demandRead.add(key) : this.demandRead.delete(key);
169
+ this.DemandChanged(key);
170
+ }
171
+ DemandChanged(key) {
172
+ this.demandChanged.add(key);
173
+ if (this.demandFlushQueued)
174
+ return;
175
+ this.demandFlushQueued = true;
176
+ _queueMicrotask(() => this.FlushDemand());
177
+ }
178
+ /**
179
+ * Subscribes the demand roots read or held now and not yet subscribed, and starts the release of
180
+ * the ones no one reads or holds. A root read and dropped since the last flush is never subscribed.
181
+ */
182
+ FlushDemand() {
183
+ this.demandFlushQueued = false;
184
+ if (this.destroyed)
185
+ return;
186
+ const keys = Array.from(this.demandChanged);
187
+ this.demandChanged.clear();
188
+ for (const key of keys) {
189
+ if (this.IsDemanded(key)) {
190
+ const timer = this.demandReleasing.get(key);
191
+ if (timer !== undefined) {
192
+ clearTimeout(timer);
193
+ this.demandReleasing.delete(key);
194
+ }
195
+ !this.demandSubscriptions.has(key) && this.SubscribeDemand(key);
196
+ }
197
+ else if (this.demandSubscriptions.has(key) && !this.demandReleasing.has(key))
198
+ this.ReleaseLater(key);
199
+ }
200
+ }
201
+ IsDemanded(key) {
202
+ return this.demandRead.has(key) || this.demandHolds.has(key);
203
+ }
204
+ SubscribeDemand(key) {
205
+ // A post for a key released since is dropped: an unsubscribe and a commit can cross on the channel
206
+ const subscription = new ChannelSubscription({ roots: [key] }, (post, first) => this.demandSubscriptions.get(key) === subscription && this.Apply(post, first));
207
+ this.demandSubscriptions.set(key, subscription);
208
+ subscription.Connect(this.client);
209
+ }
210
+ ReleaseLater(key) {
211
+ const grace = this.options.demandDebounce ?? DEMAND_GRACE;
212
+ if (grace <= 0)
213
+ return this.ReleaseDemand(key);
214
+ let fired = false;
215
+ const timer = _setTimeout(() => {
216
+ fired = true;
217
+ this.demandReleasing.delete(key);
218
+ !this.destroyed && !this.IsDemanded(key) && this.ReleaseDemand(key);
219
+ }, grace);
220
+ // A timer that ran at once (synchronous scheduling) has nothing left to cancel
221
+ !fired && this.demandReleasing.set(key, timer);
222
+ }
223
+ /** Unsubscribes a demand root and deletes it from the store, so no later reader sees it stale */
224
+ ReleaseDemand(key) {
225
+ const subscription = this.demandSubscriptions.get(key);
226
+ if (!subscription)
227
+ return;
228
+ this.demandSubscriptions.delete(key);
229
+ subscription.Stop();
230
+ this.Has(key) && this.UpdateRootMap([{ type: "delete", path: [key] }]);
105
231
  }
106
232
  /** Applies a post. A tree's first state is diffed into the roots in place, its deletes with it. */
107
233
  Apply(post, firstState) {
@@ -221,9 +347,16 @@ export class StoreAsync extends Store {
221
347
  * channel stays open: whoever created the worker terminates it.
222
348
  */
223
349
  Destroy() {
350
+ this.destroyed = true;
224
351
  this.stopLease?.();
225
352
  this.asyncQueue.Stop();
226
353
  this.subscription.Stop();
354
+ for (const subscription of this.demandSubscriptions.values())
355
+ subscription.Stop();
356
+ this.demandSubscriptions.clear();
357
+ for (const timer of this.demandReleasing.values())
358
+ clearTimeout(timer);
359
+ this.demandReleasing.clear();
227
360
  this.client.Destroy();
228
361
  this.lease?.Release();
229
362
  }
@@ -1,3 +1,4 @@
1
+ import type { EmitterEvent } from "../../Utils/emitter";
1
2
  import type { ILocalLease } from "../Diff/diffTree.types";
2
3
  import { ObservableDiffTree } from "../Diff/observableDiffTree";
3
4
  import { IStoreOptions, Store } from "./store";
@@ -8,6 +9,18 @@ export interface IStoreSyncOptions extends IStoreOptions {
8
9
  * root. With a `keyFunc`, include the entity roots the held roots refer to.
9
10
  */
10
11
  roots?: readonly string[];
12
+ /**
13
+ * Root keys whose readers the store reports to `watchCallback`: exact keys and patterns ending in
14
+ * `*`. A watched root reports when a reactive scope first reads it with `Get`, and when its last
15
+ * reader stops; a read outside a scope reports nothing.
16
+ */
17
+ watch?: readonly string[];
18
+ /**
19
+ * Told when a watched root gains its first reader or loses its last, with the root's key. It runs
20
+ * inside the scope update that caused it: record the change and act on it later, without reading
21
+ * or writing the store. Needed with `watch`.
22
+ */
23
+ watchCallback?: (rootKey: string, event: EmitterEvent) => void;
11
24
  }
12
25
  /**
13
26
  * StoreSync class extends the base Store class to provide synchronous data management operations.
@@ -81,11 +94,13 @@ export declare class StoreSync extends Store {
81
94
  * A projection in the root route (`"/"`) is readable through `Get` at `$projection_<key>`. The
82
95
  * store doesn't own it: `Destroy` stops listening.
83
96
  * - Left out, the store creates an empty tree of its own, with the store's `keyFunc` and `opaque`.
84
- * @param options `keyFunc`, `roots` and `opaque` (`IStoreSyncOptions`). `keyFunc` is for the tree the
97
+ * @param options `keyFunc`, `roots`, `opaque`, `watch` and `watchCallback` (`IStoreSyncOptions`).
98
+ * `keyFunc` is for the tree the
85
99
  * store creates: a leased tree's is the endpoint's, and a given tree's its own. A tree given or
86
100
  * leased must write the `opaque` keys whole already (`IObservableDiffTreeConfig.opaque`): share
87
101
  * them from one file.
88
- * @throws When `keyFunc` is given with a tree or a lease, which has its own.
102
+ * @throws When `keyFunc` is given with a tree or a lease, which has its own; or `watch` without
103
+ * `watchCallback`.
89
104
  */
90
105
  constructor(tree?: ObservableDiffTree | ILocalLease, options?: IStoreSyncOptions);
91
106
  /**
@@ -60,11 +60,13 @@ export class StoreSync extends Store {
60
60
  * A projection in the root route (`"/"`) is readable through `Get` at `$projection_<key>`. The
61
61
  * store doesn't own it: `Destroy` stops listening.
62
62
  * - Left out, the store creates an empty tree of its own, with the store's `keyFunc` and `opaque`.
63
- * @param options `keyFunc`, `roots` and `opaque` (`IStoreSyncOptions`). `keyFunc` is for the tree the
63
+ * @param options `keyFunc`, `roots`, `opaque`, `watch` and `watchCallback` (`IStoreSyncOptions`).
64
+ * `keyFunc` is for the tree the
64
65
  * store creates: a leased tree's is the endpoint's, and a given tree's its own. A tree given or
65
66
  * leased must write the `opaque` keys whole already (`IObservableDiffTreeConfig.opaque`): share
66
67
  * them from one file.
67
- * @throws When `keyFunc` is given with a tree or a lease, which has its own.
68
+ * @throws When `keyFunc` is given with a tree or a lease, which has its own; or `watch` without
69
+ * `watchCallback`.
68
70
  */
69
71
  constructor(tree, options = {}) {
70
72
  const lease = tree && !(tree instanceof ObservableDiffTree) ? tree : undefined;
@@ -72,7 +74,7 @@ export class StoreSync extends Store {
72
74
  throw new Error(lease
73
75
  ? `StoreSync: the endpoint ${lease.name} gives the store its keyFunc; leave it out of the options.`
74
76
  : "StoreSync: the tree has its keyFunc, which the store takes; leave it out of the options.");
75
- super(lease ? lease.endpoint.keyFunc : tree ? tree.KeyFunc : options.keyFunc, options.opaque);
77
+ super(lease ? lease.endpoint.keyFunc : tree ? tree.KeyFunc : options.keyFunc, options.opaque, options.watch, options.watchCallback);
76
78
  /** Over a lease: the roots delivered and not deleted, to delete the ones a restarted tree doesn't hold */
77
79
  this.delivered = new Set();
78
80
  this.roots = options.roots;
@@ -1,3 +1,4 @@
1
+ import { EmitterEventCallback } from "../../Utils/emitter";
1
2
  import { JsonDiffResult } from "../../Utils/json";
2
3
  export declare namespace ObservableNode {
3
4
  /**
@@ -39,6 +40,8 @@ export declare namespace ObservableNode {
39
40
  * @returns A proxied version of the value that emits change events.
40
41
  */
41
42
  function Create<T>(value: T, unwrapValue?: boolean): T;
43
+ function WatchProperty<T>(proxy: T, prop: keyof T, callback: EmitterEventCallback): void;
44
+ function UnwatchProperty<T>(proxy: T, prop: keyof T, callback: EmitterEventCallback): void;
42
45
  /**
43
46
  * Returns an immutable, cached snapshot of an observable node's current value (plain
44
47
  * objects/arrays, with keyed children read through their root). The same snapshot is
@@ -416,6 +416,25 @@ export var ObservableNode;
416
416
  return DefaultCreateProxy(value, unwrapValue);
417
417
  }
418
418
  ObservableNode.Create = Create;
419
+ function WatchProperty(proxy, prop, callback) {
420
+ if (!proxy[IS_NODE])
421
+ throw new Error("Parameter proxy is not an ObservableNode");
422
+ const wrapper = proxy[NODE_WRAPPER];
423
+ if (Array.isArray(wrapper))
424
+ throw new Error("Unable to watch individual property on array proxy");
425
+ const scope = GetPropertyScope(wrapper, prop);
426
+ ObservableScope.WatchSubscribers(scope, callback);
427
+ }
428
+ ObservableNode.WatchProperty = WatchProperty;
429
+ function UnwatchProperty(proxy, prop, callback) {
430
+ if (!proxy[IS_NODE])
431
+ throw new Error("Parameter proxy is not an ObservableNode");
432
+ const wrapper = proxy[NODE_WRAPPER];
433
+ if (Array.isArray(wrapper))
434
+ return;
435
+ Object.hasOwn(wrapper, prop) && ObservableScope.UnwatchSubscribers(wrapper[prop], callback);
436
+ }
437
+ ObservableNode.UnwatchProperty = UnwatchProperty;
419
438
  /**
420
439
  * Returns an immutable, cached snapshot of an observable node's current value (plain
421
440
  * objects/arrays, with keyed children read through their root). The same snapshot is
@@ -1,4 +1,4 @@
1
- import { Emitter, EmitterCallback } from "../../Utils/emitter";
1
+ import { Emitter, EmitterCallback, EmitterEventCallback } from "../../Utils/emitter";
2
2
  /**
3
3
  * Represents a static (non-reactive) observable scope.
4
4
  * Static scopes are immutable and do not track dependencies or emit updates.
@@ -189,6 +189,8 @@ export declare namespace ObservableScope {
189
189
  * @param callback The callback function to remove.
190
190
  */
191
191
  function Unwatch<T>(scope: IObservableScope<T> | IBasicObservableScope<T>, callback: (scope: IObservableScope<T>) => void): void;
192
+ function WatchSubscribers<T = any>(scope: IObservableScope<T> | IBasicObservableScope<T>, callback: EmitterEventCallback): void;
193
+ function UnwatchSubscribers<T = any>(scope: IObservableScope<T> | IBasicObservableScope<T>, callback: EmitterEventCallback): void;
192
194
  /**
193
195
  * Registers a callback to be invoked when the scope's value is updated.
194
196
  * Only applies to dynamic scopes; no-op for static scopes.
@@ -639,6 +639,18 @@ export var ObservableScope;
639
639
  Emitter.Remove(scope.emitter, callback);
640
640
  }
641
641
  ObservableScope.Unwatch = Unwatch;
642
+ function WatchSubscribers(scope, callback) {
643
+ if (!scope || scope.destroyed || scope.type === "static")
644
+ return;
645
+ Emitter.Watch(scope.emitter, callback);
646
+ }
647
+ ObservableScope.WatchSubscribers = WatchSubscribers;
648
+ function UnwatchSubscribers(scope, callback) {
649
+ if (!scope || scope.destroyed || scope.type === "static")
650
+ return;
651
+ Emitter.Unwatch(scope.emitter, callback);
652
+ }
653
+ ObservableScope.UnwatchSubscribers = UnwatchSubscribers;
642
654
  /**
643
655
  * Registers a callback to be invoked when the scope's value is updated.
644
656
  * Only applies to dynamic scopes; no-op for static scopes.
@@ -1,9 +1,16 @@
1
+ export declare enum EmitterEvent {
2
+ SUBSCRIBED = 0,
3
+ UNSUBSCRIBED = 1
4
+ }
5
+ export type EmitterEventCallback = (event: EmitterEvent, emitter: Emitter) => void;
1
6
  export type EmitterCallback<T extends readonly any[] = any[]> = (...args: T) => void;
2
- export type Emitter = [[number, number], ...EmitterCallback[]];
7
+ export type Emitter<T extends readonly any[] = any[]> = [[number, number, Emitter<[EmitterEvent, Emitter]> | undefined], ...EmitterCallback<T>[]];
3
8
  export declare namespace Emitter {
4
- function Create(): Emitter;
5
- function On(emitter: Emitter, callback: EmitterCallback): void;
6
- function Emit(emitter: Emitter, ...args: any[]): void;
9
+ function Create<T extends any[] = any[]>(): Emitter<T>;
10
+ function On<T extends any[]>(emitter: Emitter<T>, callback: EmitterCallback<T>): void;
11
+ function Watch(emitter: Emitter, callback: EmitterCallback<[EmitterEvent, Emitter]>): void;
12
+ function Unwatch(emitter: Emitter, callback: EmitterCallback<[EmitterEvent, Emitter]>): void;
13
+ function Emit<T extends any[]>(emitter: Emitter<T>, ...args: T): void;
7
14
  function Remove(emitter: Emitter, callback: EmitterCallback): void;
8
15
  function Clear(emitter: Emitter): void;
9
16
  }
package/Utils/emitter.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { _requestIdleCallback } from "./scheduling";
2
+ export var EmitterEvent;
3
+ (function (EmitterEvent) {
4
+ EmitterEvent[EmitterEvent["SUBSCRIBED"] = 0] = "SUBSCRIBED";
5
+ EmitterEvent[EmitterEvent["UNSUBSCRIBED"] = 1] = "UNSUBSCRIBED";
6
+ })(EmitterEvent || (EmitterEvent = {}));
2
7
  const pendingCompactEmitters = new Set();
3
8
  function Compact(emitter) {
4
9
  if (emitter.length === 1 || emitter[0][0] === 0)
@@ -29,13 +34,26 @@ function CompactEmitter(emitter) {
29
34
  export var Emitter;
30
35
  (function (Emitter) {
31
36
  function Create() {
32
- return [[0, -1]];
37
+ return [[0, -1, undefined]];
33
38
  }
34
39
  Emitter.Create = Create;
35
40
  function On(emitter, callback) {
36
41
  emitter.push(callback);
42
+ if (emitter.length - emitter[0][0] === 2)
43
+ emitter[0][2] && Emitter.Emit(emitter[0][2], EmitterEvent.SUBSCRIBED, emitter);
37
44
  }
38
45
  Emitter.On = On;
46
+ function Watch(emitter, callback) {
47
+ emitter[0][2] ??= Emitter.Create();
48
+ On(emitter[0][2], callback);
49
+ }
50
+ Emitter.Watch = Watch;
51
+ function Unwatch(emitter, callback) {
52
+ if (!emitter[0][2])
53
+ return;
54
+ Remove(emitter[0][2], callback);
55
+ }
56
+ Emitter.Unwatch = Unwatch;
39
57
  // Re-entry during emit is not supported and considered a bug in client code
40
58
  function Emit(emitter, ...args) {
41
59
  if (emitter[0][1] !== -1)
@@ -73,11 +91,16 @@ export var Emitter;
73
91
  RemoveFromBeginning(emitter, index);
74
92
  else
75
93
  RemoveFromEnd(emitter, index);
94
+ if (emitter.length - emitter[0][0] === 1)
95
+ emitter[0][2] && Emitter.Emit(emitter[0][2], EmitterEvent.UNSUBSCRIBED, emitter);
76
96
  }
77
97
  Emitter.Remove = Remove;
78
98
  function Clear(emitter) {
79
- emitter.length > 1 && emitter.splice(1);
99
+ if (emitter.length - emitter[0][0] === 1)
100
+ return;
101
+ emitter.splice(1);
80
102
  emitter[0][0] = 0;
103
+ emitter[0][2] && Emitter.Emit(emitter[0][2], EmitterEvent.UNSUBSCRIBED, emitter);
81
104
  }
82
105
  Emitter.Clear = Clear;
83
106
  })(Emitter || (Emitter = {}));
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Key patterns: an exact key, or a pattern ending in `*` that matches every key starting with the
3
+ * text before it (`task_*` matches `task_1`, and `task_` itself). A lone `*` matches every key.
4
+ * Store roots, subscriptions' key filters and route segments all match this way. The text is
5
+ * compared as it is: no character but a final `*` is special.
6
+ */
7
+ /** A pattern's wildcard, its last character */
8
+ export declare const WILDCARD = "*";
9
+ /** Whether `pattern` is a pattern rather than an exact key: it ends in `*` */
10
+ export declare function IsPattern(pattern: string): boolean;
11
+ /** The text a pattern's keys start with: the pattern without its `*` */
12
+ export declare function PatternPrefix(pattern: string): string;
13
+ /** Whether `key` matches `pattern`: it is the exact key, or starts with the pattern's prefix */
14
+ export declare function PatternMatches(pattern: string, key: string): boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Key patterns: an exact key, or a pattern ending in `*` that matches every key starting with the
3
+ * text before it (`task_*` matches `task_1`, and `task_` itself). A lone `*` matches every key.
4
+ * Store roots, subscriptions' key filters and route segments all match this way. The text is
5
+ * compared as it is: no character but a final `*` is special.
6
+ */
7
+ /** A pattern's wildcard, its last character */
8
+ export const WILDCARD = "*";
9
+ /** Whether `pattern` is a pattern rather than an exact key: it ends in `*` */
10
+ export function IsPattern(pattern) {
11
+ return pattern.endsWith(WILDCARD);
12
+ }
13
+ /** The text a pattern's keys start with: the pattern without its `*` */
14
+ export function PatternPrefix(pattern) {
15
+ return pattern.slice(0, -WILDCARD.length);
16
+ }
17
+ /** Whether `key` matches `pattern`: it is the exact key, or starts with the pattern's prefix */
18
+ export function PatternMatches(pattern, key) {
19
+ return IsPattern(pattern) ? key.startsWith(PatternPrefix(pattern)) : pattern === key;
20
+ }
@@ -1,4 +1,4 @@
1
- const WILDCARD = "*";
1
+ import { IsPattern, PatternMatches, PatternPrefix } from "./pattern";
2
2
  /**
3
3
  * Subscriptions to keys, for any use: it matches keys to subscriptions and counts who holds a
4
4
  * key, and knows nothing about what a key's value is or how it's delivered.
@@ -34,8 +34,8 @@ export class SubscriptionManager {
34
34
  if (keys)
35
35
  for (let x = 0; x < keys.length; x++) {
36
36
  const key = keys[x];
37
- if (key.endsWith(WILDCARD))
38
- entry.prefixes.push(key.slice(0, -1));
37
+ if (IsPattern(key))
38
+ entry.prefixes.push(PatternPrefix(key));
39
39
  else
40
40
  entry.exact.add(key);
41
41
  }
@@ -153,9 +153,6 @@ function Matches(entry, key) {
153
153
  return true;
154
154
  return false;
155
155
  }
156
- function PatternMatches(pattern, key) {
157
- return pattern.endsWith(WILDCARD) ? key.startsWith(pattern.slice(0, -1)) : pattern === key;
158
- }
159
156
  function Remove(list, item) {
160
157
  const index = list.indexOf(item);
161
158
  index >= 0 && list.splice(index, 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "j-templates",
3
- "version": "10.0.0",
3
+ "version": "10.0.1",
4
4
  "description": "j-templates",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/TypesInCode/jTemplates",