@push.rocks/smartstate 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
2
+ import type { StatePart } from './smartstate.classes.statepart.js';
3
+ import type { StateAction } from './smartstate.classes.stateaction.js';
4
+
5
+ export type TProcessStatus = 'idle' | 'running' | 'paused' | 'disposed';
6
+ export type TAutoPause = 'visibility' | Observable<boolean> | false;
7
+
8
+ export interface IProcessOptions<TStatePayload, TProducerValue> {
9
+ producer: () => Observable<TProducerValue>;
10
+ reducer: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
11
+ autoPause?: TAutoPause;
12
+ autoStart?: boolean;
13
+ }
14
+
15
+ export interface IScheduledActionOptions<TStatePayload, TActionPayload> {
16
+ action: StateAction<TStatePayload, TActionPayload>;
17
+ payload: TActionPayload;
18
+ intervalMs: number;
19
+ autoPause?: TAutoPause;
20
+ }
21
+
22
+ /**
23
+ * creates an Observable<boolean> from the Page Visibility API.
24
+ * emits true when the page is visible, false when hidden.
25
+ * in Node.js (no document), returns an always-true observable.
26
+ */
27
+ function createVisibilityObservable(): Observable<boolean> {
28
+ if (typeof document === 'undefined') {
29
+ return of(true);
30
+ }
31
+ return new Observable<boolean>((subscriber) => {
32
+ subscriber.next(!document.hidden);
33
+ const handler = () => subscriber.next(!document.hidden);
34
+ document.addEventListener('visibilitychange', handler);
35
+ return () => document.removeEventListener('visibilitychange', handler);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * a managed, pausable process that ties an observable producer to state updates.
41
+ * supports lifecycle management (start/pause/resume/dispose) and auto-pause signals.
42
+ */
43
+ export class StateProcess<TStatePartName, TStatePayload, TProducerValue> {
44
+ private readonly statePartRef: StatePart<TStatePartName, TStatePayload>;
45
+ private readonly producerFn: () => Observable<TProducerValue>;
46
+ private readonly reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
47
+ private readonly sideEffect?: (value: TProducerValue) => Promise<void> | void;
48
+ private readonly autoPauseOption: TAutoPause;
49
+
50
+ private statusSubject = new BehaviorSubject<TProcessStatus>('idle');
51
+ private producerSubscription: Subscription | null = null;
52
+ private autoPauseSubscription: Subscription | null = null;
53
+ private processingQueue: Promise<void> = Promise.resolve();
54
+
55
+ constructor(
56
+ statePartRef: StatePart<TStatePartName, TStatePayload>,
57
+ options: {
58
+ producer: () => Observable<TProducerValue>;
59
+ reducer?: (currentState: TStatePayload, value: TProducerValue) => TStatePayload;
60
+ sideEffect?: (value: TProducerValue) => Promise<void> | void;
61
+ autoPause?: TAutoPause;
62
+ }
63
+ ) {
64
+ this.statePartRef = statePartRef;
65
+ this.producerFn = options.producer;
66
+ this.reducer = options.reducer;
67
+ this.sideEffect = options.sideEffect;
68
+ this.autoPauseOption = options.autoPause ?? false;
69
+ }
70
+
71
+ public get status(): TProcessStatus {
72
+ return this.statusSubject.getValue();
73
+ }
74
+
75
+ public get status$(): Observable<TProcessStatus> {
76
+ return this.statusSubject.asObservable();
77
+ }
78
+
79
+ public start(): void {
80
+ if (this.status === 'disposed') {
81
+ throw new Error('Cannot start a disposed process');
82
+ }
83
+ if (this.status === 'running') return;
84
+ this.statusSubject.next('running');
85
+ this.subscribeProducer();
86
+ this.setupAutoPause();
87
+ }
88
+
89
+ public pause(): void {
90
+ if (this.status === 'disposed') {
91
+ throw new Error('Cannot pause a disposed process');
92
+ }
93
+ if (this.status !== 'running') return;
94
+ this.statusSubject.next('paused');
95
+ this.unsubscribeProducer();
96
+ }
97
+
98
+ public resume(): void {
99
+ if (this.status === 'disposed') {
100
+ throw new Error('Cannot resume a disposed process');
101
+ }
102
+ if (this.status !== 'paused') return;
103
+ this.statusSubject.next('running');
104
+ this.subscribeProducer();
105
+ }
106
+
107
+ public dispose(): void {
108
+ if (this.status === 'disposed') return;
109
+ this.unsubscribeProducer();
110
+ this.teardownAutoPause();
111
+ this.statusSubject.next('disposed');
112
+ this.statusSubject.complete();
113
+ this.statePartRef._removeProcess(this);
114
+ }
115
+
116
+ private subscribeProducer(): void {
117
+ this.unsubscribeProducer();
118
+ const source = this.producerFn();
119
+ this.producerSubscription = source.subscribe({
120
+ next: (value) => {
121
+ // Queue value processing to ensure each reads fresh state after the previous completes
122
+ this.processingQueue = this.processingQueue.then(async () => {
123
+ try {
124
+ if (this.sideEffect) {
125
+ await this.sideEffect(value);
126
+ } else if (this.reducer) {
127
+ const currentState = this.statePartRef.getState();
128
+ if (currentState !== undefined) {
129
+ await this.statePartRef.setState(this.reducer(currentState, value));
130
+ }
131
+ }
132
+ } catch (err) {
133
+ console.error('StateProcess value handling error:', err);
134
+ }
135
+ });
136
+ },
137
+ error: (err) => {
138
+ console.error('StateProcess producer error:', err);
139
+ if (this.status === 'running') {
140
+ this.statusSubject.next('paused');
141
+ this.unsubscribeProducer();
142
+ }
143
+ },
144
+ });
145
+ }
146
+
147
+ private unsubscribeProducer(): void {
148
+ if (this.producerSubscription) {
149
+ this.producerSubscription.unsubscribe();
150
+ this.producerSubscription = null;
151
+ }
152
+ }
153
+
154
+ private setupAutoPause(): void {
155
+ this.teardownAutoPause();
156
+ if (!this.autoPauseOption) return;
157
+
158
+ const signal$ = this.autoPauseOption === 'visibility'
159
+ ? createVisibilityObservable()
160
+ : this.autoPauseOption;
161
+
162
+ this.autoPauseSubscription = signal$.subscribe((active) => {
163
+ if (!active && this.status === 'running') {
164
+ this.pause();
165
+ } else if (active && this.status === 'paused') {
166
+ this.resume();
167
+ }
168
+ });
169
+ }
170
+
171
+ private teardownAutoPause(): void {
172
+ if (this.autoPauseSubscription) {
173
+ this.autoPauseSubscription.unsubscribe();
174
+ this.autoPauseSubscription = null;
175
+ }
176
+ }
177
+ }
File without changes