shelving
Version:
Toolkit for using data in JavaScript.
313 lines (312 loc) • 12 kB
JavaScript
import { DeferredSequence } from "../sequence/DeferredSequence.js";
import { isAsync } from "../util/async.js";
import { NONE, SKIP } from "../util/constants.js";
import { awaitDispose } from "../util/dispose.js";
import { isDeepEqual } from "../util/equal.js";
import { runSequence } from "../util/index.js";
import { getStarter } from "../util/start.js";
/**
* Store that retains its most recent value and is async-iterable to allow values to be observed.
* - Current value can be read at `store.value` and `store.data`
* - Stores also send their most-recent value to any new subscribers immediately when a new subscriber is added.
* - Stores can also be in a loading store where they do not have a current value.
*
* @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.
* - To set this store to be loading, use the `NONE` constant or a `Promise` value.
* - To set this store to an explicit value, use that value or another `Store` instance with a value.
*
* @param T The "main type" for this store.
* - Indicates what values `this.value` will return.
* - Methods that set values like `this.call()` and `this.await()` can also accept these values.
* @param TT The "input type" for this store.
* - Indicates what additional input types `this.value` can convert to `T`
* - Defaults to `T` (no conversion).
* - Override conversion by overriding `this._convert(v: TT): T`
* - Warning: With no override, default behaviour is to just assert TT is T (unsafe).
*/
export class Store {
/** Deferred sequence this store uses to issue values as they change. */
next = new DeferredSequence();
/**
* Snapshot returns either the current reason or the current value (or `NONE` if reason is unset).
*/
get snapshot() {
return this._reason !== undefined ? this._reason : this._value;
}
/**
* Store is considered to be "loading" if it has no value or error.
* - Calling `this.value` will throw `this.reason` if there's an error reason set, or a `Promise` if there's no value set.
* - Calling `this.loading` is a way to check if this store has a value without triggering those throws.
*/
get loading() {
return this._value === NONE && this.reason === undefined;
}
/**
* Set the value of this store.
* - Sets any sync values.
* - Awaits any async values.
* - Setting value the `NONE` symbol indicates the store has no value so should be in a "loading" state.
* - Setting value to `SKIP` indicates the value should be silently ignored (sometimes it's helpful to have a way to skip a write entirely).
* - Setting value to the same as the existing value
* - If this store has any pending `await()` calls they are aborted and their results are silently discarded.
*/
set value(input) {
if (isAsync(input))
void this.await(input);
else
this.write(input);
}
/** Write a synchronous value to this store. */
write(input) {
this.abort();
this._reason = undefined;
if (input === SKIP) {
// Skip this value entirely.
return;
}
else if (input === NONE) {
// Put the store into a loading state.
this._time = undefined;
this._value = NONE;
this.next.cancel();
return;
}
const storage = this._convert(input);
if (this._value === NONE || !this._equal(storage, this._value)) {
// Set this changed value.
this._time = Date.now();
this._value = storage;
this.next.resolve(storage);
}
}
/**
* Convert input type to internal storage type.
* - Override in subclasses to change conversion behaviour.
* - Warning: With no override, default behaviour is to just assert TT is T (unsafe).
*/
_convert(input, _caller) {
return input;
}
/** Compare two values for this store and return whether they are equal. */
_equal(a, b) {
return isDeepEqual(a, b);
}
/** Internal storage for current value. */
_value;
/**
* Get the current value of this store.
*
* @throws {Promise} if this store currently is in a "loading" state (resolves when a value is set).
* @throws {unknown} if this store currently has an error.
*/
get value() {
if (this._reason !== undefined)
throw this._reason;
const value = this.read();
if (value === NONE)
throw this.next;
return value;
}
/**
* Called to read values. Can be used to override get behaviour.
* - Override in subclasses to change getting behaviour.
* - Note: doesn't throw `reason` if there is one!
*/
read() {
return this._value;
}
/**
* Time (in milliseconds) this store was last updated with a new value.
* - Will be `undefined` if the value is still loading.
*/
get time() {
return this._time;
}
_time;
/**
* How old this store's value is (in milliseconds).
* - Will be `Infinity` if the value is still loading (to simplify downstream calculations).
*
* @example if (store.age > MINUTE) refreshStore(store);
*/
get age() {
const time = this.time;
return typeof time === "number" ? Date.now() - time : Infinity;
}
/**
* Whether this store is stale based on a `maxAge` value in milliseconds.
*
* @param maxAge The maximum age for the stale check.
* - `0` zero means "always refresh" (this is the default).
* - `Infinity` means "refresh only if store is still in a loading state"
* - Any other value may or may not be stale based on `this.age`
*/
stale(maxAge) {
return this.age >= maxAge;
}
/** Current error of this store, or `undefined` if there is no error. */
get reason() {
return this._reason;
}
set reason(reason) {
this.abort();
this._reason = reason;
if (reason !== undefined)
this.next.reject(reason);
}
_reason = undefined;
/**
* Set a starter for this store to allow a function to execute when this store has subscribers or not.
*
* @todo DH: Change this significantly. Not happy with how it's settable like this. It should be set in `constructor()`?
* - Also would love some internal hooks
*/
set starter(start) {
if (this._starter)
this._starter.stop(); // Stop the current starter.
this._starter = getStarter(start);
if (this._iterating)
this._starter.start(this); // Start the new starter if we're already iterating.
}
_starter;
/** Store is initiated with a value, or `NONE` to put it in a "loading" state. */
constructor(value) {
this._value = value;
this._time = value === NONE ? -Infinity : Date.now();
}
/** Set the value of this store as values are pulled from a sequence. */
async *through(sequence) {
for await (const value of sequence) {
this.write(value);
yield value;
}
}
/**
* Call a callback and save the returned value to this store.
* - If the callback returns an async value, it is awaited and values/errors will be saved.
*/
call(callback, ...args) {
try {
const value = callback(...args);
if (isAsync(value))
return this.await(value);
this.write(value);
return true;
}
catch (thrown) {
this.reason = thrown;
return false;
}
}
/**
* Send the current value to a callback and save the returned value to this store.
* - If the callback returns an async value, it is awaited and values/errors will be saved.
*/
reduce(reducer, ...args) {
return this.call(reducer, this.value, ...args);
}
/**
* Run a callback and ignore any returned value.
* - If the callback returns an async value, it is awaited and errors will be saved.
*/
run(callback, ...args) {
return this.call(_callSkipped, callback, ...args);
}
/**
* Send the current value to a callback and ignore any returned value.
* - If the callback returns an async value, it is awaited and errors will be saved.
*/
send(callback, ...args) {
return this.call(_callSkipped, callback, this.value, ...args);
}
/**
* Await an async value and save it to this store.
* - Saves the resolved value.
* - If it rejects saves the rejection as `reason`.
* - Silently discarded if a newer value is set.
* - Silently discarded if `await()` is called again.
* - Silently discarded if `abort()` is called.
*
* @param pending The pending value to await.
*
* @returns {true} If the callback returned a value and it was set.
* @returns {false} If the callback threw.
* @returns {Promise<true>} If the callback returned a promise and it resolved.
* @returns {Promise<false>} If the callback returned a promise and it rejected, or `abort()` was called before it resolved.
*
* @throws {never} Never throws — safe to call without handling the return value.
*/
async await(pending) {
// Keep track of the value that is being awaited.
// If `_pendingValue` changes while waiting for `asyncValue` to resolve, another call to `await()` has `set value`, `set reason`, or `abort()` has invalidated this one.
// If that happens we silently discard the resolved value/reason of this await call.
this._pendingValue = pending;
try {
const value = await pending;
if (this._pendingValue === pending) {
this.write(value);
return true;
}
return false;
}
catch (reason) {
if (this._pendingValue === pending) {
this.reason = reason;
}
return false;
}
}
_pendingValue = undefined;
/**
* Abort any current pending `await()` call.
* - The pending call's result will be silently discarded and its error will not be stored.
*/
abort() {
this._pendingValue = undefined;
}
// Implement `AsyncIterator`
// Issues the current value of this store first, then any subsequent values that are issued.
async *[Symbol.asyncIterator]() {
await Promise.resolve(); // Introduce a slight delay, i.e. don't immediately yield `this.value` in case it is changed synchronously.
this._starter?.start(this);
this._iterating++;
try {
const reason = this.reason;
if (reason !== undefined)
throw reason;
if (!this.loading)
yield this.value;
yield* this.next;
}
finally {
this._iterating--;
if (this._iterating < 1)
this._starter?.stop();
}
}
_iterating = 0;
// Implement `AsyncDisposable`
async [Symbol.asyncDispose]() {
await awaitDispose(this._starter, // Stop the starter.
this.next);
}
/**
* Subscribe to this store with handlers.
* - Returns a `StopCallback` to stop the subscription.
*/
subscribe(onNext, onError, onReturn) {
return runSequence(this, onNext, onError, onReturn);
}
}
/** Call a callback but always return or resolve to `SKIP` */
function _callSkipped(callback, ...args) {
const value = callback(...args);
if (isAsync(value))
return _awaitSkipped(value);
return SKIP;
}
/** Await a promise but resolve to `SKIP` */
async function _awaitSkipped(pending) {
await pending;
return SKIP;
}