UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

313 lines (312 loc) 12 kB
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; }