shelving 1.188.4 → 1.188.6

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.
@@ -6,5 +6,5 @@ export declare class EndpointStore<P, R> extends PayloadFetchStore<P, R> {
6
6
  readonly provider: APIProvider<P, R>;
7
7
  readonly endpoint: Endpoint<P, R>;
8
8
  constructor(endpoint: Endpoint<P, R>, payload: P, provider: APIProvider<P, R>);
9
- protected _fetch(): Promise<R>;
9
+ protected _fetch(signal: AbortSignal): Promise<R>;
10
10
  }
@@ -10,7 +10,7 @@ export class EndpointStore extends PayloadFetchStore {
10
10
  this.provider = provider;
11
11
  }
12
12
  // Override to fetch the value using the provider and endpoint.
13
- _fetch() {
14
- return this.provider.call(this.endpoint, this.payload.value, { signal: this.signal });
13
+ _fetch(signal) {
14
+ return this.provider.call(this.endpoint, this.payload.value, { signal });
15
15
  }
16
16
  }
@@ -16,5 +16,5 @@ export declare class ItemStore<I extends Identifier, T extends Data> extends Fet
16
16
  get item(): Item<I, T>;
17
17
  set item(data: T | Item<I, T>);
18
18
  constructor(collection: Collection<string, I, T>, id: I, provider: DBProvider<I>, memory?: MemoryDBProvider<I>);
19
- _fetch(): Promise<OptionalItem<I, T>>;
19
+ _fetch(_signal: AbortSignal): Promise<OptionalItem<I, T>>;
20
20
  }
@@ -38,7 +38,7 @@ export class ItemStore extends FetchStore {
38
38
  this.id = id;
39
39
  }
40
40
  // Override to get the item from the provider.
41
- _fetch() {
41
+ _fetch(_signal) {
42
42
  return this.provider.getItem(this.collection, this.id);
43
43
  }
44
44
  }
@@ -23,7 +23,7 @@ export declare class QueryStore<I extends Identifier, T extends Data> extends Fe
23
23
  /** Get the last item in this store. */
24
24
  get last(): Item<I, T>;
25
25
  constructor(collection: Collection<string, I, T>, query: Query<Item<I, T>>, provider: DBProvider<I>, memory?: MemoryDBProvider<I>);
26
- _fetch(): Promise<Items<I, T>>;
26
+ _fetch(_signal: AbortSignal): Promise<Items<I, T>>;
27
27
  /**
28
28
  * Load more items after the last once.
29
29
  * - Promise that needs to be handled.
@@ -62,7 +62,7 @@ export class QueryStore extends FetchStore {
62
62
  this.query = query;
63
63
  }
64
64
  // Override to fetch the result from the database provider.
65
- _fetch() {
65
+ _fetch(_signal) {
66
66
  return this.provider.getQuery(this.collection, this.query);
67
67
  }
68
68
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.188.4",
3
+ "version": "1.188.6",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,13 +9,13 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.4.12",
12
+ "@biomejs/biome": "^2.4.13",
13
13
  "@google-cloud/firestore": "^8.5.0",
14
- "@types/bun": "^1.3.12",
14
+ "@types/bun": "^1.3.13",
15
15
  "@types/react": "^19.2.14",
16
16
  "@types/react-dom": "^19.2.3",
17
- "@typescript/native-preview": "^7.0.0-dev.20260418.1",
18
- "firebase": "^12.12.0",
17
+ "@typescript/native-preview": "^7.0.0-dev.20260425.1",
18
+ "firebase": "^12.12.1",
19
19
  "react": "^19.2.5",
20
20
  "react-dom": "^19.2.5",
21
21
  "typescript": "^5.9.3"
@@ -2,53 +2,62 @@ import { NONE } from "../util/constants.js";
2
2
  import { BooleanStore } from "./BooleanStore.js";
3
3
  import { Store } from "./Store.js";
4
4
  /** Callback for a callback fetch store. */
5
- export type FetchCallback<T> = () => T | PromiseLike<T>;
5
+ export type FetchCallback<T> = (signal: AbortSignal) => T | PromiseLike<T>;
6
6
  /**
7
7
  * Store that fetches its values from a remote source.
8
8
  *
9
9
  * @param value The initial value for the store, or `NONE` if it does not have one yet.
10
- * @param callback An optional callback that, if set, will be called when the `fetch()` method is invoked to fetch the next value.
10
+ * @param callback An optional callback that, if set, will be called when the `refresh()` method is invoked to fetch the next value.
11
11
  */
