From 59f16c0f0c97627e818b0e968f24f526e542ddb7 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 23 Sep 2026 17:25:50 +0200 Subject: [PATCH] KeyedAutomat --- README.md | 49 ++++ src/lib/Automat.js | 17 +- src/lib/KeyedAutomat.js | 603 ++++++++++++++++++++++++++++++++++++++++ src/lib/index.d.ts | 81 ++++++ src/lib/index.js | 1 + 5 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/lib/KeyedAutomat.js diff --git a/README.md b/README.md index 93c4dfc..ab1ff36 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,55 @@ photo1.invalidateWith(photoHub); // 3. If photo1 is mounted, re-fetches immediately; if unmounted, defers fetch! ``` +### G. Dynamic / Keyed Entities (`KeyedAutomat`) + +For entity collections loaded by ID (e.g. categories, articles, users), `KeyedAutomat` provides per-ID reactive entries, concurrent in-flight deduplication, React Suspense, and fine-grained per-ID subscriptions: + +```jsx +import { KeyedAutomat } from 'react-automat'; + +const categoryAutomat = new KeyedAutomat(null, {}, { + name: 'categories', + loader: async (id) => { + const res = await fetch(`/api/categories/${id}`); + return res.json(); + }, +}); + +// 1. Scoped handle for a specific ID: +const techHandle = categoryAutomat.key('tech'); + +// 2. React Suspense (auto-triggers load, throws in-flight promise): +const TechCategory = () => { + const category = categoryAutomat.read('tech'); // or techHandle.read() + return

{category.name}

