Files
smartstate/ts/smartstate.classes.stateprocess.ts

178 lines
5.8 KiB
TypeScript
Raw Normal View History

import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
import type { StatePart } from './smartstate.classes.statepart.js';
import type { StateAction } from './smartstate.classes.stateaction.js';
export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
export type TAutoPause = 'visibility' | Observable<boolean> | false;
export interface IProcessOptions<TStatePayload, TProducerValue> {
producer: () => Observable<TProducerValue>;
reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
autoPause?: TAutoPause;
autoStart?: boolean;
}
export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
action: StateAction<TStatePayload, TActionPayload>;
payload: TActionPayload;
intervalMs: number;
autoPause?: TAutoPause;
}
/**
* creates an Observable<boolean> from the Page Visibility API.
* emits true when the page is visible, false when hidden.
* in Node.js (no document), returns an always-true observable.
*/
function createVisibilityObservable(): Observable<boolean> {
if (typeof document === 'undefined') {
return of(true);
}
return new Observable<boolean>((subscriber) => {
subscriber.next(!document.hidden);
const handler = () => subscriber.next(!document.hidden);
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
});
}
/**
* a managed, pausable process that ties an observable producer to state updates.
* supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
*/
export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
private readonly producerFn: () => Observable<TProducerValue>;
private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
private readonly autoPauseOption: TAutoPause;
private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
private producerSubscription: Subscription | null = null;
private autoPauseSubscription: Subscription | null = null;
private processingQueue: Promise<void> = Promise.resolve();
constructor(
statePartRef: StatePart<TStatePartName, TStatePayload>,
options: {
producer: () => Observable<TProducerValue>;
reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
sideEffect?: (value: TProducerValue) => Promise<void> | void;
autoPause?: TAutoPause;
}
) {
this.statePartRef = statePartRef;
this.producerFn = options.producer;
this.reducer = options.reducer;
this.sideEffect = options.sideEffect;
this.autoPauseOption = options.autoPause ?? false;
}
public get status(): TProcessStatus {
return this.statusSubject.getValue();
}
public get status$(): Observable<TProcessStatus> {
return this.statusSubject.asObservable();
}
public start(): void {
if (this.status === 'disposed') {
throw new Error('Cannot start a disposed process');
}
if (this.status === 'running') return;
this.statusSubject.next('running');
this.subscribeProducer();
this.setupAutoPause();
}
public pause(): void {
if (this.status === 'disposed') {
throw new Error('Cannot pause a disposed process');
}
if (this.status !== 'running') return;
this.statusSubject.next('paused');
this.unsubscribeProducer();
}
public resume(): void {
if (this.status === 'disposed') {
throw new Error('Cannot resume a disposed process');
}
if (this.status !== 'paused') return;
this.statusSubject.next('running');
this.subscribeProducer();
}
public dispose(): void {
if (this.status === 'disposed') return;
this.unsubscribeProducer();
this.teardownAutoPause();
this.statusSubject.next('disposed');
this.statusSubject.complete();
this.statePartRef._removeProcess(this);
}
private subscribeProducer(): void {
this.unsubscribeProducer();
const source = this.producerFn();
this.producerSubscription = source.subscribe({
next: (value) => {
// Queue value processing to ensure each reads fresh state after the previous completes
this.processingQueue = this.processingQueue.then(async () => {
try {
if (this.sideEffect) {
await this.sideEffect(value);
} else if (this.reducer) {
const currentState = this.statePartRef.getState();
if (currentState !== undefined) {
await this.statePartRef.setState(this.reducer(currentState, value));
}
}
} catch (err) {
console.error('StateProcess value handling error:', err);
}
});
},
error: (err) => {
console.error('StateProcess producer error:', err);
if (this.status === 'running') {
this.statusSubject.next('paused');
this.unsubscribeProducer();
}
},
});
}
private unsubscribeProducer(): void {
if (this.producerSubscription) {
this.producerSubscription.unsubscribe();
this.producerSubscription = null;
}
}
private setupAutoPause(): void {
this.teardownAutoPause();
if (!this.autoPauseOption) return;
const signal$ = this.autoPauseOption === 'visibility'
? createVisibilityObservable()
: this.autoPauseOption;
this.autoPauseSubscription = signal$.subscribe((active) => {
if (!active && this.status === 'running') {
this.pause();
} else if (active && this.status === 'paused') {
this.resume();
}
});
}
private teardownAutoPause(): void {
if (this.autoPauseSubscription) {
this.autoPauseSubscription.unsubscribe();
this.autoPauseSubscription = null;
}
}
}