feat(stateprocess): add managed state processes with lifecycle controls, scheduled actions, and disposal safety
This commit is contained in:
177
ts/smartstate.classes.stateprocess.ts
Normal file
177
ts/smartstate.classes.stateprocess.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user