12
12
  export declare class FetchStore<T> extends Store<T> {
13
13
  /**
14
14
  * Store that indicates the busy state of this store.
15
- * - Can be listened to for e.g. loading spinners etc.
15
+ * - `true` while a refresh is in-flight, `false` otherwise.
16
+ * - Can be subscribed to for e.g. loading spinners.
16
17
  */
17
18
  readonly busy: BooleanStore;
18
19
  get loading(): boolean;
19
20
  get value(): T;
20
- set value(value: T | typeof NONE);
21
+ set value(value: T | typeof NONE | PromiseLike<T | typeof NONE>);
21
22
  constructor(value: T | typeof NONE, callback?: FetchCallback<T>);
22
23
  /**
23
- * Fetch the result for this endpoint now.
24
- * - Triggered automatically when someone reads `value` or `loading`
25
- * - Multiple requests to `fetch()` while one is inflight will return the same promise.
24
+ * Fetch the result for this store now.
25
+ * - Triggered automatically when someone reads `value` or `loading`.
26
+ * - Concurrent calls while a fetch is in-flight return the same promise (deduplication).
27
+ * - Never throws — errors are stored as `reason`.
26
28
  *
27
- * @returns {Promise} that resolves when the fetch is done.
28
- * @returns {void} if this store returned a synchronous value.
29
- * @throws {never} Never throws so safe to call unhandled.
29
+ * @returns `true` if the fetch completed and the value was applied, `false` if aborted or superseded.
30
+ * @returns Synchronous `boolean` if the callback returned a synchronous value.
30
31
  */
31
- refresh(): Promise<void> | void;
32
- /** An in-flight refresh, so we don't de-duplicate these. */
32
+ refresh(): Promise<boolean> | boolean;
33
33
  private _inflight;
34
- await(value: PromiseLike<T>): Promise<void>;
35
- private _awaits;
36
- /** Call the callback with the current payload. */
37
- protected _fetch(): T | PromiseLike<T>;
34
+ private _refresh;
35
+ /**
36
+ * Current `AbortSignal` for this store's in-flight fetch.
37
+ * - Created lazily; a new signal is issued each time `refresh()` starts a new fetch or `abort()` is called.
38
+ */
39
+ get signal(): AbortSignal;
40
+ private _controller;
41
+ /**
42
+ * Call the fetch callback to get the next value.
43
+ * @param signal `AbortSignal` for the current fetch — passed through to the callback so it can cancel HTTP requests etc.
44
+ */
45
+ protected _fetch(signal: AbortSignal): T | PromiseLike<T>;
38
46
  private _callback;
39
- /** Invalidate this endpoint, so a new fetch is triggered next time `this.value` is called. */
47
+ /**
48
+ * Invalidate this store so a new fetch is triggered on the next read of `loading` or `value`.
49
+ * - Also aborts any current in-flight fetch.
50
+ */
40
51
  invalidate(): void;
41
52
  private _invalidation;
42
- /** Re-fetch the result now if the current value is older than `maxAge` millisecond or has been invalidated. */
53
+ /** Re-fetch now if the current value is older than `maxAge` milliseconds or has been invalidated. */
43
54
  refreshStale(maxAge: number): Promise<void>;
44
55
  /**
45
- * Create or get an `AbortSignal` that can be used to cancel an in-flight fetch for this store.
46
- * - implementation code or subclasses can use this signal when fetching to cancel the most recent request
47
- * - The signal will be reset whenever a fetch completes or a new fetch starts.
56
+ * Abort any current in-flight fetch and pending async operation.
57
+ * - Aborts the current `AbortSignal` and clears the controller (a new signal will be created on the next read or fetch).
58
+ * - Clears the in-flight promise and resets busy state.
59
+ * - Any pending `await()` result will be silently discarded.
48
60
  */
49
- get signal(): AbortSignal;
50
- private _controller;
51
- /** Abort the current signal now. */
52
61
  abort(): void;
53
62
  [Symbol.asyncDispose](): Promise<void>;
54
63
  }
@@ -8,126 +8,125 @@ import { Store } from "./Store.js";
8
8
  * Store that fetches its values from a remote source.
9
9
  *
