@futdevpro/fsm-dynamo 1.15.11 → 1.15.12
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.
- package/build/_modules/state-machine/_models/state-machine-config.interface.d.ts +19 -0
- package/build/_modules/state-machine/_models/state-machine-config.interface.d.ts.map +1 -0
- package/build/_modules/state-machine/_models/state-machine-config.interface.js +3 -0
- package/build/_modules/state-machine/_models/state-machine-config.interface.js.map +1 -0
- package/build/_modules/state-machine/_models/state-machine-event.interface.d.ts +26 -0
- package/build/_modules/state-machine/_models/state-machine-event.interface.d.ts.map +1 -0
- package/build/_modules/state-machine/_models/state-machine-event.interface.js +3 -0
- package/build/_modules/state-machine/_models/state-machine-event.interface.js.map +1 -0
- package/build/_modules/state-machine/_models/state-machine.control-model.d.ts +113 -0
- package/build/_modules/state-machine/_models/state-machine.control-model.d.ts.map +1 -0
- package/build/_modules/state-machine/_models/state-machine.control-model.js +264 -0
- package/build/_modules/state-machine/_models/state-machine.control-model.js.map +1 -0
- package/build/_modules/state-machine/_models/state-transition-result.interface.d.ts +40 -0
- package/build/_modules/state-machine/_models/state-transition-result.interface.d.ts.map +1 -0
- package/build/_modules/state-machine/_models/state-transition-result.interface.js +3 -0
- package/build/_modules/state-machine/_models/state-transition-result.interface.js.map +1 -0
- package/build/_modules/state-machine/_models/state-transition.interface.d.ts +31 -0
- package/build/_modules/state-machine/_models/state-transition.interface.d.ts.map +1 -0
- package/build/_modules/state-machine/_models/state-transition.interface.js +3 -0
- package/build/_modules/state-machine/_models/state-transition.interface.js.map +1 -0
- package/build/_modules/state-machine/index.d.ts +6 -0
- package/build/_modules/state-machine/index.d.ts.map +1 -0
- package/build/_modules/state-machine/index.js +10 -0
- package/build/_modules/state-machine/index.js.map +1 -0
- package/package.json +10 -1
- package/src/_modules/state-machine/_models/state-machine-config.interface.ts +19 -0
- package/src/_modules/state-machine/_models/state-machine-event.interface.ts +25 -0
- package/src/_modules/state-machine/_models/state-machine.control-model.spec.ts +314 -0
- package/src/_modules/state-machine/_models/state-machine.control-model.ts +298 -0
- package/src/_modules/state-machine/_models/state-transition-result.interface.ts +53 -0
- package/src/_modules/state-machine/_models/state-transition.interface.ts +30 -0
- package/src/_modules/state-machine/index.ts +6 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Egy sikeres atmenet utan kibocsatott esemeny.
|
|
3
|
+
*
|
|
4
|
+
* A `DyFM_StateMachine.events$` Observable-en at szubszkribalhato. Az opcionalis
|
|
5
|
+
* `persist` callback is ezt az objektumot kapja meg, igy a konzumens (pl.
|
|
6
|
+
* Mongoose ControlModel) eldontheti milyen mezoket frissit.
|
|
7
|
+
*
|
|
8
|
+
* `stateVersion` minden sikeres atmenet utan inkrementalodik (0 -> 1 -> 2 -> ...).
|
|
9
|
+
* Optimistic-concurrency token-kent hasznalhato a `persist` callback-ben
|
|
10
|
+
* (`$inc: { stateVersion: 1 }` + `findOneAndUpdate({ stateVersion: prevVersion })`
|
|
11
|
+
* mintara) hogy a race conditions detektalhatoak legyenek tobb-folyamatos
|
|
12
|
+
* deploy-okban.
|
|
13
|
+
*/
|
|
14
|
+
export interface DyFM_StateMachineEvent<TState extends string, TContext = unknown> {
|
|
15
|
+
/** Az atmenet elotti state. */
|
|
16
|
+
from: TState;
|
|
17
|
+
/** Az atmenet utani state. */
|
|
18
|
+
to: TState;
|
|
19
|
+
/** A frissitett state-version szam (>= 1). */
|
|
20
|
+
stateVersion: number;
|
|
21
|
+
/** Az opcionalis context, ahogy a `transition()`-be erkezett. */
|
|
22
|
+
context?: TContext;
|
|
23
|
+
/** Az atmenet idobelyege (ms epoch). */
|
|
24
|
+
timestamp: number;
|
|
25
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { firstValueFrom, take, toArray } from 'rxjs';
|
|
2
|
+
|
|
3
|
+
import { DyFM_StateMachine } from './state-machine.control-model';
|
|
4
|
+
import { DyFM_StateMachineEvent } from './state-machine-event.interface';
|
|
5
|
+
import {
|
|
6
|
+
DyFM_StateMachineTransition_FailureResult,
|
|
7
|
+
DyFM_StateMachineTransition_Result,
|
|
8
|
+
DyFM_StateMachineTransition_SuccessResult,
|
|
9
|
+
} from './state-transition-result.interface';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/** Test state-enum a RAG ingest mintabol. */
|
|
13
|
+
enum TestRagState {
|
|
14
|
+
Idle = 'idle',
|
|
15
|
+
Ingesting = 'ingesting',
|
|
16
|
+
Embedding = 'embedding',
|
|
17
|
+
Ready = 'ready',
|
|
18
|
+
Degraded = 'degraded',
|
|
19
|
+
Reingesting = 'reingesting',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default test-config helper. */
|
|
23
|
+
const buildBasicConfig = () => ({
|
|
24
|
+
initial: TestRagState.Idle,
|
|
25
|
+
transitions: [
|
|
26
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting },
|
|
27
|
+
{ from: TestRagState.Ingesting, to: TestRagState.Embedding },
|
|
28
|
+
{ from: TestRagState.Embedding, to: TestRagState.Ready },
|
|
29
|
+
{ from: TestRagState.Ready, to: TestRagState.Degraded },
|
|
30
|
+
{ from: TestRagState.Degraded, to: TestRagState.Reingesting },
|
|
31
|
+
{ from: TestRagState.Reingesting, to: TestRagState.Ready },
|
|
32
|
+
// Wildcard array → degraded from any "working" state
|
|
33
|
+
{
|
|
34
|
+
from: [TestRagState.Ingesting, TestRagState.Embedding, TestRagState.Reingesting] as TestRagState[],
|
|
35
|
+
to: TestRagState.Degraded,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
describe('| DyFM_StateMachine | constructor + sync getters', () => {
|
|
42
|
+
it('| initial state is set from config.initial', () => {
|
|
43
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
44
|
+
expect(sm.state()).toBe(TestRagState.Idle);
|
|
45
|
+
expect(sm.stateVersion()).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('| throws on missing config', () => {
|
|
49
|
+
expect(() => new DyFM_StateMachine<TestRagState>(undefined as any)).toThrowError(/config object required/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('| throws on missing config.initial', () => {
|
|
53
|
+
expect(() => new DyFM_StateMachine<TestRagState>({ transitions: [] } as any)).toThrowError(/initial must be a string/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('| throws on empty transitions array', () => {
|
|
57
|
+
expect(() => new DyFM_StateMachine<TestRagState>({ initial: TestRagState.Idle, transitions: [] })).toThrowError(/non-empty array/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
describe('| DyFM_StateMachine | state$ observable', () => {
|
|
63
|
+
it('| emits initial value on subscribe (BehaviorSubject pattern)', async () => {
|
|
64
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
65
|
+
const firstValue: TestRagState = await firstValueFrom(sm.state$);
|
|
66
|
+
expect(firstValue).toBe(TestRagState.Idle);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('| emits new state after successful transition', async () => {
|
|
70
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
71
|
+
const next$: Promise<TestRagState[]> = firstValueFrom(sm.state$.pipe(take(2), toArray()));
|
|
72
|
+
await sm.transition(TestRagState.Ingesting);
|
|
73
|
+
const emitted: TestRagState[] = await next$;
|
|
74
|
+
expect(emitted).toEqual([TestRagState.Idle, TestRagState.Ingesting]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
describe('| DyFM_StateMachine | canTransition', () => {
|
|
80
|
+
it('| true on valid transition path', async () => {
|
|
81
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
82
|
+
expect(await sm.canTransition(TestRagState.Ingesting)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('| false on invalid transition (no matching from→to)', async () => {
|
|
86
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
87
|
+
// Idle → Ready is NOT in the transition map
|
|
88
|
+
expect(await sm.canTransition(TestRagState.Ready)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('| false when guard returns false', async () => {
|
|
92
|
+
const sm = new DyFM_StateMachine<TestRagState, { allow: boolean }>({
|
|
93
|
+
initial: TestRagState.Idle,
|
|
94
|
+
transitions: [
|
|
95
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting, guard: (ctx) => ctx?.allow === true },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
expect(await sm.canTransition(TestRagState.Ingesting, { allow: false })).toBe(false);
|
|
99
|
+
expect(await sm.canTransition(TestRagState.Ingesting, { allow: true })).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
describe('| DyFM_StateMachine | transition — happy paths', () => {
|
|
105
|
+
it('| basic ok flow: state changes, stateVersion++, ok result', async () => {
|
|
106
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
107
|
+
const result: DyFM_StateMachineTransition_Result<TestRagState> = await sm.transition(TestRagState.Ingesting);
|
|
108
|
+
expect(result.ok).toBe(true);
|
|
109
|
+
if (result.ok) {
|
|
110
|
+
expect(result.from).toBe(TestRagState.Idle);
|
|
111
|
+
expect(result.to).toBe(TestRagState.Ingesting);
|
|
112
|
+
expect(result.stateVersion).toBe(1);
|
|
113
|
+
}
|
|
114
|
+
expect(sm.state()).toBe(TestRagState.Ingesting);
|
|
115
|
+
expect(sm.stateVersion()).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('| full happy-path through ingest → ready', async () => {
|
|
119
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
120
|
+
await sm.transition(TestRagState.Ingesting);
|
|
121
|
+
await sm.transition(TestRagState.Embedding);
|
|
122
|
+
const r3: DyFM_StateMachineTransition_Result<TestRagState> = await sm.transition(TestRagState.Ready);
|
|
123
|
+
expect(r3.ok).toBe(true);
|
|
124
|
+
expect(sm.state()).toBe(TestRagState.Ready);
|
|
125
|
+
expect(sm.stateVersion()).toBe(3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('| wildcard `*` from matches any current state', async () => {
|
|
129
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
130
|
+
initial: TestRagState.Idle,
|
|
131
|
+
transitions: [
|
|
132
|
+
{ from: '*', to: TestRagState.Degraded },
|
|
133
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting },
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
// Idle → Degraded via wildcard
|
|
137
|
+
const r1 = await sm.transition(TestRagState.Degraded);
|
|
138
|
+
expect(r1.ok).toBe(true);
|
|
139
|
+
expect(sm.state()).toBe(TestRagState.Degraded);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('| array-from matches multiple sources', async () => {
|
|
143
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
144
|
+
await sm.transition(TestRagState.Ingesting);
|
|
145
|
+
// [Ingesting, Embedding, Reingesting] → Degraded
|
|
146
|
+
const r: DyFM_StateMachineTransition_Result<TestRagState> = await sm.transition(TestRagState.Degraded);
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
expect(sm.state()).toBe(TestRagState.Degraded);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
describe('| DyFM_StateMachine | transition — failure paths', () => {
|
|
154
|
+
it('| invalid-transition reason when no matching transition', async () => {
|
|
155
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
156
|
+
// Idle → Ready is not in the map
|
|
157
|
+
const result: DyFM_StateMachineTransition_Result<TestRagState> = await sm.transition(TestRagState.Ready);
|
|
158
|
+
expect(result.ok).toBe(false);
|
|
159
|
+
const f: DyFM_StateMachineTransition_FailureResult<TestRagState> = result as DyFM_StateMachineTransition_FailureResult<TestRagState>;
|
|
160
|
+
expect(f.reason).toBe('invalid-transition');
|
|
161
|
+
expect(f.currentState).toBe(TestRagState.Idle);
|
|
162
|
+
expect(f.attemptedTo).toBe(TestRagState.Ready);
|
|
163
|
+
expect(sm.state()).toBe(TestRagState.Idle);
|
|
164
|
+
expect(sm.stateVersion()).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('| guard-rejected reason when guard returns false; state unchanged', async () => {
|
|
168
|
+
const sm = new DyFM_StateMachine<TestRagState, { allow: boolean }>({
|
|
169
|
+
initial: TestRagState.Idle,
|
|
170
|
+
transitions: [
|
|
171
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting, guard: (ctx) => ctx?.allow === true },
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
const result = await sm.transition(TestRagState.Ingesting, { allow: false });
|
|
175
|
+
expect(result.ok).toBe(false);
|
|
176
|
+
const f: DyFM_StateMachineTransition_FailureResult<TestRagState> = result as DyFM_StateMachineTransition_FailureResult<TestRagState>;
|
|
177
|
+
expect(f.reason).toBe('guard-rejected');
|
|
178
|
+
expect(sm.state()).toBe(TestRagState.Idle);
|
|
179
|
+
expect(sm.stateVersion()).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('| onLeave called BEFORE state change', async () => {
|
|
183
|
+
let stateAtOnLeave: TestRagState | null = null;
|
|
184
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
185
|
+
initial: TestRagState.Idle,
|
|
186
|
+
transitions: [
|
|
187
|
+
{
|
|
188
|
+
from: TestRagState.Idle,
|
|
189
|
+
to: TestRagState.Ingesting,
|
|
190
|
+
onLeave: async () => { stateAtOnLeave = sm.state(); },
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
await sm.transition(TestRagState.Ingesting);
|
|
195
|
+
expect(stateAtOnLeave).toBe(TestRagState.Idle);
|
|
196
|
+
expect(sm.state()).toBe(TestRagState.Ingesting);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('| onEnter called AFTER state change, BEFORE persist', async () => {
|
|
200
|
+
let stateAtOnEnter: TestRagState | null = null;
|
|
201
|
+
let stateAtPersist: TestRagState | null = null;
|
|
202
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
203
|
+
initial: TestRagState.Idle,
|
|
204
|
+
transitions: [
|
|
205
|
+
{
|
|
206
|
+
from: TestRagState.Idle,
|
|
207
|
+
to: TestRagState.Ingesting,
|
|
208
|
+
onEnter: async () => { stateAtOnEnter = sm.state(); },
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
persist: async () => { stateAtPersist = sm.state(); },
|
|
212
|
+
});
|
|
213
|
+
await sm.transition(TestRagState.Ingesting);
|
|
214
|
+
expect(stateAtOnEnter).toBe(TestRagState.Ingesting);
|
|
215
|
+
expect(stateAtPersist).toBe(TestRagState.Ingesting);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('| onEnter throw → state reverts + on-enter-failed reason', async () => {
|
|
219
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
220
|
+
initial: TestRagState.Idle,
|
|
221
|
+
transitions: [
|
|
222
|
+
{
|
|
223
|
+
from: TestRagState.Idle,
|
|
224
|
+
to: TestRagState.Ingesting,
|
|
225
|
+
onEnter: async () => { throw new Error('boom-onEnter'); },
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
const result: DyFM_StateMachineTransition_Result<TestRagState> = await sm.transition(TestRagState.Ingesting);
|
|
230
|
+
expect(result.ok).toBe(false);
|
|
231
|
+
const f1: DyFM_StateMachineTransition_FailureResult<TestRagState> = result as DyFM_StateMachineTransition_FailureResult<TestRagState>;
|
|
232
|
+
expect(f1.reason).toBe('on-enter-failed');
|
|
233
|
+
expect(String((f1.error as Error)?.message ?? '')).toContain('boom-onEnter');
|
|
234
|
+
expect(sm.state()).toBe(TestRagState.Idle); // reverted
|
|
235
|
+
expect(sm.stateVersion()).toBe(0); // reverted
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('| persist throw → state reverts + persist-failed reason', async () => {
|
|
239
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
240
|
+
initial: TestRagState.Idle,
|
|
241
|
+
transitions: [
|
|
242
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting },
|
|
243
|
+
],
|
|
244
|
+
persist: async () => { throw new Error('boom-persist'); },
|
|
245
|
+
});
|
|
246
|
+
const result = await sm.transition(TestRagState.Ingesting);
|
|
247
|
+
expect(result.ok).toBe(false);
|
|
248
|
+
const f2: DyFM_StateMachineTransition_FailureResult<TestRagState> = result as DyFM_StateMachineTransition_FailureResult<TestRagState>;
|
|
249
|
+
expect(f2.reason).toBe('persist-failed');
|
|
250
|
+
expect(String((f2.error as Error)?.message ?? '')).toContain('boom-persist');
|
|
251
|
+
expect(sm.state()).toBe(TestRagState.Idle); // reverted
|
|
252
|
+
expect(sm.stateVersion()).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('| onLeave throw → state NOT changed + on-leave-failed reason', async () => {
|
|
256
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
257
|
+
initial: TestRagState.Idle,
|
|
258
|
+
transitions: [
|
|
259
|
+
{
|
|
260
|
+
from: TestRagState.Idle,
|
|
261
|
+
to: TestRagState.Ingesting,
|
|
262
|
+
onLeave: async () => { throw new Error('boom-onLeave'); },
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const result = await sm.transition(TestRagState.Ingesting);
|
|
267
|
+
expect(result.ok).toBe(false);
|
|
268
|
+
const f3: DyFM_StateMachineTransition_FailureResult<TestRagState> = result as DyFM_StateMachineTransition_FailureResult<TestRagState>;
|
|
269
|
+
expect(f3.reason).toBe('on-leave-failed');
|
|
270
|
+
expect(sm.state()).toBe(TestRagState.Idle); // never changed
|
|
271
|
+
expect(sm.stateVersion()).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
describe('| DyFM_StateMachine | events$ + mutex', () => {
|
|
277
|
+
it('| events$ emits on successful transition', async () => {
|
|
278
|
+
const sm = new DyFM_StateMachine<TestRagState>(buildBasicConfig());
|
|
279
|
+
const event$: Promise<DyFM_StateMachineEvent<TestRagState>> = firstValueFrom(sm.events$);
|
|
280
|
+
await sm.transition(TestRagState.Ingesting);
|
|
281
|
+
const event = await event$;
|
|
282
|
+
expect(event.from).toBe(TestRagState.Idle);
|
|
283
|
+
expect(event.to).toBe(TestRagState.Ingesting);
|
|
284
|
+
expect(event.stateVersion).toBe(1);
|
|
285
|
+
expect(typeof event.timestamp).toBe('number');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('| concurrent transitions are serialized (mutex)', async () => {
|
|
289
|
+
let inflightCount: number = 0;
|
|
290
|
+
let maxInflight: number = 0;
|
|
291
|
+
const sm = new DyFM_StateMachine<TestRagState>({
|
|
292
|
+
initial: TestRagState.Idle,
|
|
293
|
+
transitions: [
|
|
294
|
+
{ from: TestRagState.Idle, to: TestRagState.Ingesting,
|
|
295
|
+
onEnter: async (): Promise<void> => {
|
|
296
|
+
inflightCount++;
|
|
297
|
+
if (inflightCount > maxInflight) { maxInflight = inflightCount; }
|
|
298
|
+
await new Promise((r): void => { setTimeout(r, 10); });
|
|
299
|
+
inflightCount--;
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
{ from: TestRagState.Ingesting, to: TestRagState.Idle },
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
// Fire 3 transitions concurrently — they must serialize
|
|
306
|
+
await Promise.all([
|
|
307
|
+
sm.transition(TestRagState.Ingesting),
|
|
308
|
+
sm.transition(TestRagState.Idle),
|
|
309
|
+
sm.transition(TestRagState.Ingesting),
|
|
310
|
+
]);
|
|
311
|
+
expect(maxInflight).toBe(1);
|
|
312
|
+
expect(sm.stateVersion()).toBe(3);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|
2
|
+
|
|
3
|
+
import { DyFM_StateMachineConfig } from './state-machine-config.interface';
|
|
4
|
+
import { DyFM_StateMachineEvent } from './state-machine-event.interface';
|
|
5
|
+
import { DyFM_StateTransition } from './state-transition.interface';
|
|
6
|
+
import {
|
|
7
|
+
DyFM_StateMachineTransition_FailureReason,
|
|
8
|
+
DyFM_StateMachineTransition_Result,
|
|
9
|
+
} from './state-transition-result.interface';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generic finite state machine (FR-006, BL-20260518-004).
|
|
14
|
+
*
|
|
15
|
+
* Konfiguralhato `initial` state-tel + `transitions[]` listaval. Reaktiv
|
|
16
|
+
* (`state$` BehaviorSubject + `events$` Subject), single-instance mutex-szel
|
|
17
|
+
* vedett, opcionalis aszinkron `persist` callback-kel.
|
|
18
|
+
*
|
|
19
|
+
* **Hasznalat (CCAP MP1-D RAG ingest minta):**
|
|
20
|
+
* ```typescript
|
|
21
|
+
* enum RagIngestState { Idle='idle', Ingesting='ingesting', Embedding='embedding',
|
|
22
|
+
* Ready='ready', Degraded='degraded', Reingesting='reingesting' }
|
|
23
|
+
*
|
|
24
|
+
* const ragFsm = new DyFM_StateMachine<RagIngestState>({
|
|
25
|
+
* initial: RagIngestState.Idle,
|
|
26
|
+
* transitions: [
|
|
27
|
+
* { from: RagIngestState.Idle, to: RagIngestState.Ingesting },
|
|
28
|
+
* { from: RagIngestState.Ingesting, to: RagIngestState.Embedding,
|
|
29
|
+
* guard: ctx => ctx?.pendingChunks === 0 },
|
|
30
|
+
* { from: RagIngestState.Embedding, to: RagIngestState.Ready,
|
|
31
|
+
* onEnter: () => persistCorpusVersion() },
|
|
32
|
+
* { from: RagIngestState.Ready, to: RagIngestState.Degraded },
|
|
33
|
+
* { from: RagIngestState.Degraded, to: RagIngestState.Reingesting },
|
|
34
|
+
* // Wildcard array: barmely "futasi" allapotbol degraded-re
|
|
35
|
+
* { from: [RagIngestState.Ingesting, RagIngestState.Embedding,
|
|
36
|
+
* RagIngestState.Reingesting], to: RagIngestState.Degraded },
|
|
37
|
+
* ],
|
|
38
|
+
* persist: async event => await mongoCollection.updateOne(
|
|
39
|
+
* { _id: 'rag-status', stateVersion: event.stateVersion - 1 },
|
|
40
|
+
* { $set: { state: event.to, stateVersion: event.stateVersion } },
|
|
41
|
+
* ),
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* const result = await ragFsm.transition(RagIngestState.Ingesting);
|
|
45
|
+
* if (result.ok) { console.log('Now in:', result.to, 'v:', result.stateVersion); }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* **Atomicity:** Az `onEnter` vagy `persist` callback throw-ja eseten a state
|
|
49
|
+
* automatikusan visszaall a from-ra (revert). Ha az `onLeave` throw-ol, a
|
|
50
|
+
* state-valtas mar el sem kezdodik (early-return).
|
|
51
|
+
*
|
|
52
|
+
* **Mutex:** Egy-instance-en belul a `transition()` hivasok sorosak (in-memory
|
|
53
|
+
* Promise-queue). Tobb-instance / tobb-folyamatos race-condition-re az
|
|
54
|
+
* opcionalis `persist` callback `stateVersion`-t hasznalja optimistic-locking-hez.
|
|
55
|
+
*/
|
|
56
|
+
export class DyFM_StateMachine<TState extends string, TContext = unknown> {
|
|
57
|
+
|
|
58
|
+
/** Aktualis state. */
|
|
59
|
+
private _state: TState;
|
|
60
|
+
|
|
61
|
+
/** Aktualis state-version (0 = initial; minden sikeres atmenet utan +1). */
|
|
62
|
+
private _stateVersion: number = 0;
|
|
63
|
+
|
|
64
|
+
/** Reactive state-source. */
|
|
65
|
+
private readonly _state$: BehaviorSubject<TState>;
|
|
66
|
+
|
|
67
|
+
/** Public reactive state observable. */
|
|
68
|
+
readonly state$: Observable<TState>;
|
|
69
|
+
|
|
70
|
+
/** Reactive event-stream — minden sikeres atmenet utan emittal. */
|
|
71
|
+
private readonly _events$: Subject<DyFM_StateMachineEvent<TState, TContext>>;
|
|
72
|
+
|
|
73
|
+
/** Public reactive event observable. */
|
|
74
|
+
readonly events$: Observable<DyFM_StateMachineEvent<TState, TContext>>;
|
|
75
|
+
|
|
76
|
+
/** Config snapshot. */
|
|
77
|
+
private readonly _config: DyFM_StateMachineConfig<TState, TContext>;
|
|
78
|
+
|
|
79
|
+
/** Mutex tail — soros transition() hivasokhoz. */
|
|
80
|
+
private _mutexTail: Promise<unknown> = Promise.resolve();
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
constructor(config: DyFM_StateMachineConfig<TState, TContext>) {
|
|
84
|
+
if (!config || typeof config !== 'object') {
|
|
85
|
+
throw new Error('DyFM_StateMachine: config object required');
|
|
86
|
+
}
|
|
87
|
+
if (typeof config.initial !== 'string') {
|
|
88
|
+
throw new Error('DyFM_StateMachine: config.initial must be a string state value');
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(config.transitions) || config.transitions.length === 0) {
|
|
91
|
+
throw new Error('DyFM_StateMachine: config.transitions must be a non-empty array');
|
|
92
|
+
}
|
|
93
|
+
this._config = config;
|
|
94
|
+
this._state = config.initial;
|
|
95
|
+
this._state$ = new BehaviorSubject<TState>(this._state);
|
|
96
|
+
this.state$ = this._state$.asObservable();
|
|
97
|
+
this._events$ = new Subject<DyFM_StateMachineEvent<TState, TContext>>();
|
|
98
|
+
this.events$ = this._events$.asObservable();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sync getter — az aktualis state.
|
|
104
|
+
*/
|
|
105
|
+
state(): TState {
|
|
106
|
+
return this._state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sync getter — az aktualis state-version.
|
|
111
|
+
*/
|
|
112
|
+
stateVersion(): number {
|
|
113
|
+
return this._stateVersion;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Megnezi hogy van-e olyan transition ami a jelenlegi state-bol a kert
|
|
118
|
+
* `to`-ba vezet, ES (ha van guard) a guard `true`-t adna a kontextusra.
|
|
119
|
+
*
|
|
120
|
+
* NEM fut le onLeave / onEnter / persist callback.
|
|
121
|
+
*/
|
|
122
|
+
async canTransition(to: TState, ctx?: TContext): Promise<boolean> {
|
|
123
|
+
const transition: DyFM_StateTransition<TState, TContext> | undefined =
|
|
124
|
+
this._findTransition(this._state, to);
|
|
125
|
+
if (!transition) { return false; }
|
|
126
|
+
if (transition.guard) {
|
|
127
|
+
try {
|
|
128
|
+
const guardResult: boolean = await transition.guard(ctx);
|
|
129
|
+
return guardResult === true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Atmenet vegrehajtasa a kert `to` state-re.
|
|
139
|
+
*
|
|
140
|
+
* Flow:
|
|
141
|
+
* 1. Mutex-acquire — soros vegrehajtas egy instance-en
|
|
142
|
+
* 2. Transition-keres (`_findTransition`); ha nincs → `invalid-transition`
|
|
143
|
+
* 3. `guard(ctx)` — ha false → `guard-rejected`
|
|
144
|
+
* 4. `onLeave(ctx)` — throw → `on-leave-failed` (state meg NEM valtozott)
|
|
145
|
+
* 5. State + version frissites, `state$.next()` emit
|
|
146
|
+
* 6. `onEnter(ctx)` — throw → revert state + `on-enter-failed`
|
|
147
|
+
* 7. `persist(event)` if configured — throw → revert state + `persist-failed`
|
|
148
|
+
* 8. `events$.next(event)` emit
|
|
149
|
+
* 9. Mutex-release, return ok
|
|
150
|
+
*/
|
|
151
|
+
async transition(
|
|
152
|
+
to: TState,
|
|
153
|
+
ctx?: TContext,
|
|
154
|
+
): Promise<DyFM_StateMachineTransition_Result<TState>> {
|
|
155
|
+
const release: { resolve: () => void } = { resolve: (): void => { /* noop */ } };
|
|
156
|
+
const myTurn: Promise<void> = new Promise<void>((res: () => void): void => {
|
|
157
|
+
release.resolve = res;
|
|
158
|
+
});
|
|
159
|
+
const prevTail: Promise<unknown> = this._mutexTail;
|
|
160
|
+
this._mutexTail = myTurn;
|
|
161
|
+
try {
|
|
162
|
+
await prevTail;
|
|
163
|
+
return await this._transitionInternal(to, ctx);
|
|
164
|
+
} finally {
|
|
165
|
+
release.resolve();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/** Mutex-protected belso transition logika. */
|
|
171
|
+
private async _transitionInternal(
|
|
172
|
+
to: TState,
|
|
173
|
+
ctx?: TContext,
|
|
174
|
+
): Promise<DyFM_StateMachineTransition_Result<TState>> {
|
|
175
|
+
const from: TState = this._state;
|
|
176
|
+
|
|
177
|
+
// 1. Transition lookup
|
|
178
|
+
const transition: DyFM_StateTransition<TState, TContext> | undefined =
|
|
179
|
+
this._findTransition(from, to);
|
|
180
|
+
if (!transition) {
|
|
181
|
+
return this._failure(from, to, 'invalid-transition');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 2. Guard check
|
|
185
|
+
if (transition.guard) {
|
|
186
|
+
let guardResult: boolean;
|
|
187
|
+
try {
|
|
188
|
+
guardResult = await transition.guard(ctx);
|
|
189
|
+
} catch (err: unknown) {
|
|
190
|
+
return this._failure(from, to, 'guard-rejected', err as Error);
|
|
191
|
+
}
|
|
192
|
+
if (guardResult !== true) {
|
|
193
|
+
return this._failure(from, to, 'guard-rejected');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 3. onLeave (pre-change)
|
|
198
|
+
if (transition.onLeave) {
|
|
199
|
+
try {
|
|
200
|
+
await transition.onLeave(ctx);
|
|
201
|
+
} catch (err: unknown) {
|
|
202
|
+
return this._failure(from, to, 'on-leave-failed', err as Error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 4. State + version transition (the actual change)
|
|
207
|
+
const newVersion: number = this._stateVersion + 1;
|
|
208
|
+
this._state = to;
|
|
209
|
+
this._stateVersion = newVersion;
|
|
210
|
+
this._state$.next(this._state);
|
|
211
|
+
|
|
212
|
+
// 5. onEnter (post-change, pre-persist)
|
|
213
|
+
if (transition.onEnter) {
|
|
214
|
+
try {
|
|
215
|
+
await transition.onEnter(ctx);
|
|
216
|
+
} catch (err: unknown) {
|
|
217
|
+
// Revert
|
|
218
|
+
this._state = from;
|
|
219
|
+
this._stateVersion = newVersion - 1;
|
|
220
|
+
this._state$.next(this._state);
|
|
221
|
+
return this._failure(from, to, 'on-enter-failed', err as Error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 6. Persist (post-state-change, post-onEnter)
|
|
226
|
+
const event: DyFM_StateMachineEvent<TState, TContext> = {
|
|
227
|
+
from: from,
|
|
228
|
+
to: to,
|
|
229
|
+
stateVersion: newVersion,
|
|
230
|
+
context: ctx,
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
};
|
|
233
|
+
if (this._config.persist) {
|
|
234
|
+
try {
|
|
235
|
+
await this._config.persist(event);
|
|
236
|
+
} catch (err: unknown) {
|
|
237
|
+
// Revert
|
|
238
|
+
this._state = from;
|
|
239
|
+
this._stateVersion = newVersion - 1;
|
|
240
|
+
this._state$.next(this._state);
|
|
241
|
+
return this._failure(from, to, 'persist-failed', err as Error);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 7. Emit event
|
|
246
|
+
this._events$.next(event);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
from: from,
|
|
251
|
+
to: to,
|
|
252
|
+
stateVersion: newVersion,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Lookup: a `transitions[]` listaban olyat keres, aminek `from` matchel a
|
|
259
|
+
* `current`-tel (vagy `'*'` wildcard), ES `to` matchel a `target`-tel.
|
|
260
|
+
*
|
|
261
|
+
* Az elso talalatot adja vissza (a kepes ezert a config-ban a specifikusabb
|
|
262
|
+
* jojjon eloszor a wildcardnel — de a peldakban a wildcard tartalmazza az
|
|
263
|
+
* 'invalidat' is, igy az ordering MUST nem szigoru a tipikus eseteknel).
|
|
264
|
+
*/
|
|
265
|
+
private _findTransition(
|
|
266
|
+
current: TState,
|
|
267
|
+
target: TState,
|
|
268
|
+
): DyFM_StateTransition<TState, TContext> | undefined {
|
|
269
|
+
for (const t of this._config.transitions) {
|
|
270
|
+
if (t.to !== target) { continue; }
|
|
271
|
+
if (t.from === '*') { return t; }
|
|
272
|
+
if (Array.isArray(t.from)) {
|
|
273
|
+
if (t.from.includes(current)) { return t; }
|
|
274
|
+
} else if (t.from === current) {
|
|
275
|
+
return t;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Belso helper a sikertelen result epitesere.
|
|
283
|
+
*/
|
|
284
|
+
private _failure(
|
|
285
|
+
from: TState,
|
|
286
|
+
attemptedTo: TState,
|
|
287
|
+
reason: DyFM_StateMachineTransition_FailureReason,
|
|
288
|
+
error?: Error,
|
|
289
|
+
): DyFM_StateMachineTransition_Result<TState> {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
reason: reason,
|
|
293
|
+
currentState: this._state,
|
|
294
|
+
attemptedTo: attemptedTo,
|
|
295
|
+
error: error,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DyFM_Error } from '../../../_models/control-models/error.control-model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Failure reason-ok a `DyFM_StateMachineTransition_Result.reason` mezohoz.
|
|
5
|
+
*
|
|
6
|
+
* - `invalid-transition`: nincs olyan transition a config-ban ami a jelenlegi
|
|
7
|
+
* state-bol a kert `to`-ba vezet.
|
|
8
|
+
* - `guard-rejected`: a transition `guard` callback-je `false`-t adott.
|
|
9
|
+
* - `on-leave-failed`: a transition `onLeave` callback-je throw-olt; state NEM valtozott.
|
|
10
|
+
* - `on-enter-failed`: az `onEnter` callback throw-olt az uj state beallitasa utan; state revert-elve a from-ra.
|
|
11
|
+
* - `persist-failed`: a `persist` callback throw-olt; state revert-elve a from-ra.
|
|
12
|
+
*/
|
|
13
|
+
export type DyFM_StateMachineTransition_FailureReason =
|
|
14
|
+
| 'invalid-transition'
|
|
15
|
+
| 'guard-rejected'
|
|
16
|
+
| 'on-leave-failed'
|
|
17
|
+
| 'on-enter-failed'
|
|
18
|
+
| 'persist-failed';
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sikeres atmenet eredmenye.
|
|
23
|
+
*/
|
|
24
|
+
export interface DyFM_StateMachineTransition_SuccessResult<TState extends string> {
|
|
25
|
+
ok: true;
|
|
26
|
+
from: TState;
|
|
27
|
+
to: TState;
|
|
28
|
+
/** A frissitett state-version szam (>= 1). */
|
|
29
|
+
stateVersion: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sikertelen atmenet eredmenye.
|
|
35
|
+
*/
|
|
36
|
+
export interface DyFM_StateMachineTransition_FailureResult<TState extends string> {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: DyFM_StateMachineTransition_FailureReason;
|
|
39
|
+
/** A jelenlegi state amikor a sikertelenseg torent. */
|
|
40
|
+
currentState: TState;
|
|
41
|
+
/** A megkiserelt cel-state. */
|
|
42
|
+
attemptedTo: TState;
|
|
43
|
+
/** Opcionalis hiba ha az atmenet error-ral bukott (pl. on-enter throw, persist throw). */
|
|
44
|
+
error?: DyFM_Error | Error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Discriminated union — `ok` flag-gel branchol.
|
|
50
|
+
*/
|
|
51
|
+
export type DyFM_StateMachineTransition_Result<TState extends string> =
|
|
52
|
+
| DyFM_StateMachineTransition_SuccessResult<TState>
|
|
53
|
+
| DyFM_StateMachineTransition_FailureResult<TState>;
|