@vedmalex/statemachine 1.0.0-beta.1 → 1.0.0-beta.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vedmalex/statemachine",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "Hierarchical state machine for TypeScript with monitoring, validation, and persistence (lite-only DI-free surface).",
5
5
  "keywords": [
6
6
  "state-machine",
@@ -45,6 +45,9 @@
45
45
  "clean": "rm -rf dist types coverage tsconfig.tsbuildinfo",
46
46
  "build": "npm run clean && tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
47
47
  "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
48
+ "lint": "biome check .",
49
+ "typecheck": "tsc --noEmit",
50
+ "check": "biome check . && tsc --noEmit && knip --no-progress",
48
51
  "test": "vitest run",
49
52
  "test:watch": "vitest",
50
53
  "test:coverage": "vitest run --coverage",
@@ -53,11 +56,12 @@
53
56
  "test:browser": "playwright test",
54
57
  "knip": "knip --no-progress",
55
58
  "api:check": "api-extractor run --local --verbose",
56
- "prepublishOnly": "npm run build && node test/verify-dist.cjs"
59
+ "prepublishOnly": "npm run check && npm run build && node test/verify-dist.cjs"
57
60
  },
58
61
  "dependencies": {},
59
62
  "peerDependencies": {},
60
63
  "devDependencies": {
64
+ "@biomejs/biome": "2.4.14",
61
65
  "@changesets/cli": "^2.31.0",
62
66
  "@microsoft/api-extractor": "^7.58.7",
63
67
  "@playwright/test": "^1.59.1",
@@ -65,6 +65,38 @@ export declare class ConfigValidator {
65
65
  private validateInitialState;
66
66
  private validateCrossReferences;
67
67
  private collectStateNames;
68
+ /**
69
+ * Collects every state in the tree as {dottedPath, config} entries, walking
70
+ * region containers (region names are NOT registered states, so they are part
71
+ * of the path but never emitted as an entry — mirroring runtime expansion).
72
+ */
73
+ private collectStateEntries;
74
+ /**
75
+ * True when `composite` has at least one `final:true` atomic substate reachable
76
+ * through its (possibly nested) region tree — i.e. some region of the composite
77
+ * can become done. Used by REGION_NO_REACHABLE_FINAL.
78
+ */
79
+ private hasReachableFinal;
80
+ /**
81
+ * SCXML/UML final-state and all-regions-final (done.state.<C>) join validation.
82
+ *
83
+ * - FINAL_STATE_HAS_OUTGOING (error): a `final:true` state is the `from` of a
84
+ * transition (a final pseudo-state cannot itself transition out).
85
+ * - FINAL_ON_COMPOSITE (warning): `final:true` on a state that has regions
86
+ * (final is meaningful only on an atomic leaf; ignored at runtime).
87
+ * - REGION_NO_REACHABLE_FINAL (warning): a `done.state.<C>` transition exists
88
+ * but composite C declares no `final` substate, so the join can never fire.
89
+ * - DONE_VS_PARALLEL_EXIT_AMBIGUITY (warning): the same composite C has BOTH a
90
+ * `done.state.C` join AND a plain `from:'C'` user-event parallel-exit, which
91
+ * can preempt the all-final join (the user event is eligible on ANY leaf).
92
+ * - REGION_MISSING_INITIAL (advisory warning, valid:true): a region omits an
93
+ * explicit `initial`, so expansion relies on first-key insertion order.
94
+ */
95
+ private validateFinalStates;
96
+ /**
97
+ * Collects the dotted paths of every `final:true` atomic leaf in the tree.
98
+ */
99
+ private collectFinalStatePaths;
68
100
  private validatePerformanceConstraints;
69
101
  private countStates;
70
102
  private addError;
package/types/index.d.ts CHANGED
@@ -37,6 +37,16 @@ export type { State } from './types';
37
37
  * @category Unstable
38
38
  */
39
39
  export type { States, Events, Event, Adapter, StateMachineOptions, RegionsConfig, StateInvocation, StateName, EventName, RegionName, EventAction, ErrorHandler, ActionOrString, ErrorHandlerOrString, RegionStateName, NestedStateName, DeepNestedStateName, StatePaths, SimpleStateName, MethodsOf, PropertiesOf, KeysOf, ExtractAdaptee, ErrorContext, } from './types';
40
+ /**
41
+ * @category Unstable
42
+ * @unstable — validation preset/config shape used by validation helpers and presets.
43
+ */
44
+ export type { ValidationConfig } from './config_validator';
45
+ /**
46
+ * @category Unstable
47
+ * @unstable — monitoring preset/config shape used by monitoring helpers and presets.
48
+ */
49
+ export type { MonitoringConfig } from './monitoring';
40
50
  export { MemoryAdapter, LocalStorageAdapter, SessionStorageAdapter, ServerAdapter, StateMachineError, isAdapter, } from './types';
41
51
  export { createEnhancedError, EnhancedStateMachineError, ErrorAnalytics, ErrorCategory, type ErrorCategory as ErrorCategoryType, ErrorHandler as EnhancedErrorHandler, type ErrorRecoveryStrategy, ErrorSeverity, type ErrorSeverity as ErrorSeverityType, type ExtendedErrorContext, FallbackStateRecoveryStrategy, isRecoverableError, RetryRecoveryStrategy, } from './error_handling';
42
52
  export { validateConfig, validateConfigStrict, isValidConfig, type ValidationResult, type ValidationError, type ValidationWarning, } from './config_validator';
@@ -50,6 +60,29 @@ export type { IMonitor } from './types';
50
60
  * @unstable — timer scheduling injection contract; implement for WASM/host portability.
51
61
  */
52
62
  export type { ITimerScheduler } from './types';
63
+ /**
64
+ * @category Unstable
65
+ * @unstable — virtual-clock type alias; matches the `clock` option in StateMachineOptions.
66
+ */
67
+ export type { Clock } from './scheduler';
68
+ /**
69
+ * @category Unstable
70
+ * @unstable — factory for a deterministic, non-real-time scheduler. Pass the
71
+ * returned scheduler together with the same `clock` function in StateMachine
72
+ * options to enable virtual-time (DST) testing without real setTimeout.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * let t = 0
77
+ * const clock = () => t
78
+ * const scheduler = createVirtualScheduler(clock)
79
+ * const sm = new StateMachine(config, adapter, { clock, scheduler })
80
+ * await Promise.resolve() // flush microtasks: enter initial state + arm invoke timers
81
+ * t = 1000
82
+ * scheduler.process() // advance virtual time to 1000 ms
83
+ * ```
84
+ */
85
+ export { createVirtualScheduler } from './scheduler';
53
86
  /**
54
87
  * @category Unstable
55
88
  * @unstable — error-handler injection contract; implement to replace built-in recovery.
@@ -1,4 +1,9 @@
1
1
  import type { ITimerScheduler } from './types';
2
+ /**
3
+ * Clock function type: returns the current virtual or real time in ms.
4
+ * Matches the signature of `Date.now`.
5
+ */
6
+ export type Clock = () => number;
2
7
  /**
3
8
  * Тип для идентификатора таймера
4
9
  */
@@ -12,7 +17,8 @@ export declare class TimerScheduler {
12
17
  private activeTokens;
13
18
  private intervalId;
14
19
  private pollingInterval;
15
- constructor();
20
+ private readonly clock;
21
+ constructor(clock?: Clock);
16
22
  /**
17
23
  * Настройка режима опроса
18
24
  * @param interval Интервал проверки в мс. Если 0 или null - авто-тик выключается (ручной режим)
@@ -62,4 +68,28 @@ export declare class TimerScheduler {
62
68
  * Same-module placement required: TimerScheduler constructor is public within module.
63
69
  */
64
70
  export declare function createDefaultScheduler(): ITimerScheduler;
71
+ /**
72
+ * Returns a deterministic virtual-time scheduler driven entirely by the
73
+ * supplied `clock` function. Intended for tests, simulation, and DST.
74
+ *
75
+ * Guarantees:
76
+ * - `isActive()` always returns `true` — StateMachine routes all timers
77
+ * through it and never touches real `setTimeout`/`setInterval`.
78
+ * - No real timer is ever created.
79
+ * - `schedule(delay, cb)` queues a task at `clock() + delay`.
80
+ * - `process(now?)` drains every task whose `executeAt <= now`
81
+ * (default `now` is `clock()`).
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * let t = 0
86
+ * const clock = () => t
87
+ * const scheduler = createVirtualScheduler(clock)
88
+ * const sm = new StateMachine(config, adaptee, { clock, scheduler })
89
+ * await Promise.resolve() // flush microtasks: enter initial state + arm invoke timers
90
+ * t = 1000
91
+ * scheduler.process() // fires all tasks due by t=1000
92
+ * ```
93
+ */
94
+ export declare function createVirtualScheduler(clock: Clock): ITimerScheduler;
65
95
  export {};
@@ -18,6 +18,8 @@ export declare class StateMachine<TOwner extends object, SMConfig extends StateM
18
18
  private monitor;
19
19
  private scheduler;
20
20
  private errorHandler;
21
+ private clock;
22
+ private schedulerProvided;
21
23
  private states;
22
24
  private events;
23
25
  private stateAttribute;
@@ -47,6 +49,7 @@ export declare class StateMachine<TOwner extends object, SMConfig extends StateM
47
49
  get currentState(): string;
48
50
  constructor(config: SMConfig, adaptee?: Adapter<PropertiesOf<TOwner>> | PropertiesOf<TOwner>, options?: StateMachineOptions);
49
51
  setContext(context: MethodsOf<TOwner>): void;
52
+ private resolveCallbackOwner;
50
53
  private enqueueEvent;
51
54
  private raiseEvent;
52
55
  private scheduleProcessing;
@@ -145,6 +148,106 @@ export declare class StateMachine<TOwner extends object, SMConfig extends StateM
145
148
  private getInitialCompositeState;
146
149
  private getDirectChildren;
147
150
  private getInitialStatesForRegions;
151
+ /**
152
+ * Whether `leaf` is a UML/SCXML `<final>` atomic state of its region.
153
+ *
154
+ * Reads the `final` marker straight from the flattened state map populated by
155
+ * processStates, so it works for any registered atomic leaf regardless of
156
+ * nesting depth. Returns `false` for unregistered names and for composites
157
+ * (the all-regions-final join derives doneness from atomic leaves, not from a
158
+ * `final` flag on a composite parent).
159
+ */
160
+ private isStateFinal;
161
+ /**
162
+ * Whether composite `compositeId` has reached its UML/SCXML "done"
163
+ * configuration: every one of its regions has its active atomic leaf in a
164
+ * `final` state (recursively, for nested composites).
165
+ *
166
+ * D10 (mustFix): doneness is derived by scanning the active atomic `|`-leaves
167
+ * against the STATIC regions tree (`this.states.get(C)?.regions`), NEVER via a
168
+ * region-key Map lookup (`configMap.get`) — that map keys leaves by their
169
+ * deepest region container and so cannot answer "which leaf is active in
170
+ * region X of composite C". For each region we locate the active leaf via the
171
+ * `C.region.` dotted prefix; the region is final iff that leaf is `final`, or
172
+ * the leaf lives under a nested composite that is itself `isCompositeDone`.
173
+ *
174
+ * Returns `false` for a non-composite id, for a composite with no active leaf
175
+ * in some region, or when no substate under it is ever `final` (cheap miss).
176
+ */
177
+ private isCompositeDone;
178
+ /**
179
+ * The OUTERMOST registered composite (regions-bearing) ancestor of `leaf`
180
+ * that lives strictly under `regionPrefix`, or `undefined` if the region
181
+ * holds a simple atomic state directly. Used by {@link isCompositeDone} to
182
+ * delegate a region's completeness to its nested composite (recursing over
183
+ * every parallel branch), independent of whether any single branch leaf
184
+ * happens to carry the `final` flag.
185
+ *
186
+ * ancestorChain is root-to-leaf, so the FIRST matching ancestor is the
187
+ * region's direct composite child (e.g. `C.p.D` for leaf `C.p.D.s.s2` under
188
+ * region prefix `C.p.`); isCompositeDone then recurses into its regions.
189
+ */
190
+ private regionComposite;
191
+ /**
192
+ * Whether composite `compositeId` has reached its all-regions-final ("done")
193
+ * configuration in the CURRENT active state. Public guard surface (`@stable`)
194
+ * for authoring a join as `guard: () => sm.isDone('C')` instead of (or
195
+ * alongside) listening on the engine `done.state.<C>` event.
196
+ *
197
+ * @param compositeId - The dotted id of the composite/parallel state.
198
+ * @returns `true` iff every region's active atomic leaf is `final`.
199
+ */
200
+ isDone(compositeId: string, adaptee?: Adapter<PropertiesOf<TOwner>>): boolean;
201
+ /**
202
+ * SCXML completion hook (D10/D11/D12): after a new configuration is written,
203
+ * raise `done.state.<C>` for each composite that became all-regions-final.
204
+ *
205
+ * - Scans only composites that GAINED an active leaf (the ancestor chain of
206
+ * each `|`-leaf of `newState`), so unaffected composites are not re-checked.
207
+ * - Emits INNERMOST-first (a deeper composite's `done.state` precedes its
208
+ * parent's), matching SCXML's inner-before-outer completion ordering, via a
209
+ * per-config emitted-id Set so each id is raised at most once per call.
210
+ * - Gates each emission on `this.events.has('done.state.'+C)` (D11 mustFix):
211
+ * raising an undeclared event would hit the `Invalid event` throw
212
+ * (executeQueuedTransition) as an unhandled microtask rejection. No declared
213
+ * `done.state.<C>` event => no emission, no crash, no observable effect.
214
+ * - Uses the internal queue (`raiseEvent`) + `scheduleProcessing` so the
215
+ * completion event is processed before subsequent external events.
216
+ */
217
+ private checkCompletion;
218
+ /**
219
+ * Build the registered ancestor chain for an atomic leaf, ordered root-to-leaf.
220
+ *
221
+ * Walks every dot-prefix of `leaf` and keeps only the ones that are real
222
+ * registered states (`this.states.has`). Region containers are never
223
+ * registered (only composite parents and atomic leaves are — see
224
+ * processStates/processRegions), so they are filtered out automatically. The
225
+ * result is exactly `[parent..leaf]` for a leaf inside a composite, and
226
+ * `[leaf]` for a flat state.
227
+ *
228
+ * Example: `ancestorChain('a.r1.c1')` -> `['a', 'a.r1.c1']` (the `a.r1`
229
+ * region container is excluded). Nested:
230
+ * `ancestorChain('a.r1.c1.r3.x')` -> `['a', 'a.r1.c1', 'a.r1.c1.r3.x']`.
231
+ */
232
+ private ancestorChain;
233
+ /**
234
+ * Compute the ordered enter/exit sets between two composite configurations
235
+ * (SCXML ancestor-first entry / descendant-first exit).
236
+ *
237
+ * For each `|`-separated atomic leaf in `oldComposite` and `newComposite` the
238
+ * union of its {@link ancestorChain} forms the old/new active ancestry. A
239
+ * state shared by both ancestries lands in NEITHER diff, so a surviving
240
+ * ancestor is never re-entered nor exited (no onEnter/onExit re-fire, no
241
+ * timer re-arm/leak).
242
+ *
243
+ * - `enterStates` = new ancestry MINUS old, sorted ascending by depth then
244
+ * document (insertion) order -> root-to-leaf entry.
245
+ * - `exitStates` = old ancestry MINUS new, sorted descending by depth ->
246
+ * leaf-to-root exit.
247
+ *
248
+ * Consumed by BOTH R1 (applyTransition / setInitialState / reset) and R2.
249
+ */
250
+ private computeEnterExitSets;
148
251
  private validateCompositeState;
149
252
  private processStates;
150
253
  private processRegions;
package/types/types.d.ts CHANGED
@@ -34,6 +34,12 @@ export interface ITimerScheduler {
34
34
  isActive(): boolean;
35
35
  schedule(delay: number, callback: () => void): object;
36
36
  cancel(token: object): void;
37
+ /**
38
+ * Optional manual drain — advance virtual time and fire all timers whose
39
+ * `executeAt <= now` (default `now` is the scheduler's own clock). Real-time
40
+ * schedulers driven by setInterval may leave this unimplemented.
41
+ */
42
+ process?(now?: number): void;
37
43
  }
38
44
  /** @unstable — transition observability context (additive on IMonitor). */
39
45
  export interface TransitionContext {
@@ -74,6 +80,13 @@ export interface StateMachineOptions {
74
80
  monitor?: IMonitor;
75
81
  scheduler?: ITimerScheduler;
76
82
  errorHandler?: IErrorHandler;
83
+ /**
84
+ * Clock function returning the current time in milliseconds.
85
+ * Default: `Date.now`. Inject a virtual clock together with a
86
+ * `scheduler` (see `createVirtualScheduler`) for deterministic replay / DST.
87
+ * Used for `stateEntryTimes`, `resumeTimers`, and `getQueuedEvents` age math.
88
+ */
89
+ clock?: () => number;
77
90
  /**
78
91
  * Maximum time (ms) to wait for async entry/exit actions.
79
92
  * If exceeded, the transition aborts with an error.
@@ -119,7 +132,7 @@ export type DeepNestedStateName<S> = {
119
132
  }[keyof R & string] : never : never;
120
133
  }[keyof S & string];
121
134
  export type StatePaths<S> = SimpleStateName<S> | RegionStateName<S> | NestedStateName<S> | DeepNestedStateName<S>;
122
- export type ActionOrString<T extends object, R = void> = KeysOf<T, EventAction<T, R>> | EventAction<Adapter<T>, R>;
135
+ export type ActionOrString<T extends object, R = void> = KeysOf<T, EventAction<T, R>> | EventAction<T, R>;
123
136
  export type ErrorHandlerOrString<T extends object> = KeysOf<T, ErrorHandler<T>> | ErrorHandler<T>;
124
137
  export type RegionName = string;
125
138
  export type RegionsConfig<T extends object> = Record<RegionName, StateMachineConfig<T>['states']>;
@@ -138,6 +151,15 @@ export type State<T extends object> = {
138
151
  regions?: RegionsConfig<T>;
139
152
  initial?: StateName;
140
153
  history?: 'deep' | 'shallow';
154
+ /**
155
+ * Marks this atomic state as the SCXML/UML `<final>` pseudo-substate of its
156
+ * region. When every region of a composite has its active atomic leaf marked
157
+ * `final`, that composite is "done": the engine raises the `done.state.<id>`
158
+ * event and {@link StateMachine.isDone} returns `true`. Only meaningful on a
159
+ * leaf state (a state without `regions`); set on a composite it is ignored at
160
+ * runtime and flagged by the config validator.
161
+ */
162
+ final?: boolean;
141
163
  invoke?: StateInvocation<T>[];
142
164
  };
143
165
  export interface StateInvocation<T extends object> {
@@ -188,7 +210,7 @@ export interface StateMachineConfig<T extends object = object> {
188
210
  initialState: keyof StateMachineConfig<T>['states'];
189
211
  events: Record<EventName, Omit<Event<T, StateMachineConfig<T>['states']>, 'name'>>;
190
212
  states: States<T>;
191
- onError?: KeysOf<T, ErrorHandler<T>>;
213
+ onError?: ErrorHandlerOrString<T>;
192
214
  }
193
215
  export interface StatePersistenceAdapter {
194
216
  save(state?: {