10
10
  * @param value The initial value for the store, or `NONE` if it does not have one yet.
11
- * @param callback An optional callback that, if set, will be called when the `fetch()` method is invoked to fetch the next value.
11
+ * @param callback An optional callback that, if set, will be called when the `refresh()` method is invoked to fetch the next value.
12
12
  */
13
13
  export class FetchStore extends Store {
14
14
  /**
15
15
  * Store that indicates the busy state of this store.
16
- * - Can be listened to for e.g. loading spinners etc.
16
+ * - `true` while a refresh is in-flight, `false` otherwise.
17
+ * - Can be subscribed to for e.g. loading spinners.
17
18
  */
18
19
  busy;
19
20
  // Override to possibly trigger a fetch when `this.loading` is read.
20
- // This is because when we check `store.loading` in a component we are signalling intent that we wish to use that value.
21
+ // Reading `loading` signals intent to use the value, so we start a fetch if needed.
21
22
  get loading() {
22
23
  const loading = super.loading;
23
24
  if (loading || this._invalidation)
24
25
  void this.refresh();
25
26
  return loading;
26
27
  }
27
- // Override to possibly trigger a fetch if `this.value` is still in a loading state or is invalid.
28
- // This is because when we check `store.loading` in a component we are signalling intent that we wish to use that value.
28
+ // Override to possibly trigger a fetch if `this.value` is still loading.
29
+ // Reading `value` signals intent to use the value, so we start a fetch if needed.
29
30
  get value() {
30
31
  if (super.loading)
31
32
  void this.refresh();
32
33
  return super.value;
33
34
  }
34
35
  set value(value) {
35
- super.value = value;
36
- // Setting a value resets in the invalid state.
36
+ super.value = value; // calls Store.set value() which calls this.abort() then _applyValue()
37
+ // Setting a value resets the invalid state.
37
38
  this._invalidation = 0;
38
39
  }
39
- // Override to save callback.
40
40
  constructor(value, callback) {
41
41
  super(value);
42
- this.busy = new BooleanStore(value !== NONE);
42
+ this.busy = new BooleanStore(value === NONE);
43
43
  this._callback = callback;
44
44
  }
45
45
  /**
46
- * Fetch the result for this endpoint now.
47
- * - Triggered automatically when someone reads `value` or `loading`
48
- * - Multiple requests to `fetch()` while one is inflight will return the same promise.
46
+ * Fetch the result for this store now.
47
+ * - Triggered automatically when someone reads `value` or `loading`.
48
+ * - Concurrent calls while a fetch is in-flight return the same promise (deduplication).
49
+ * - Never throws — errors are stored as `reason`.
49
50
  *
50
- * @returns {Promise} that resolves when the fetch is done.
51
- * @returns {void} if this store returned a synchronous value.
52
- * @throws {never} Never throws so safe to call unhandled.
51
+ * @returns `true` if the fetch completed and the value was applied, `false` if aborted or superseded.
52
+ * @returns Synchronous `boolean` if the callback returned a synchronous value.
53
53
  */
54
54
  refresh() {
55
55
  if (this._inflight)
56
56
  return this._inflight;
57
+ // Cancel any existing controller and create a fresh one for this fetch.
58
+ this._controller?.abort(ABORTED);
59
+ this._controller = new AbortController();
57
60
  try {
58
- const value = this._fetch();
61
+ const value = this._fetch(this._controller.signal);
59
62
  if (isAsync(value))
60
- return (this._inflight = this.await(value));
63
+ return (this._inflight = this._refresh(value));
61
64
  this.value = value;
65
+ return true;
62
66
  }
63
67
  catch (thrown) {
64
68
  this.reason = thrown;
69
+ return false;
65
70
  }
66
71
  }
67
- /** An in-flight refresh, so we don't de-duplicate these. */
68
72
  _inflight = undefined;
69
- // Override to start/stop `this.busy` when awaiting values, and handle aborts correctly.
70
- async await(value) {
73
+ async _refresh(asyncValue) {
71
74
  this.busy.value = true;
72
- this._awaits.add(value);
73
75
  try {
74
- // Capture the invalidation number before the change.
75
- const invalidation = this._invalidation;
76
- // Use super.value to set the value directly without resetting the invalidation number.
77
- super.value = await value;
78
- // If this store was not invalidated while awaiting the value (i.e. invalidation number did not change) then reset the invalidation number.
79
- if (invalidation === this._invalidation)
76
+ const refreshed = await this.await(asyncValue);
77
+ if (refreshed)
80
78
  this._invalidation = 0;
81
- }
82
- catch (thrown) {
83
- // If the throw was not an on-purpose abort, save it as the reason.
84
- if (thrown !== ABORTED)
85
- this.reason = thrown;
79
+ return refreshed;
86
80
  }
87
81
  finally {
88
- this._awaits.delete(value);
89
- if (!this._awaits.size) {
90
- this.busy.value = false;
91
- this._inflight = undefined; // Clear any inflight refresh if this was the last await.
92
- }
93
- this._controller = undefined;
82
+ this.busy.value = false;
83
+ this._inflight = undefined;
94
84
  }
95
85
  }
96
- _awaits = new Set(); // Used to only set `busy` when we have no awaited values left.
97
- /** Call the callback with the current payload. */
98
- _fetch() {
86
+ /**
87
+ * Current `AbortSignal` for this store's in-flight fetch.
88
+ * - Created lazily; a new signal is issued each time `refresh()` starts a new fetch or `abort()` is called.
89
+ */
90
+ get signal() {
91
+ return (this._controller ||= new AbortController()).signal;
92
+ }
93
+ _controller;
94
+ /**
95
+ * Call the fetch callback to get the next value.
96
+ * @param signal `AbortSignal` for the current fetch — passed through to the callback so it can cancel HTTP requests etc.
97
+ */
98
+ _fetch(signal) {
99
99
  if (!this._callback)
100
100
  throw new RequiredError("FetchStore has no callback() function", { store: this, caller: this.refresh });
101
- return this._callback();
101
+ return this._callback(signal);
102
102
  }
103
103
  _callback;
104
- /** Invalidate this endpoint, so a new fetch is triggered next time `this.value` is called. */
104
+ /**
105
+ * Invalidate this store so a new fetch is triggered on the next read of `loading` or `value`.
106
+ * - Also aborts any current in-flight fetch.
107
+ */
105
108
  invalidate() {
106
109
  this.abort();
107
110
  this._invalidation++;
108
111
  }
109
- _invalidation = 0; // Used to track the "invalidation number" which increments on each invalidation and
110
- /** Re-fetch the result now if the current value is older than `maxAge` millisecond or has been invalidated. */
112
+ _invalidation = 0;
113
+ /** Re-fetch now if the current value is older than `maxAge` milliseconds or has been invalidated. */
111
114
  async refreshStale(maxAge) {
112
115
  if (this._invalidation || this.age > maxAge)
113
116
  await this.refresh();
114
117
  }
115
118
  /**
116
- * Create or get an `AbortSignal` that can be used to cancel an in-flight fetch for this store.
117
- * - implementation code or subclasses can use this signal when fetching to cancel the most recent request
118
- * - The signal will be reset whenever a fetch completes or a new fetch starts.
119
+ * Abort any current in-flight fetch and pending async operation.
120
+ * - Aborts the current `AbortSignal` and clears the controller (a new signal will be created on the next read or fetch).
121
+ * - Clears the in-flight promise and resets busy state.
122
+ * - Any pending `await()` result will be silently discarded.
119
123
  */
120
- get signal() {
121
- return (this._controller ||= new AbortController()).signal;
122
- }
123
- _controller;
124
- /** Abort the current signal now. */
125
124
  abort() {
126
- const controller = this._controller;
127
- if (controller) {
128
- controller.abort(ABORTED);
129
- this._controller = undefined;
130
- }
125
+ this._controller?.abort(ABORTED);
126
+ this._controller = undefined;
127
+ this._inflight = undefined;
128
+ this.busy.value = false;
129
+ super.abort(); // clears _pendingValue
131
130
  }
132
131
  // Implement `AsyncDisposable`.
133
132
  async [Symbol.asyncDispose]() {
@@ -33,8 +33,7 @@ export class PayloadFetchStore extends FetchStore {
33
33
  */
34
34
  async function _iterate(store) {
35
35
  for await (const _payload of store.payload.next) {
36
- store.abort(); // Abort any in-flight request that used the old payload.
37
- store.invalidate(); // Mark stale so the next read (or the refresh below) fetches fresh data.
38
- void store.refresh(); // Eagerly start a fresh fetch if no other fetch is already in-flight.
36
+ store.invalidate(); // Abort any in-flight fetch and mark stale.
37
+ void store.refresh(); // Eagerly start a fresh fetch with the new payload.
39
38
  }
40
39
  }
package/store/Store.d.ts CHANGED
@@ -10,9 +10,9 @@ export type AnyStore = Store<any>;
10
10
  * - Stores also send their most-recent value to any new subscribers immediately when a new subscriber is added.
11
11
  * - Stores can also be in a loading store where they do not have a current value.
12
12
  *
13
- * @param initial The initial value for the store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
14
- * - To set the store to be loading, use the `NONE` constant or a `Promise` value.
15
- * - To set the store to an explicit value, use that value or another `Store` instance with a value.
13
+ * @param initial The initial value for this store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
14
+ * - To set this store to be loading, use the `NONE` constant or a `Promise` value.
15
+ * - To set this store to an explicit value, use that value or another `Store` instance with a value.
16
16
  */
17
17
  export declare class Store<T> implements AsyncIterable<T, void, void>, AsyncDisposable {
18
18
  /** Deferred sequence this store uses to issue values as they change. */
@@ -24,13 +24,18 @@ export declare class Store<T> implements AsyncIterable<T, void, void>, AsyncDisp
24
24
  */
25
25
  get loading(): boolean;
26
26
  /**
27
- * Current value of the store.
27
+ * Get the current value of this store.
28
28
  *
29
- * @throws {Promise} that resolves when this store receives its next value or error).
30
- * @throws {unknown} if the store currently has an error.
29
+ * @throws {Promise} if this store currently has no value (resolves when this store receives its next value or error).
30
+ * @throws {unknown} if this store currently has an error.
31
31
  */
32
32
  get value(): T;
33
- set value(value: T | typeof NONE);
33
+ /**
34
+ * Set the value of this store .
35
+ * - Silently discards any pending `await()` calls.
36
+ * - Awaits any async values.
37
+ */
38
+ set value(value: T | typeof NONE | PromiseLike<T | typeof NONE>);
34
39
  private _value;
35
40
  /**
36
41
  * Time (in milliseconds) this store was last updated with a new value.
@@ -58,22 +63,46 @@ export declare class Store<T> implements AsyncIterable<T, void, void>, AsyncDisp
58
63
  private _starter;
59
64
  /** Store is initiated with an initial store. */
60
65
  constructor(value: T | typeof NONE);
61
- /** Set the value of the store as values are pulled from a sequence. */
66
+ /** Set the value of this store as values are pulled from a sequence. */
62
67
  through(sequence: AsyncIterable<T>): AsyncIterable<T>;
63
68
  /**
64
- * Safely call a callback and save its output value in this `Store`, optionally passing an input value.
65
- * @param callback The callback function to call that should return a value to set on this store.
69
+ * Call a callback that returns a new value (possibly async) for this store.
70
+ * - Errors are stored as `reason`; never throws.
71
+ *
72
+ * @returns `true` if the value was applied, `false` if an error occurred or the result was superseded.
73
+ */
74
+ call<A extends Arguments = []>(callback: (...args: A) => T | PromiseLike<T>, ...args: A): Promise<boolean> | boolean;
75
+ /**
76
+ * Reduce the current value using a reducer callback that receives the current value.
77
+ *
78
+ * @param reducer The callback function to call that should return a value to set on this store.
79
+ * @param value The current value of this store.
80
+ * @param args Any additional input values for the reducer.
81
+ * @returns New value for this store (possibly async).
66
82
  * @oaram args Any arguments to pass to the callback.
83
+ *
84
+ * @throws {Promise} if this store currently has no value (resolves when this store receives its next value or error).
85
+ * @throws {unknown} if this store currently has an error reason set.
67
86
  */
68
- call<A extends Arguments = []>(callback: (...args: A) => T | PromiseLike<T>, ...args: A): void;
87
+ reduce<A extends Arguments = []>(reducer: (value: T, ...args: A) => T | PromiseLike<T>, ...args: A): Promise<boolean> | boolean;
69
88
  /**
70
89
  * Await an async value and save it to this store.
71
- * - If it rejects, save the rejection `reason` to this store.
90
+ * - Saves the resolved value.
91
+ * - If it rejects saves the rejection as `reason`.
92
+ * - Silently discarded if a newer value is set.
93
+ * - Silently discarded if `await()` is called again.
94
+ * - Silently discarded if `abort()` is called.
72
95
  *
73
- * @returns Promise that resolves when the value has been saved (either resolves to the value or `undefined` if the value threw).
74
- * @throws {never} Never throws so safe to call unhandled..
96
+ * @returns `true` if the value was applied, `false` if superseded, aborted, or errored.
97
+ * @throws {never} Never throws safe to call without handling the return value.
98
+ */
99
+ await(asyncValue: PromiseLike<T | typeof NONE>): Promise<boolean>;
100
+ private _pendingValue;
101
+ /**
102
+ * Abort any current pending `await()` call.
103
+ * - The pending call's result will be silently discarded and its error will not be stored.
75
104
  */
76
- await(value: PromiseLike<T>): Promise<void>;
105
+ abort(): void;
77
106
  [Symbol.asyncIterator](): AsyncIterator<T, void, void>;
78
107
  private _iterating;
79
108
  /** Compare two values for this store and return whether they are equal. */
package/store/Store.js CHANGED
@@ -10,9 +10,9 @@ import { getStarter } from "../util/start.js";
10
10
  * - Stores also send their most-recent value to any new subscribers immediately when a new subscriber is added.
11
11
  * - Stores can also be in a loading store where they do not have a current value.
12
12
  *
13
- * @param initial The initial value for the store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
14
- * - To set the store to be loading, use the `NONE` constant or a `Promise` value.
15
- * - To set the store to an explicit value, use that value or another `Store` instance with a value.
13
+ * @param initial The initial value for this store, a `Promise` that resolves to the initial value, a source `Subscribable` to subscribe to, or another `Store` instance to take the initial value from and subscribe to.
14
+ * - To set this store to be loading, use the `NONE` constant or a `Promise` value.
15
+ * - To set this store to an explicit value, use that value or another `Store` instance with a value.
16
16
  */
17
17
  export class Store {
18
18
  /** Deferred sequence this store uses to issue values as they change. */
@@ -26,10 +26,10 @@ export class Store {
26
26
  return this._value === NONE && this._reason === undefined;
27
27
  }
28
28
  /**
29
- * Current value of the store.
29
+ * Get the current value of this store.
30
30
  *
31
- * @throws {Promise} that resolves when this store receives its next value or error).
32
- * @throws {unknown} if the store currently has an error.
31
+ * @throws {Promise} if this store currently has no value (resolves when this store receives its next value or error).
32
+ * @throws {unknown} if this store currently has an error.
33
33
  */
34
34
  get value() {
35
35
  if (this._reason !== undefined)
@@ -38,17 +38,28 @@ export class Store {
38
38
  throw this.next;
39
39
  return this._value;
40
40
  }
41
+ /**
42
+ * Set the value of this store .
43
+ * - Silently discards any pending `await()` calls.
44
+ * - Awaits any async values.
45
+ */
41
46
  set value(value) {
42
- this._reason = undefined;
43
- if (value === NONE) {
44
- this._time = undefined;
45
- this._value = value;
46
- this.next.cancel();
47
+ if (isAsync(value)) {
48
+ void this.await(value);
47
49
  }
48
- else if (this._value === NONE || !this.isEqual(value, this._value)) {
49
- this._time = Date.now();
50
- this._value = value;
51
- this.next.resolve(value);
50
+ else {
51
+ this.abort();
52
+ this._reason = undefined;
53
+ if (value === NONE) {
54
+ this._time = undefined;
55
+ this._value = value;
56
+ this.next.cancel();
57
+ }
58
+ else if (this._value === NONE || !this.isEqual(value, this._value)) {
59
+ this._time = Date.now();
60
+ this._value = value;
61
+ this.next.resolve(value);
62
+ }
52
63
  }
53
64
  }
54
65
  _value;
@@ -75,10 +86,10 @@ export class Store {
75
86
  return this._reason;
76
87
  }
77
88
  set reason(reason) {
89
+ this.abort();
78
90
  this._reason = reason;
79
- if (reason !== undefined) {
91
+ if (reason !== undefined)
80
92
  this.next.reject(reason);
81
- }
82
93
  }
83
94
  _reason = undefined;
84
95
  /**
@@ -99,7 +110,7 @@ export class Store {
99
110
  this._value = value;
100
111
  this._time = value === NONE ? -Infinity : Date.now();
101
112
  }
102
- /** Set the value of the store as values are pulled from a sequence. */
113
+ /** Set the value of this store as values are pulled from a sequence. */
103
114
  async *through(sequence) {
104
115
  for await (const value of sequence) {
105
116
  this.value = value;
@@ -107,39 +118,80 @@ export class Store {
107
118
  }
108
119
  }
109
120
  /**
110
- * Safely call a callback and save its output value in this `Store`, optionally passing an input value.
111
- * @param callback The callback function to call that should return a value to set on this store.
112
- * @oaram args Any arguments to pass to the callback.
121
+ * Call a callback that returns a new value (possibly async) for this store.
122
+ * - Errors are stored as `reason`; never throws.
123
+ *
124
+ * @returns `true` if the value was applied, `false` if an error occurred or the result was superseded.
113
125
  */
114
126
  call(callback, ...args) {
115
127
  try {
116
128
  const value = callback(...args);
117
129
  if (isAsync(value))
118
- void this.await(value);
119
- else
120
- this.value = value;
130
+ return this.await(value);
131
+ this.value = value;
132
+ return true;
121
133
  }
122
134
  catch (thrown) {
123
135
  this.reason = thrown;
136
+ return false;
124
137
  }
125
138
  }
139
+ /**
140
+ * Reduce the current value using a reducer callback that receives the current value.
141
+ *
142
+ * @param reducer The callback function to call that should return a value to set on this store.
143
+ * @param value The current value of this store.
144
+ * @param args Any additional input values for the reducer.
145
+ * @returns New value for this store (possibly async).
146
+ * @oaram args Any arguments to pass to the callback.
147
+ *
148
+ * @throws {Promise} if this store currently has no value (resolves when this store receives its next value or error).
149
+ * @throws {unknown} if this store currently has an error reason set.
150
+ */
151
+ reduce(reducer, ...args) {
152
+ return this.call(reducer, this.value, ...args);
153
+ }
126
154
  /**
127
155
  * Await an async value and save it to this store.
128
- * - If it rejects, save the rejection `reason` to this store.
156
+ * - Saves the resolved value.
157
+ * - If it rejects saves the rejection as `reason`.
158
+ * - Silently discarded if a newer value is set.
159
+ * - Silently discarded if `await()` is called again.
160
+ * - Silently discarded if `abort()` is called.
129
161
  *
130
- * @returns Promise that resolves when the value has been saved (either resolves to the value or `undefined` if the value threw).
131
- * @throws {never} Never throws so safe to call unhandled..
162
+ * @returns `true` if the value was applied, `false` if superseded, aborted, or errored.
163
+ * @throws {never} Never throws safe to call without handling the return value.
132
164
  */
133
- async await(value) {
165
+ async await(asyncValue) {
166
+ // Keep track of the value that is being awaited.
167
+ // If `_pendingValue` changes while waiting for `asyncValue` to resolve, another call to `await()` has `set value`, `set reason`, or `abort()` has invalidated this one.
168
+ // If that happens we silently discard the resolved value/reason of this await call.
169
+ this._pendingValue = asyncValue;
134
170
  try {
135
- this.value = await value;
171
+ const value = await asyncValue;
172
+ if (this._pendingValue === asyncValue) {
173
+ this.value = value;
174
+ return true;
175
+ }
176
+ return false;
136
177
  }
137
178
  catch (reason) {
138
- this.reason = reason;
179
+ if (this._pendingValue === asyncValue) {
180
+ this.reason = reason;
181
+ }
182
+ return false;
139
183
  }
140
184
  }
185
+ _pendingValue = undefined;
186
+ /**
187
+ * Abort any current pending `await()` call.
188
+ * - The pending call's result will be silently discarded and its error will not be stored.
189
+ */
190
+ abort() {
191
+ this._pendingValue = undefined;
192
+ }
141
193
  // Implement `AsyncIterator`
142
- // Issues the current value of the store first, then any subsequent values that are issued.
194
+ // Issues the current value of this store first, then any subsequent values that are issued.
143
195
  async *[Symbol.asyncIterator]() {
144
196
  await Promise.resolve(); // Introduce a slight delay, i.e. don't immediately yield `this.value` in case it is changed synchronously.
145
197
  this._starter?.start(this);