Files
automat/src/lib/Automat.js
2026-09-23 17:25:50 +02:00

650 lines
18 KiB
JavaScript

/**
* @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<string, Automat>} 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])
);
}
}