/** * @module Automat * * Higher-order observable state container for React PureComponents with optional * persistence (Window or IndexedDB), backend API binding, cascading invalidation, * and multi-automat combination. */ /** * Performs a shallow equality check between two values or objects. * Used by Automat subscribers to avoid unnecessary setState calls when * a subscribed slice of state has not changed. */ export function shallowEqual(a, b) { if (Object.is(a, b)) return true; if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (let i = 0; i < keysA.length; i++) { const key = keysA[i]; if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) { return false; } } return true; } import { KeyedAutomat } from './KeyedAutomat.js'; // ── Global Window & Registry Aliases ────────────────────────────── const W = typeof window !== 'undefined' ? window : null; export const getGlobalMap = (key) => W && (W[key] ||= new Map()); // ── Unified IndexedDB Storage ───────────────────────────────────── const IDB_NAME = 'automat_db'; const IDB_STORE = 'states'; let idbPromise = null; function getIdb() { if (typeof indexedDB === 'undefined') return Promise.resolve(null); if (!idbPromise) { idbPromise = new Promise((resolve) => { try { const req = indexedDB.open(IDB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); } catch { resolve(null); } }); } return idbPromise; } function idbGet(key) { return getIdb().then((db) => { if (!db) return undefined; return new Promise((resolve) => { try { const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(key); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(undefined); } catch { resolve(undefined); } }); }); } function idbSet(key, value) { return getIdb().then((db) => { if (!db) return; return new Promise((resolve) => { try { const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put(value, key); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); } catch { resolve(); } }); }); } function idbDelete(key) { return getIdb().then((db) => { if (!db) return; return new Promise((resolve) => { try { const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = () => resolve(); tx.onerror = () => resolve(); } catch { resolve(); } }); }); } // ── Selector Normalizer ─────────────────────────────────────────── export function resolveSelector(s, isComponent = false) { if (typeof s === 'function') { return s; } if (typeof s === 'string') { return isComponent ? (state) => (state ? { [s]: state[s] } : state) : (state) => state?.[s]; } if (Array.isArray(s)) { return (state) => { if (!state) return state; const slice = {}; for (let i = 0; i < s.length; i++) { const k = s[i]; slice[k] = state[k]; } return slice; }; } return (state) => state; } export class Automat { constructor(initialState = null, callbacks = {}, options = {}) { const hasInitial = initialState !== null && initialState !== undefined; this._initial = hasInitial ? typeof initialState === 'object' ? { ...initialState } : initialState : null; this._state = this._initial ? { ...this._initial } : null; 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; } this._subs = new Map(); this._dirtySubs = new Set(); this._unsubs = []; this._name = options.name ?? null; this._persist = Boolean(options.persist); this._loader = options.loader ?? null; this._error = null; if (this._name) { getGlobalMap('__AUTOMATS__')?.set(this._name, this); } this._isDirty = false; if (this._loader) { this._ready = this._fetchLoader(); } else if (this._name && this._persist) { this._ready = idbGet(this._name).then((saved) => { if (saved && typeof saved === 'object') { this.setState(saved); } return this._state; }); } else if (this._name && !this._persist) { const saved = getGlobalMap('__AUTOMAT_STATE__')?.get(this._name); if (saved && typeof saved === 'object') { this._state = { ...this._state, ...saved }; } getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); this._ready = Promise.resolve(this._state); } else { this._ready = Promise.resolve(this._state); } } /** * Internal data loader for function-backed automats. * @private */ _fetchLoader() { this._isDirty = false; this._error = null; const promise = Promise.resolve(this._loader(this)) .then((data) => { if (data && typeof data === 'object') { this._state = { ...this._state, ...data }; } else if (data !== undefined) { this._state = data; } this._isDirty = false; this._error = null; if (this._name) { this._persist ? idbSet(this._name, this._state) : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); } this._notify(this._state); return this._state; }) .catch((err) => { this._error = err?.message || String(err); this._notify(this._state); throw err; }); this._ready = promise; return promise; } /** * Marks the automat as dirty and unready. * State resets to initial default or null. * If subscribed anywhere and configured with a loader, triggers loader fetch immediately. */ setDirty(keep = false, now = false) { let shouldKeep = Boolean(keep); let shouldNow = Boolean(now); if (typeof keep === 'object' && keep !== null) { shouldKeep = Boolean(keep.keep); shouldNow = Boolean(keep.now); } if (typeof this._state?.revoke === 'function') { try { this._state.revoke(); } catch {} } this._isDirty = true; if (!shouldKeep) { this._state = this._initial ? { ...this._initial } : null; this._notify(this._state); } if (this._dirtySubs) { for (const fn of this._dirtySubs) { try { fn(this); } catch {} } } if (this._loader && (shouldNow || this._subs.size > 0)) { return this._fetchLoader(); } return Promise.resolve(this._state); } /** * Reads current state for React Suspense or imperative consumers. * If dirty and unmounted, triggers deferred fetch. * If unready, throws the ready promise for React Suspense. */ read() { if (this._loader) { if (this._isDirty) { this._fetchLoader(); } if (this._error) throw this._error; if (this._state === null && this._ready) { throw this._ready; } } return this._state; } /** * Retrieves an Automat instance registered by name. * @param {string} name */ static get(name) { return getGlobalMap('__AUTOMATS__')?.get(name); } /** * Combines multiple automats into one derived automat. * * Supports two signatures: * 1. Array + Combiner function: * Automat.combine([automatA, automatB], (stateA, stateB) => ({ ... }), options) * 2. Object Dictionary: * Automat.combine({ auth: authAutomat, company: companyAutomat }) * * @param {Automat[] | Record} upstream * @param {Function} [combiner] * @param {object} [options] * @returns {Automat} */ static combine(upstream, combiner, options = {}) { if (Array.isArray(upstream)) { if (typeof combiner !== 'function') { throw new TypeError('Automat.combine with array expects a combiner function as second argument.'); } return createOrchestratedCombinedAutomat(upstream, combiner, options); } if (upstream && typeof upstream === 'object') { return new CombinedAutomat(upstream); } 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; } get persist() { return this._persist; } get isPersisted() { return this._persist; } get isDirty() { return this._isDirty; } get error() { return this._error; } get subscriberCount() { return this._subs.size; } get ready() { if (this._loader && this._isDirty) { return this._fetchLoader(); } return this._ready; } async clearPersistence() { if (!this._name) return; this._persist ? await idbDelete(this._name) : getGlobalMap('__AUTOMAT_STATE__')?.delete(this._name); } get state() { if (this._loader && this._isDirty) { this._fetchLoader(); } return this._state; } getState() { return this.state; } /** * Shallow-merges `partial` into current state and notifies all subscribers. */ setState(partial) { const update = typeof partial === 'function' ? partial(this._state) : partial; this._state = { ...this._state, ...update }; if (this._name) { this._persist ? idbSet(this._name, this._state) : getGlobalMap('__AUTOMAT_STATE__')?.set(this._name, this._state); } this._notify(update); return this._state; } /** * Subscribes a React PureComponent instance or a listener callback. * Supports slice subscriptions with string, array, or function selectors. * Uses shallow equality to prevent re-renders when the slice has not changed. * * @param {object|function} target React component (`this`) or callback function. * @param {string|string[]|function} [selector] * @returns {function} Unsubscribe cleanup function. */ subscribe(target, selector) { const isComponent = target && typeof target.setState === 'function'; const isFunction = typeof target === 'function'; if (!isComponent && !isFunction) { throw new TypeError( 'Automat.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 = (state, partial) => { if (getSlice) { const nextSlice = getSlice(state); if (nextSlice == null) return; if (shallowEqual(lastSlice, nextSlice)) return; lastSlice = nextSlice; if (isComponent) { if (typeof nextSlice !== 'object') { throw new TypeError( 'Automat: selector for a React component must return a state object, e.g. state => ({ count: state.count }).' ); } target.setState(nextSlice); } else { target(nextSlice); } } else { if (isComponent) { const update = partial ?? state; update && typeof update === 'object' ? target.setState(update) : target.forceUpdate(); } else { target(state); } } }; this._subs.set(target, notifyFn); if (this._loader && this._isDirty) { this._fetchLoader(); } return () => { this.unsubscribe(target); }; } /** * Slices this automat to a subset of state for reading and subscription. * * @param {string|string[]|function} selector * @returns {{ readonly state: any, subscribe(target: object|function): function }} */ select(selector) { const getSlice = resolveSelector(selector, false); const self = this; return { get state() { return getSlice(self._state); }, subscribe: (target) => self.subscribe(target, selector), }; } unsubscribe(target) { this._subs.delete(target); } /** * Registers a listener called whenever this automat becomes dirty. * @param {(automat: Automat) => void} fn * @returns {() => void} */ onDirty(fn) { this._dirtySubs.add(fn); return () => this._dirtySubs.delete(fn); } /** * Cascading invalidation: automatically sets this automat dirty whenever * upstreamAutomat is set dirty. * * @param {Automat} upstreamAutomat * @param {(upstreamState: any, myState: any) => boolean} [filterFn] * @returns {this} */ invalidateWith(upstreamAutomat, filterFn) { const unsub = upstreamAutomat.onDirty((upstream) => { if (!filterFn || filterFn(upstream.state, this._state)) { this.setDirty(); } }); this._unsubs.push(unsub); return this; } /** * Connects this automat to derive state from an upstream automat. * * @param {Automat} upstreamAutomat * @param {(upstreamState: any, myState: any) => any} transform * @param {object} [options] * @param {boolean | ((upstreamState: any, myState: any) => boolean)} [options.cascadeDirty] * @returns {this} Chainable. */ subscribeTo(upstreamAutomat, transform, options = {}) { const unsub = upstreamAutomat.subscribe((upstreamState) => { const partial = transform(upstreamState, this._state); if (partial != null) { this.setState(partial); } }); this._unsubs.push(unsub); if (options?.cascadeDirty) { this.invalidateWith( upstreamAutomat, typeof options.cascadeDirty === 'function' ? options.cascadeDirty : null ); } return this; } get actions() { return this._cbs; } dispose() { if (typeof this._state?.revoke === 'function') { try { this._state.revoke(); } catch {} } this._unsubs.forEach((fn) => fn()); this._unsubs = []; this._subs.clear(); this._dirtySubs?.clear(); if (this._name) { getGlobalMap('__AUTOMATS__')?.delete(this._name); } } _notify(partial) { for (const notifyFn of this._subs.values()) { notifyFn(this._state, partial); } } } /** * Creates an orchestrated combined Automat from an array of upstreams and combiner function. */ function createOrchestratedCombinedAutomat(upstreamAutomats, combiner, options = {}) { const computeInitial = () => { const upstreamStates = upstreamAutomats.map((a) => a.state); return combiner(...upstreamStates); }; const combined = new Automat( computeInitial(), {}, options ); // Subscribe to all upstreams upstreamAutomats.forEach((upstream) => { combined.subscribeTo(upstream, () => { const upstreamStates = upstreamAutomats.map((a) => a.state); const combinedSlice = combiner(...upstreamStates); combined.setState(combinedSlice); return combinedSlice; }); }); return combined; } /** * Derived aggregate returned by Automat.combine({ auth: authAutomat, company: companyAutomat }). * Disposing it removes aggregate subscriptions but leaves its children alive. */ class CombinedAutomat extends Automat { constructor(automats) { if (!automats || typeof automats !== 'object' || Array.isArray(automats)) { throw new TypeError('Automat.combine expects an object of Automat instances.'); } const entries = Object.entries(automats); for (const [key, automat] of entries) { if (!automat || typeof automat.subscribe !== 'function') { throw new TypeError(`Automat.combine: "${key}" is not an Automat instance.`); } } super( Object.fromEntries(entries.map(([key, automat]) => [key, automat.state])), Object.fromEntries(entries.map(([key, automat]) => [key, automat.actions])) ); this._children = automats; for (const [key, automat] of entries) { this._unsubs.push( automat.subscribe((state) => this.setState({ [key]: state })), automat.onDirty?.(() => { for (const fn of this._dirtySubs) { try { fn(this); } catch {} } }) ); } } get isDirty() { return Object.values(this._children).some((automat) => automat.isDirty); } get error() { return Object.values(this._children).find((automat) => automat.error)?.error ?? null; } get ready() { return Promise.all(Object.values(this._children).map((automat) => automat.ready)).then(() => { this._syncState(); return this._state; }); } read() { this._state = Object.fromEntries( Object.entries(this._children).map(([key, automat]) => [key, automat.read?.() ?? automat.state]) ); return this._state; } setDirty(keep = false, now = false) { return Promise.all(Object.values(this._children).map((automat) => automat.setDirty?.(keep, now))).then(() => { this._syncState(); return this._state; }); } _syncState() { this._state = Object.fromEntries( Object.entries(this._children).map(([key, automat]) => [key, automat.state]) ); } }