; +}; + +// 3. PureComponent subscription (O(1) updates, 0 cross-talk between IDs): +class CategoryView extends PureComponent { + constructor(props) { + super(props); + this.handle = categoryAutomat.key(props.id); + this.state = this.handle.state; // { data, loading, loaded, error } + } + componentDidMount() { + this.unsub = this.handle.subscribe(this, (item) => ({ + data: item.data, + loading: item.loading, + })); + this.handle.load(); // Deduplicated across concurrent mounts + } + componentWillUnmount() { + this.unsub(); + } + render() { + const { loading, data } = this.state; + if (loading) return
Loading category...
; + return
{data?.name}
; + } +} +``` + --- ## 3. Core Invariants diff --git a/src/lib/Automat.js b/src/lib/Automat.js index b2ecb4f..1b20b5b 100644 --- a/src/lib/Automat.js +++ b/src/lib/Automat.js @@ -26,9 +26,11 @@ export function shallowEqual(a, b) { return true; } +import { KeyedAutomat } from './KeyedAutomat.js'; + // ── Global Window & Registry Aliases ────────────────────────────── const W = typeof window !== 'undefined' ? window : null; -const getGlobalMap = (key) => W && (W[key] ||= new Map()); +export const getGlobalMap = (key) => W && (W[key] ||= new Map()); // ── Unified IndexedDB Storage ───────────────────────────────────── const IDB_NAME = 'automat_db'; @@ -106,7 +108,7 @@ function idbDelete(key) { } // ── Selector Normalizer ─────────────────────────────────────────── -function resolveSelector(s, isComponent = false) { +export function resolveSelector(s, isComponent = false) { if (typeof s === 'function') { return s; } @@ -304,6 +306,17 @@ export class Automat { throw new TypeError('Automat.combine expects an array or dictionary object of Automat instances.'); } + /** + * Creates a KeyedAutomat instance for dynamic ID-based state. + * @param {any} [initialItemState=null] + * @param {any} [callbacks={}] + * @param {any} [options={}] + * @returns {KeyedAutomat} + */ + static keyed(initialItemState = null, callbacks = {}, options = {}) { + return new KeyedAutomat(initialItemState, callbacks, options); + } + get name() { return this._name; } diff --git a/src/lib/KeyedAutomat.js b/src/lib/KeyedAutomat.js new file mode 100644 index 0000000..da4408c --- /dev/null +++ b/src/lib/KeyedAutomat.js @@ -0,0 +1,603 @@ +/** + * @module KeyedAutomat + * + * Observable dynamic/keyed entity state container for React PureComponents. + * Manages collections of items by ID with concurrent fetch deduplication, + * React Suspense support, and fine-grained per-ID subscriptions. + */ + +import { shallowEqual, resolveSelector, getGlobalMap } from './Automat.js'; + +/** + * Creates a blank item state container. + * @template T + * @param {T | null} [initialData=null] + * @returns {import('./index.d.ts').ItemState} + */ +export function createDefaultItemState(initialData = null) { + return { + data: initialData, + loading: false, + loaded: false, + error: null, + }; +} + +/** + * Lightweight scoped handle for a specific key/ID within a KeyedAutomat. + * Implements an interface consistent with standard Automat instances. + * @template T + */ +export class KeyHandle { + /** + * @param {KeyedAutomat} automat + * @param {string | number} id + */ + constructor(automat, id) { + this._automat = automat; + this._id = id; + } + + get id() { + return this._id; + } + + get automat() { + return this._automat; + } + + get state() { + return this._automat.get(this._id); + } + + get data() { + return this._automat.getData(this._id); + } + + get loading() { + return this._automat.get(this._id).loading; + } + + get loaded() { + return this._automat.isLoaded(this._id); + } + + get error() { + return this._automat.get(this._id).error; + } + + load(options) { + return this._automat.load(this._id, options); + } + + read() { + return this._automat.read(this._id); + } + + reload() { + return this._automat.reload(this._id); + } + + setDirty(keep, now) { + return this._automat.setDirty(this._id, keep, now); + } + + set(data) { + return this._automat.set(this._id, data); + } + + evict() { + return this._automat.evict(this._id); + } + + subscribe(target, selector) { + return this._automat.subscribeKey(this._id, target, selector); + } +} + +/** + * KeyedAutomat manages a dynamic dictionary of reactive entity states by ID. + * @template T + * @template A + */ +export class KeyedAutomat { + /** + * @param {T | null} [initialItemState=null] Default data value for new items. + * @param {A | ((automat: KeyedAutomat) => A)} [callbacks={}] Actions dictionary or factory. + * @param {object} [options={}] + * @param {string} [options.name] Identifier for global registry and devtools. + * @param {(id: string | number, automat: KeyedAutomat) => Promise | T} [options.loader] + */ + constructor(initialItemState = null, callbacks = {}, options = {}) { + this._initialItem = initialItemState !== undefined ? initialItemState : null; + this._options = options || {}; + this._name = options.name ?? null; + this._loader = options.loader ?? null; + + /** @type {Map>} */ + this._items = new Map(); + + /** @type {Map>} */ + this._inFlight = new Map(); + + /** @type {Map>} */ + this._keyedSubs = new Map(); + + /** @type {Map} */ + this._globalSubs = new Map(); + + /** @type {Set} */ + this._dirtySubs = new Set(); + + /** @type {Map>} */ + this._keyHandles = new Map(); + + const resolvedCallbacks = typeof callbacks === 'function' ? callbacks(this) : (callbacks || {}); + this._cbs = {}; + for (const [key, fn] of Object.entries(resolvedCallbacks)) { + this._cbs[key] = typeof fn === 'function' ? fn.bind(this) : fn; + } + + if (this._name) { + getGlobalMap('__KEYED_AUTOMATS__')?.set(this._name, this); + getGlobalMap('__AUTOMATS__')?.set(this._name, this); + } + } + + get name() { + return this._name; + } + + get actions() { + return this._cbs; + } + + /** + * Returns a snapshot object of all currently cached ID item states. + */ + get state() { + const snapshot = {}; + for (const [id, item] of this._items.entries()) { + snapshot[id] = item; + } + return snapshot; + } + + getState() { + return this.state; + } + + /** + * Retrieves or creates a scoped KeyHandle proxy for the specified ID. + * @param {string | number} id + * @returns {KeyHandle} + */ + key(id) { + const keyStr = String(id); + let handle = this._keyHandles.get(keyStr); + if (!handle) { + handle = new KeyHandle(this, id); + this._keyHandles.set(keyStr, handle); + } + return handle; + } + + /** + * Returns whether an entry exists for the given ID. + * @param {string | number} id + * @returns {boolean} + */ + has(id) { + return this._items.has(id); + } + + /** + * Returns whether the given ID has been successfully loaded. + * @param {string | number} id + * @returns {boolean} + */ + isLoaded(id) { + return Boolean(this._items.get(id)?.loaded); + } + + /** + * Returns the ItemState for an ID. Returns a default unready ItemState if not yet fetched. + * @param {string | number} id + * @returns {import('./index.d.ts').ItemState} + */ + get(id) { + const item = this._items.get(id); + if (item) return item; + return createDefaultItemState(this._initialItem); + } + + /** + * Alias for get(id). + * @param {string | number} id + */ + getItem(id) { + return this.get(id); + } + + /** + * Returns the data property for an ID, or null if unready. + * @param {string | number} id + * @returns {T | null} + */ + getData(id) { + return this.get(id).data; + } + + /** + * Loads data for a specific ID with concurrent fetch deduplication. + * @param {string | number} id + * @param {{ force?: boolean }} [options] + * @returns {Promise} + */ + load(id, options = {}) { + const keyStr = String(id); + if (!options.force && this._inFlight.has(keyStr)) { + return this._inFlight.get(keyStr); + } + + const current = this.get(id); + if (!options.force && current.loaded && !current.error) { + return Promise.resolve(current.data); + } + + if (!this._loader) { + return Promise.resolve(current.data); + } + + // Set loading state + const loadingState = { + ...current, + loading: true, + error: null, + }; + this._items.set(id, loadingState); + this._notifyKey(id, loadingState); + + const promise = Promise.resolve(this._loader(id, this)) + .then((data) => { + this._inFlight.delete(keyStr); + const loadedState = { + data: data !== undefined ? data : null, + loading: false, + loaded: true, + error: null, + }; + this._items.set(id, loadedState); + this._notifyKey(id, loadedState); + return loadedState.data; + }) + .catch((err) => { + this._inFlight.delete(keyStr); + const errorVal = err?.message || String(err); + const errorState = { + ...this.get(id), + loading: false, + error: errorVal, + }; + this._items.set(id, errorState); + this._notifyKey(id, errorState); + throw err; + }); + + this._inFlight.set(keyStr, promise); + return promise; + } + + /** + * React Suspense-compatible read method for an ID. + * - If not loaded and not loading, triggers load(id) and throws the promise. + * - If loading, throws the in-flight promise. + * - If error, throws the error. + * - If loaded, returns data. + * + * @param {string | number} id + * @returns {T} + */ + read(id) { + const keyStr = String(id); + const item = this._items.get(id); + + if (!item || (!item.loaded && !item.loading && !item.error)) { + const promise = this.load(id); + throw promise; + } + + if (item.loading) { + const inFlight = this._inFlight.get(keyStr); + if (inFlight) throw inFlight; + } + + if (item.error) { + throw typeof item.error === 'object' ? item.error : new Error(item.error); + } + + return item.data; + } + + /** + * Directly sets/commits data for a specific ID into state. + * @param {string | number} id + * @param {T} data + * @returns {import('./index.d.ts').ItemState} + */ + set(id, data) { + const next = { + data: data !== undefined ? data : null, + loading: false, + loaded: true, + error: null, + }; + this._items.set(id, next); + this._notifyKey(id, next); + return next; + } + + /** + * Invalidates a specific ID or all cached IDs. + * If subscribed and configured with a loader, re-fetches immediately when requested or subscribed. + * + * @param {string | number} [id] Target ID. If omitted, marks all keys dirty. + * @param {boolean | { keep?: boolean, now?: boolean }} [keep=false] + * @param {boolean} [now=false] + */ + setDirty(id, keep = false, now = false) { + if (id === undefined || id === null) { + // Invalidate all items + const promises = []; + for (const key of Array.from(this._items.keys())) { + promises.push(this.setDirty(key, keep, now)); + } + return Promise.all(promises); + } + + let shouldKeep = Boolean(keep); + let shouldNow = Boolean(now); + if (typeof keep === 'object' && keep !== null) { + shouldKeep = Boolean(keep.keep); + shouldNow = Boolean(keep.now); + } + + const current = this._items.get(id); + if (typeof current?.data?.revoke === 'function') { + try { + current.data.revoke(); + } catch {} + } + + if (!shouldKeep) { + const resetState = createDefaultItemState(this._initialItem); + this._items.set(id, resetState); + this._notifyKey(id, resetState); + } else if (current) { + current.loaded = false; + this._notifyKey(id, current); + } + + for (const fn of this._dirtySubs) { + try { + fn(id, this); + } catch {} + } + + const keySubs = this._keyedSubs.get(id); + const hasSubscribers = (keySubs && keySubs.size > 0) || this._globalSubs.size > 0; + + if (this._loader && (shouldNow || hasSubscribers)) { + return this.load(id, { force: true }); + } + + return Promise.resolve(this.get(id).data); + } + + /** + * Forces re-fetch of an ID (or all if omitted). + * @param {string | number} [id] + */ + reload(id) { + return this.setDirty(id, false, true); + } + + /** + * Evicts an item from memory, revoking any blob URLs if present. + * @param {string | number} id + */ + evict(id) { + const current = this._items.get(id); + if (typeof current?.data?.revoke === 'function') { + try { + current.data.revoke(); + } catch {} + } + + this._items.delete(id); + this._inFlight.delete(String(id)); + + const empty = createDefaultItemState(this._initialItem); + this._notifyKey(id, empty); + } + + /** + * Clears all cached items and in-flight requests. + */ + clear() { + for (const id of Array.from(this._items.keys())) { + this.evict(id); + } + } + + /** + * Registers a listener called whenever an item (or the automat) becomes dirty. + * @param {(id: string | number | undefined, automat: KeyedAutomat) => void} fn + * @returns {() => void} + */ + onDirty(fn) { + this._dirtySubs.add(fn); + return () => this._dirtySubs.delete(fn); + } + + /** + * Subscribes a React PureComponent instance or a listener callback to a specific ID. + * Updates only trigger when this specific ID's state changes. + * + * @param {string | number} id + * @param {object | function} target React component (`this`) or callback function. + * @param {string | string[] | function} [selector] + * @returns {() => void} Unsubscribe cleanup function. + */ + subscribeKey(id, target, selector) { + const isComponent = target && typeof target.setState === 'function'; + const isFunction = typeof target === 'function'; + + if (!isComponent && !isFunction) { + throw new TypeError( + 'KeyedAutomat.subscribeKey expects a callback function or a React component instance with a setState method.' + ); + } + + let subsForId = this._keyedSubs.get(id); + if (!subsForId) { + subsForId = new Map(); + this._keyedSubs.set(id, subsForId); + } + + const hasSelector = selector !== undefined && selector !== null; + const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null; + let lastSlice = getSlice ? getSlice(this.get(id)) : undefined; + + const notifyFn = (itemState) => { + if (getSlice) { + const nextSlice = getSlice(itemState); + if (nextSlice == null) return; + if (shallowEqual(lastSlice, nextSlice)) return; + lastSlice = nextSlice; + + if (isComponent) { + if (typeof nextSlice !== 'object') { + throw new TypeError( + 'KeyedAutomat: selector for a React component must return a state object, e.g. s => ({ data: s.data }).' + ); + } + target.setState(nextSlice); + } else { + target(nextSlice); + } + } else { + if (isComponent) { + target.setState({ [id]: itemState }); + } else { + target(itemState); + } + } + }; + + subsForId.set(target, notifyFn); + + return () => { + subsForId.delete(target); + if (subsForId.size === 0) { + this._keyedSubs.delete(id); + } + }; + } + + /** + * Subscribes to the entire dictionary state or multi-item slices. + * + * @param {object | function} target React component (`this`) or callback function. + * @param {string | string[] | function} [selector] + * @returns {() => void} Unsubscribe cleanup function. + */ + subscribe(target, selector) { + const isComponent = target && typeof target.setState === 'function'; + const isFunction = typeof target === 'function'; + + if (!isComponent && !isFunction) { + throw new TypeError( + 'KeyedAutomat.subscribe expects a callback function or a React component instance with a setState method.' + ); + } + + const hasSelector = selector !== undefined && selector !== null; + const getSlice = hasSelector ? resolveSelector(selector, isComponent) : null; + let lastSlice = getSlice ? getSlice(this.state) : undefined; + + const notifyFn = (fullState, partial) => { + if (getSlice) { + const nextSlice = getSlice(fullState); + if (nextSlice == null) return; + if (shallowEqual(lastSlice, nextSlice)) return; + lastSlice = nextSlice; + + if (isComponent) { + if (typeof nextSlice !== 'object') { + throw new TypeError( + 'KeyedAutomat: selector for a React component must return a state object, e.g. state => ({ items: state }).' + ); + } + target.setState(nextSlice); + } else { + target(nextSlice); + } + } else { + if (isComponent) { + target.setState(partial ?? fullState); + } else { + target(fullState); + } + } + }; + + this._globalSubs.set(target, notifyFn); + + return () => { + this._globalSubs.delete(target); + }; + } + + /** + * Disposes the KeyedAutomat, revoking all blob resources and removing all subscribers. + */ + dispose() { + this.clear(); + this._keyedSubs.clear(); + this._globalSubs.clear(); + this._dirtySubs.clear(); + this._keyHandles.clear(); + + if (this._name) { + getGlobalMap('__KEYED_AUTOMATS__')?.delete(this._name); + getGlobalMap('__AUTOMATS__')?.delete(this._name); + } + } + + /** + * @private + */ + _notifyKey(id, itemState) { + // 1. Notify fine-grained per-ID subscribers (O(1)) + const subsForId = this._keyedSubs.get(id); + if (subsForId) { + for (const notifyFn of subsForId.values()) { + try { + notifyFn(itemState); + } catch {} + } + } + + // 2. Notify global dictionary subscribers + if (this._globalSubs.size > 0) { + const fullState = this.state; + const partial = { [id]: itemState }; + for (const notifyFn of this._globalSubs.values()) { + try { + notifyFn(fullState, partial); + } catch {} + } + } + } +} diff --git a/src/lib/index.d.ts b/src/lib/index.d.ts index b4c739e..6d2f036 100644 --- a/src/lib/index.d.ts +++ b/src/lib/index.d.ts @@ -85,6 +85,11 @@ export class Automat> { dispose(): void; static get(name: string): Automat | undefined; + static keyed>( + initialItemState?: T | null, + actions?: A | ((automat: KeyedAutomat) => A), + options?: KeyedAutomatOptions + ): KeyedAutomat; static combine>>( automats: M ): Automat, CombinedActions>; @@ -95,6 +100,82 @@ export class Automat> { ): Automat>; } +export interface ItemState { + data: T | null; + loading: boolean; + loaded: boolean; + error: Error | string | null; +} + +export interface KeyedAutomatOptions { + name?: string | null; + loader?: (id: string | number, automat: KeyedAutomat) => Promise | T; +} + +export class KeyHandle { + readonly id: string | number; + readonly automat: KeyedAutomat; + readonly state: ItemState; + readonly data: T | null; + readonly loading: boolean; + readonly loaded: boolean; + readonly error: Error | string | null; + + load(options?: { force?: boolean }): Promise; + read(): T; + reload(): Promise; + setDirty(keep?: boolean, now?: boolean): Promise; + set(data: T): ItemState; + evict(): void; + subscribe( + target: PureComponent | ((itemState: ItemState) => void), + selector?: Selector, any> + ): () => void; +} + +export class KeyedAutomat> { + constructor( + initialItemState?: T | null, + actions?: A | ((automat: KeyedAutomat) => A), + options?: KeyedAutomatOptions + ); + + readonly name: string | null; + readonly actions: A; + readonly state: Record>; + getState(): Record>; + + key(id: string | number): KeyHandle; + has(id: string | number): boolean; + isLoaded(id: string | number): boolean; + get(id: string | number): ItemState; + getItem(id: string | number): ItemState; + getData(id: string | number): T | null; + + load(id: string | number, options?: { force?: boolean }): Promise; + read(id: string | number): T; + set(id: string | number, data: T): ItemState; + setDirty(id?: string | number, keep?: boolean, now?: boolean): Promise; + reload(id?: string | number): Promise; + evict(id: string | number): void; + clear(): void; + + onDirty(fn: (id: string | number | undefined, automat: this) => void): () => void; + + subscribeKey( + id: string | number, + target: PureComponent | ((itemState: ItemState) => void), + selector?: Selector, any> + ): () => void; + + subscribe( + target: PureComponent | ((state: Record>) => void), + selector?: Selector>, any> + ): () => void; + + dispose(): void; +} + export function fetchJson(url: string, options?: RequestInit): Promise; export function fetchBlob(url: string, options?: RequestInit): Promise; export const fetchImageBlob: typeof fetchBlob; diff --git a/src/lib/index.js b/src/lib/index.js index 288accd..f18227c 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -34,6 +34,7 @@ * } */ export { Automat } from './Automat.js'; +export { KeyedAutomat, KeyHandle } from './KeyedAutomat.js'; export { fetchJson, fetchImageBlob,