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 | false; export interface IProcessOptions { producer: () => Observable; reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload; autoPause?: TAutoPause; autoStart?: boolean; } export interface IScheduledActionOptions { action: StateAction; payload: TActionPayload; intervalMs: number; autoPause?: TAutoPause; } /** * creates an Observable 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 { if (typeof document === 'undefined') { return of(true); } return new Observable((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 { private readonly statePartRef: StatePart; private readonly producerFn: () => Observable; private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload; private readonly sideEffect?: (value: TProducerValue) => Promise | void; private readonly autoPauseOption: TAutoPause; private statusSubject = new BehaviorSubject('idle'); private producerSubscription: Subscription | null = null; private autoPauseSubscription: Subscription | null = null; private processingQueue: Promise = Promise.resolve(); constructor( statePartRef: StatePart, options: { producer: () => Observable; reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload; sideEffect?: (value: TProducerValue) => Promise | 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 { 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; } } }