Files
smartstate/ts/smartstate.classes.statepart.ts

329 lines
9.8 KiB
TypeScript
Raw Normal View History

2022-03-25 13:31:21 +01:00
import * as plugins from './smartstate.plugins.js';
import { Observable, shareReplay, takeUntil } from 'rxjs';
2023-07-27 15:20:24 +02:00
import { StateAction, type IActionDef } from './smartstate.classes.stateaction.js';
import type { Smartstate } from './smartstate.classes.smartstate.js';
export type TMiddleware<TPayload> = (
newState: TPayload,
oldState: TPayload | undefined,
) => TPayload | Promise<TPayload>;
/**
* creates an Observable that emits once when the given AbortSignal fires
*/
function fromAbortSignal(signal: AbortSignal): Observable<void> {
return new Observable<void>((subscriber) => {
if (signal.aborted) {
subscriber.next();
subscriber.complete();
return;
}
const handler = () => {
subscriber.next();
subscriber.complete();
};
signal.addEventListener('abort', handler);
return () => signal.removeEventListener('abort', handler);
});
}
2020-11-29 23:51:05 +00:00
export class StatePart<TStatePartName, TStatePayload> {
public name: TStatePartName;
public state = new plugins.smartrx.rxjs.Subject<TStatePayload>();
public stateStore: TStatePayload | undefined;
public smartstateRef?: Smartstate<any>;
2023-04-04 20:59:45 +02:00
private cumulativeDeferred = plugins.smartpromise.cumulativeDefer();
2020-11-29 23:51:05 +00:00
private pendingCumulativeNotification: ReturnType<typeof setTimeout> | null = null;
private pendingBatchNotification = false;
2023-10-03 19:19:54 +02:00
private webStoreOptions: plugins.webstore.IWebStoreOptions;
private webStore: plugins.webstore.WebStore<TStatePayload> | null = null;
private middlewares: TMiddleware<TStatePayload>[] = [];
// Selector memoization
private selectorCache = new WeakMap<Function, plugins.smartrx.rxjs.Observable<any>>();
private defaultSelectObservable: plugins.smartrx.rxjs.Observable<TStatePayload> | null = null;
2023-10-03 07:53:28 +02:00
2023-10-03 19:19:54 +02:00
constructor(nameArg: TStatePartName, webStoreOptionsArg?: plugins.webstore.IWebStoreOptions) {
2020-11-29 23:51:05 +00:00
this.name = nameArg;
2023-10-03 07:53:28 +02:00
2023-10-03 19:19:54 +02:00
if (webStoreOptionsArg) {
this.webStoreOptions = webStoreOptionsArg;
2023-10-03 07:53:28 +02:00
}
}
/**
* initializes the webstore
*/
2023-10-03 19:19:54 +02:00
public async init() {
if (this.webStoreOptions) {
this.webStore = new plugins.webstore.WebStore<TStatePayload>(this.webStoreOptions);
2023-10-03 07:53:28 +02:00
await this.webStore.init();
const storedState = await this.webStore.get(String(this.name));
if (storedState && this.validateState(storedState)) {
2023-10-03 07:53:28 +02:00
this.stateStore = storedState;
await this.notifyChange();
2023-10-03 07:53:28 +02:00
}
}
2020-11-29 23:51:05 +00:00
}
/**
* gets the state from the state store
*/
public getState(): TStatePayload | undefined {
2020-11-29 23:51:05 +00:00
return this.stateStore;
}
/**
* adds a middleware that intercepts setState calls.
* middleware can transform the state or throw to reject it.
* returns a removal function.
*/
public addMiddleware(middleware: TMiddleware<TStatePayload>): () => void {
this.middlewares.push(middleware);
return () => {
const idx = this.middlewares.indexOf(middleware);
if (idx !== -1) {
this.middlewares.splice(idx, 1);
}
};
}
2020-11-29 23:51:05 +00:00
/**
* sets the stateStore to the new state
*/
2023-10-03 07:53:28 +02:00
public async setState(newStateArg: TStatePayload) {
// Run middleware chain
let processedState = newStateArg;
for (const mw of this.middlewares) {
processedState = await mw(processedState, this.stateStore);
}
// Validate state structure
if (!this.validateState(processedState)) {
throw new Error(`Invalid state structure for state part '${this.name}'`);
}
// Save to WebStore first to ensure atomicity
2023-10-03 07:53:28 +02:00
if (this.webStore) {
await this.webStore.set(String(this.name), processedState);
2023-10-03 07:53:28 +02:00
}
// Update in-memory state after successful persistence
this.stateStore = processedState;
await this.notifyChange();
2023-10-07 12:23:03 +02:00
return this.stateStore;
2020-11-29 23:51:05 +00:00
}
/**
* Validates state structure - can be overridden for custom validation
*/
protected validateState(stateArg: any): stateArg is TStatePayload {
return stateArg !== null && stateArg !== undefined;
}
2020-11-29 23:51:05 +00:00
/**
* notifies of a change on the state
*/
public async notifyChange() {
if (!this.stateStore) {
return;
}
// If inside a batch, defer the notification
if (this.smartstateRef?.isBatching) {
this.pendingBatchNotification = true;
this.smartstateRef.registerPendingNotification(this);
return;
}
const createStateHash = async (stateArg: any) => {
return await plugins.smarthashWeb.sha256FromString(plugins.smartjson.stableOneWayStringify(stateArg));
2022-01-24 06:39:36 +01:00
};
const currentHash = await createStateHash(this.stateStore);
2023-04-04 21:44:16 +02:00
if (
this.lastStateNotificationPayloadHash &&
currentHash === this.lastStateNotificationPayloadHash
2023-04-04 21:44:16 +02:00
) {
2022-01-24 06:39:36 +01:00
return;
} else {
this.lastStateNotificationPayloadHash = currentHash;
2022-01-24 06:39:36 +01:00
}
2020-11-29 23:51:05 +00:00
this.state.next(this.stateStore);
}
2022-01-24 06:39:36 +01:00
private lastStateNotificationPayloadHash: any;
2020-11-29 23:51:05 +00:00
2023-04-13 14:22:31 +02:00
/**
* creates a cumulative notification by adding a change notification at the end of the call stack
2023-04-13 14:22:31 +02:00
*/
public notifyChangeCumulative() {
if (this.pendingCumulativeNotification) {
clearTimeout(this.pendingCumulativeNotification);
}
this.pendingCumulativeNotification = setTimeout(async () => {
this.pendingCumulativeNotification = null;
if (this.stateStore) {
await this.notifyChange();
}
}, 0);
2023-04-13 14:22:31 +02:00
}
2020-11-29 23:51:05 +00:00
/**
* selects a state or a substate.
* supports an optional AbortSignal for automatic unsubscription.
* memoizes observables by selector function reference.
2020-11-29 23:51:05 +00:00
*/
public select<T = TStatePayload>(
selectorFn?: (state: TStatePayload) => T,
options?: { signal?: AbortSignal }
2020-11-29 23:51:05 +00:00
): plugins.smartrx.rxjs.Observable<T> {
const hasSignal = options?.signal != null;
// Check memoization cache (only for non-signal selects)
if (!hasSignal) {
if (!selectorFn) {
if (this.defaultSelectObservable) {
return this.defaultSelectObservable as unknown as plugins.smartrx.rxjs.Observable<T>;
}
} else if (this.selectorCache.has(selectorFn)) {
return this.selectorCache.get(selectorFn)!;
}
2020-11-29 23:51:05 +00:00
}
const effectiveSelectorFn = selectorFn || ((state: TStatePayload) => <T>(<any>state));
let mapped = this.state.pipe(
2020-11-29 23:51:05 +00:00
plugins.smartrx.rxjs.ops.startWith(this.getState()),
plugins.smartrx.rxjs.ops.filter((stateArg): stateArg is TStatePayload => stateArg !== undefined),
2020-11-29 23:51:05 +00:00
plugins.smartrx.rxjs.ops.map((stateArg) => {
try {
return effectiveSelectorFn(stateArg);
2020-11-29 23:51:05 +00:00
} catch (e) {
console.error(`Selector error in state part '${this.name}':`, e);
return undefined;
2020-11-29 23:51:05 +00:00
}
})
);
if (hasSignal) {
mapped = mapped.pipe(takeUntil(fromAbortSignal(options.signal)));
return mapped;
}
// Apply shareReplay for caching and store in memo cache
const shared = mapped.pipe(shareReplay({ bufferSize: 1, refCount: true }));
if (!selectorFn) {
this.defaultSelectObservable = shared as unknown as plugins.smartrx.rxjs.Observable<TStatePayload>;
} else {
this.selectorCache.set(selectorFn, shared);
}
return shared;
2020-11-29 23:51:05 +00:00
}
/**
* creates an action capable of modifying the state
*/
public createAction<TActionPayload>(
actionDef: IActionDef<TStatePayload, TActionPayload>
): StateAction<TStatePayload, TActionPayload> {
return new StateAction(this, actionDef);
}
/**
* dispatches an action on the statepart level
*/
2023-10-03 12:47:12 +02:00
public async dispatchAction<T>(stateAction: StateAction<TStatePayload, T>, actionPayload: T): Promise<TStatePayload> {
2023-04-04 20:59:45 +02:00
await this.cumulativeDeferred.promise;
2020-11-29 23:51:05 +00:00
const newState = await stateAction.actionDef(this, actionPayload);
2023-10-03 12:47:12 +02:00
await this.setState(newState);
return this.getState();
2020-11-29 23:51:05 +00:00
}
/**
* waits until a certain part of the state becomes available.
* supports optional timeout and AbortSignal.
2020-11-29 23:51:05 +00:00
*/
public async waitUntilPresent<T = TStatePayload>(
selectorFn?: (state: TStatePayload) => T,
optionsOrTimeout?: number | { timeoutMs?: number; signal?: AbortSignal }
2020-11-29 23:51:05 +00:00
): Promise<T> {
// Parse backward-compatible args
let timeoutMs: number | undefined;
let signal: AbortSignal | undefined;
if (typeof optionsOrTimeout === 'number') {
timeoutMs = optionsOrTimeout;
} else if (optionsOrTimeout) {
timeoutMs = optionsOrTimeout.timeoutMs;
signal = optionsOrTimeout.signal;
}
2020-11-29 23:51:05 +00:00
const done = plugins.smartpromise.defer<T>();
const selectedObservable = this.select(selectorFn);
let resolved = false;
// Check if already aborted
if (signal?.aborted) {
throw new Error('Aborted');
}
const subscription = selectedObservable.subscribe((value) => {
if (value && !resolved) {
resolved = true;
2020-11-29 23:51:05 +00:00
done.resolve(value);
}
});
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timeoutMs) {
timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true;
subscription.unsubscribe();
done.reject(new Error(`waitUntilPresent timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
}
// Handle abort signal
const abortHandler = signal ? () => {
if (!resolved) {
resolved = true;
subscription.unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
done.reject(new Error('Aborted'));
}
} : undefined;
if (signal && abortHandler) {
signal.addEventListener('abort', abortHandler);
}
try {
const result = await done.promise;
return result;
} finally {
subscription.unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
}
2020-11-29 23:51:05 +00:00
}
2023-04-04 20:59:45 +02:00
/**
2023-04-04 21:44:16 +02:00
* is executed
2023-04-04 20:59:45 +02:00
*/
2023-04-12 20:34:34 +02:00
public async stateSetup(
funcArg: (statePartArg?: StatePart<any, TStatePayload>) => Promise<TStatePayload>
2023-04-04 21:44:16 +02:00
) {
2023-04-12 20:34:34 +02:00
const resultPromise = funcArg(this);
this.cumulativeDeferred.addPromise(resultPromise);
await this.setState(await resultPromise);
2023-04-04 20:59:45 +02:00
}
2020-11-29 23:51:05 +00:00
}