@camcima/finita 4.0.0 → 4.1.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.
package/dist/index.d.cts CHANGED
@@ -287,6 +287,12 @@ interface StatemachineInterface<TSubject = unknown> {
287
287
  getProcess(): ProcessInterface;
288
288
  triggerEvent(name: string, context?: Map<string, unknown>): Promise<void>;
289
289
  checkTransitions(context?: Map<string, unknown>): Promise<void>;
290
+ /**
291
+ * Resolves once the operation queue is empty and the runner is idle,
292
+ * including operations chained via EnqueueContext.enqueue(). Resolves
293
+ * immediately if the machine is already idle.
294
+ */
295
+ whenIdle(): Promise<void>;
290
296
  attachBefore(observer: BeforeTransitionObserver<TSubject>): void;
291
297
  detachBefore(observer: BeforeTransitionObserver<TSubject>): void;
292
298
  getBeforeObservers(): Iterable<BeforeTransitionObserver<TSubject>>;
@@ -316,7 +322,16 @@ interface StatemachineOptions<TSubject = unknown> {
316
322
  initialStateName?: string;
317
323
  /** Defaults to OneOrNoneActiveTransition. */
318
324
  transitionSelector?: TransitionSelectorInterface<TSubject>;
319
- /** Defaults to NullMutex (no cross-process serialization). */
325
+ /**
326
+ * Defaults to NullMutex (no cross-process serialization).
327
+ *
328
+ * Must be exclusive to this machine — never share one MutexInterface
329
+ * instance between machines: the engine reads isAcquired() as "this
330
+ * machine holds the lock", so a shared instance silently disables mutual
331
+ * exclusion. To coordinate machines, share the underlying
332
+ * LockAdapterInterface (same resource name) and construct one mutex per
333
+ * machine, as MutexFactory does.
334
+ */
320
335
  mutex?: MutexInterface;
321
336
  /** When true, the engine releases the mutex at the end of each top-level operation. Defaults to true. */
322
337
  autoreleaseLock?: boolean;
@@ -330,6 +345,36 @@ interface StatemachineOptions<TSubject = unknown> {
330
345
  * Defaults to 100.
331
346
  */
332
347
  maxAutomaticHops?: number;
348
+ /**
349
+ * Called when an operation chained via EnqueueContext.enqueue() fails.
350
+ * Chained operations are not awaited by the caller whose transition
351
+ * enqueued them, so without this hook their errors are discarded.
352
+ * Exceptions thrown by the hook itself are swallowed — it must not be
353
+ * able to break the machine's drain loop.
354
+ */
355
+ onChainedOperationError?: (error: unknown, info: {
356
+ eventName: string;
357
+ }) => void;
358
+ /**
359
+ * Maximum number of operations that may wait in the queue (the running
360
+ * operation does not count). When the limit is reached, further
361
+ * triggerEvent/checkTransitions calls reject with
362
+ * QueueLimitExceededError, and EnqueueContext.enqueue() throws it into
363
+ * the enqueuing after-observer's error path. In that case the original
364
+ * caller's promise rejects even though its transition already
365
+ * committed — check the machine's state, not just the rejection, before
366
+ * retrying. Must be a positive integer when set. Defaults to Infinity
367
+ * (unbounded, the previous behavior).
368
+ */
369
+ maxQueueLength?: number;
370
+ /**
371
+ * Diagnostic hook called whenever the automatic post-operation lock
372
+ * release throws — including when the operation itself also failed, in
373
+ * which case the caller's rejection carries the operation error and the
374
+ * release error would otherwise be discarded. Does not change rejection
375
+ * behavior. Exceptions thrown by the hook itself are swallowed.
376
+ */
377
+ onReleaseError?: (error: unknown) => void;
333
378
  }
334
379
 
335
380
  declare class Statemachine<TSubject = unknown> implements StatemachineInterface<TSubject> {
@@ -341,11 +386,15 @@ declare class Statemachine<TSubject = unknown> implements StatemachineInterface<
341
386
  private lastState;
342
387
  private autoreleaseLock;
343
388
  private readonly maxAutomaticHops;
389
+ private readonly maxQueueLength;
344
390
  private readonly queue;
345
391
  private running;
392
+ private idleWaiters;
346
393
  private inSyncCallback;
347
394
  private readonly beforeObservers;
348
395
  private readonly afterObservers;
396
+ private readonly onChainedOperationError?;
397
+ private readonly onReleaseError?;
349
398
  constructor(subject: TSubject, process: ProcessInterface, options?: StatemachineOptions<TSubject>);
350
399
  getCurrentState(): StateInterface;
351
400
  getLastState(): StateInterface | null;
@@ -364,6 +413,14 @@ declare class Statemachine<TSubject = unknown> implements StatemachineInterface<
364
413
  setAutoreleaseLock(autorelease: boolean): void;
365
414
  triggerEvent(name: string, context?: Map<string, unknown>): Promise<void>;
366
415
  checkTransitions(context?: Map<string, unknown>): Promise<void>;
416
+ /**
417
+ * Resolves once the operation queue is empty and the runner is idle —
418
+ * i.e. every operation enqueued so far, including operations chained via
419
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
420
+ * machine is already idle. Note this is a quiescence point, not a
421
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
422
+ */
423
+ whenIdle(): Promise<void>;
367
424
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
368
425
  * the flag is cleared as soon as fn returns (before any promise it returned
369
426
  * is awaited), so concurrent external callers are never affected. This
@@ -674,11 +731,12 @@ interface Graph {
674
731
  nodes: GraphNode[];
675
732
  edges: GraphEdge[];
676
733
  }
734
+ type GraphDirection = "TB" | "BT" | "LR" | "RL";
677
735
  interface DotOptions {
678
- rankdir?: string;
736
+ rankdir?: GraphDirection;
679
737
  }
680
738
  interface MermaidOptions {
681
- direction?: string;
739
+ direction?: GraphDirection;
682
740
  }
683
741
  declare class GraphBuilder {
684
742
  private readonly nodes;
@@ -723,7 +781,7 @@ declare class ProcessFinalizedError extends FinitaError {
723
781
  constructor(processName: string);
724
782
  }
725
783
 
726
- type GraphValidationCode = "unknownTarget" | "unknownSource" | "missingInitialState" | "multipleInitialStates" | "invalidStateName" | "invalidEventName" | "invalidConditionName" | "orphanState";
784
+ type GraphValidationCode = "unknownTarget" | "unknownSource" | "missingInitialState" | "multipleInitialStates" | "invalidStateName" | "invalidEventName" | "invalidConditionName" | "invalidTransitionWeight" | "orphanState";
727
785
  declare class GraphValidationError extends FinitaError {
728
786
  readonly code: GraphValidationCode;
729
787
  readonly details: Readonly<Record<string, unknown>>;
@@ -791,4 +849,9 @@ declare class ReentrancyError extends FinitaError {
791
849
  constructor(operation: string);
792
850
  }
793
851
 
794
- export { AbstractNamedProcessDetector, ActiveTransitionFilter, type AddStateOptions, type AddTransitionOptions, type AfterTransitionObserver, AmbiguousTransitionError, AndComposite, AutomaticTransitionCycleError, type BeforeTransitionObserver, type BuildOptions, CallbackCondition, type CallbackInterface, CallbackObserver, type ConditionCallbackFn, type ConditionInterface, Contradiction, type DispatcherInterface, type DotOptions, DuplicateStateError, type DuplicateTransitionConflict, DuplicateTransitionError, type EnqueueContext, Event, type EventInterface, Factory, type FactoryInterface, FilterStateByEvent, FilterStateByFinalState, FilterStateByTransition, FilterTransitionByEvent, FinitaError, type Graph, GraphBuilder, type GraphEdge, type GraphNode, type GraphValidationCode, GraphValidationError, InvalidSubjectError, type LastStateHasChangedDateInterface, type LockAdapterInterface, LockAdapterMutex, LockCanNotBeAcquiredError, type LoggerInterface, type MaybePromise, type MermaidOptions, type Metadata, MutexFactory, type MutexFactoryInterface, type MutexInterface, type Named, Not, NullMutex, type ObservableSubject, type Observer, OnEnterObserver, OneOrNoneActiveTransition, OrComposite, Process, ProcessBuilder, type ProcessDetectorInterface, ProcessFinalizedError, type ProcessInterface, ProcessNotFoundError, type ProposedTransitionFrame, ReentrancyError, ScoreTransition, SingleProcessDetector, State, type StateCollectionInterface, StateEventNotFoundError, type StateInterface, type StateNameDetectorInterface, StateNotFoundError, type StatefulInterface, StatefulStateNameDetector, StatefulStatusChanger, Statemachine, type StatemachineInterface, type StatemachineOptions, type StringConverter, Tautology, Timeout, Transition, type TransitionFrame, type TransitionInterface, TransitionLogger, type TransitionSelectorInterface, WeightTransition, type Weighted, WrongEventForStateError };
852
+ declare class QueueLimitExceededError extends FinitaError {
853
+ readonly code = "queueLimitExceeded";
854
+ constructor(limit: number, eventName: string | null);
855
+ }
856
+
857
+ export { AbstractNamedProcessDetector, ActiveTransitionFilter, type AddStateOptions, type AddTransitionOptions, type AfterTransitionObserver, AmbiguousTransitionError, AndComposite, AutomaticTransitionCycleError, type BeforeTransitionObserver, type BuildOptions, CallbackCondition, type CallbackInterface, CallbackObserver, type ConditionCallbackFn, type ConditionInterface, Contradiction, type DispatcherInterface, type DotOptions, DuplicateStateError, type DuplicateTransitionConflict, DuplicateTransitionError, type EnqueueContext, Event, type EventInterface, Factory, type FactoryInterface, FilterStateByEvent, FilterStateByFinalState, FilterStateByTransition, FilterTransitionByEvent, FinitaError, type Graph, GraphBuilder, type GraphDirection, type GraphEdge, type GraphNode, type GraphValidationCode, GraphValidationError, InvalidSubjectError, type LastStateHasChangedDateInterface, type LockAdapterInterface, LockAdapterMutex, LockCanNotBeAcquiredError, type LoggerInterface, type MaybePromise, type MermaidOptions, type Metadata, MutexFactory, type MutexFactoryInterface, type MutexInterface, type Named, Not, NullMutex, type ObservableSubject, type Observer, OnEnterObserver, OneOrNoneActiveTransition, OrComposite, Process, ProcessBuilder, type ProcessDetectorInterface, ProcessFinalizedError, type ProcessInterface, ProcessNotFoundError, type ProposedTransitionFrame, QueueLimitExceededError, ReentrancyError, ScoreTransition, SingleProcessDetector, State, type StateCollectionInterface, StateEventNotFoundError, type StateInterface, type StateNameDetectorInterface, StateNotFoundError, type StatefulInterface, StatefulStateNameDetector, StatefulStatusChanger, Statemachine, type StatemachineInterface, type StatemachineOptions, type StringConverter, Tautology, Timeout, Transition, type TransitionFrame, type TransitionInterface, TransitionLogger, type TransitionSelectorInterface, WeightTransition, type Weighted, WrongEventForStateError };
package/dist/index.d.ts CHANGED
@@ -287,6 +287,12 @@ interface StatemachineInterface<TSubject = unknown> {
287
287
  getProcess(): ProcessInterface;
288
288
  triggerEvent(name: string, context?: Map<string, unknown>): Promise<void>;
289
289
  checkTransitions(context?: Map<string, unknown>): Promise<void>;
290
+ /**
291
+ * Resolves once the operation queue is empty and the runner is idle,
292
+ * including operations chained via EnqueueContext.enqueue(). Resolves
293
+ * immediately if the machine is already idle.
294
+ */
295
+ whenIdle(): Promise<void>;
290
296
  attachBefore(observer: BeforeTransitionObserver<TSubject>): void;
291
297
  detachBefore(observer: BeforeTransitionObserver<TSubject>): void;
292
298
  getBeforeObservers(): Iterable<BeforeTransitionObserver<TSubject>>;
@@ -316,7 +322,16 @@ interface StatemachineOptions<TSubject = unknown> {
316
322
  initialStateName?: string;
317
323
  /** Defaults to OneOrNoneActiveTransition. */
318
324
  transitionSelector?: TransitionSelectorInterface<TSubject>;
319
- /** Defaults to NullMutex (no cross-process serialization). */
325
+ /**
326
+ * Defaults to NullMutex (no cross-process serialization).
327
+ *
328
+ * Must be exclusive to this machine — never share one MutexInterface
329
+ * instance between machines: the engine reads isAcquired() as "this
330
+ * machine holds the lock", so a shared instance silently disables mutual
331
+ * exclusion. To coordinate machines, share the underlying
332
+ * LockAdapterInterface (same resource name) and construct one mutex per
333
+ * machine, as MutexFactory does.
334
+ */
320
335
  mutex?: MutexInterface;
321
336
  /** When true, the engine releases the mutex at the end of each top-level operation. Defaults to true. */
322
337
  autoreleaseLock?: boolean;
@@ -330,6 +345,36 @@ interface StatemachineOptions<TSubject = unknown> {
330
345
  * Defaults to 100.
331
346
  */
332
347
  maxAutomaticHops?: number;
348
+ /**
349
+ * Called when an operation chained via EnqueueContext.enqueue() fails.
350
+ * Chained operations are not awaited by the caller whose transition
351
+ * enqueued them, so without this hook their errors are discarded.
352
+ * Exceptions thrown by the hook itself are swallowed — it must not be
353
+ * able to break the machine's drain loop.
354
+ */
355
+ onChainedOperationError?: (error: unknown, info: {
356
+ eventName: string;
357
+ }) => void;
358
+ /**
359
+ * Maximum number of operations that may wait in the queue (the running
360
+ * operation does not count). When the limit is reached, further
361
+ * triggerEvent/checkTransitions calls reject with
362
+ * QueueLimitExceededError, and EnqueueContext.enqueue() throws it into
363
+ * the enqueuing after-observer's error path. In that case the original
364
+ * caller's promise rejects even though its transition already
365
+ * committed — check the machine's state, not just the rejection, before
366
+ * retrying. Must be a positive integer when set. Defaults to Infinity
367
+ * (unbounded, the previous behavior).
368
+ */
369
+ maxQueueLength?: number;
370
+ /**
371
+ * Diagnostic hook called whenever the automatic post-operation lock
372
+ * release throws — including when the operation itself also failed, in
373
+ * which case the caller's rejection carries the operation error and the
374
+ * release error would otherwise be discarded. Does not change rejection
375
+ * behavior. Exceptions thrown by the hook itself are swallowed.
376
+ */
377
+ onReleaseError?: (error: unknown) => void;
333
378
  }
334
379
 
335
380
  declare class Statemachine<TSubject = unknown> implements StatemachineInterface<TSubject> {
@@ -341,11 +386,15 @@ declare class Statemachine<TSubject = unknown> implements StatemachineInterface<
341
386
  private lastState;
342
387
  private autoreleaseLock;
343
388
  private readonly maxAutomaticHops;
389
+ private readonly maxQueueLength;
344
390
  private readonly queue;
345
391
  private running;
392
+ private idleWaiters;
346
393
  private inSyncCallback;
347
394
  private readonly beforeObservers;
348
395
  private readonly afterObservers;
396
+ private readonly onChainedOperationError?;
397
+ private readonly onReleaseError?;
349
398
  constructor(subject: TSubject, process: ProcessInterface, options?: StatemachineOptions<TSubject>);
350
399
  getCurrentState(): StateInterface;
351
400
  getLastState(): StateInterface | null;
@@ -364,6 +413,14 @@ declare class Statemachine<TSubject = unknown> implements StatemachineInterface<
364
413
  setAutoreleaseLock(autorelease: boolean): void;
365
414
  triggerEvent(name: string, context?: Map<string, unknown>): Promise<void>;
366
415
  checkTransitions(context?: Map<string, unknown>): Promise<void>;
416
+ /**
417
+ * Resolves once the operation queue is empty and the runner is idle —
418
+ * i.e. every operation enqueued so far, including operations chained via
419
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
420
+ * machine is already idle. Note this is a quiescence point, not a
421
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
422
+ */
423
+ whenIdle(): Promise<void>;
367
424
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
368
425
  * the flag is cleared as soon as fn returns (before any promise it returned
369
426
  * is awaited), so concurrent external callers are never affected. This
@@ -674,11 +731,12 @@ interface Graph {
674
731
  nodes: GraphNode[];
675
732
  edges: GraphEdge[];
676
733
  }
734
+ type GraphDirection = "TB" | "BT" | "LR" | "RL";
677
735
  interface DotOptions {
678
- rankdir?: string;
736
+ rankdir?: GraphDirection;
679
737
  }
680
738
  interface MermaidOptions {
681
- direction?: string;
739
+ direction?: GraphDirection;
682
740
  }
683
741
  declare class GraphBuilder {
684
742
  private readonly nodes;
@@ -723,7 +781,7 @@ declare class ProcessFinalizedError extends FinitaError {
723
781
  constructor(processName: string);
724
782
  }
725
783
 
726
- type GraphValidationCode = "unknownTarget" | "unknownSource" | "missingInitialState" | "multipleInitialStates" | "invalidStateName" | "invalidEventName" | "invalidConditionName" | "orphanState";
784
+ type GraphValidationCode = "unknownTarget" | "unknownSource" | "missingInitialState" | "multipleInitialStates" | "invalidStateName" | "invalidEventName" | "invalidConditionName" | "invalidTransitionWeight" | "orphanState";
727
785
  declare class GraphValidationError extends FinitaError {
728
786
  readonly code: GraphValidationCode;
729
787
  readonly details: Readonly<Record<string, unknown>>;
@@ -791,4 +849,9 @@ declare class ReentrancyError extends FinitaError {
791
849
  constructor(operation: string);
792
850
  }
793
851
 
794
- export { AbstractNamedProcessDetector, ActiveTransitionFilter, type AddStateOptions, type AddTransitionOptions, type AfterTransitionObserver, AmbiguousTransitionError, AndComposite, AutomaticTransitionCycleError, type BeforeTransitionObserver, type BuildOptions, CallbackCondition, type CallbackInterface, CallbackObserver, type ConditionCallbackFn, type ConditionInterface, Contradiction, type DispatcherInterface, type DotOptions, DuplicateStateError, type DuplicateTransitionConflict, DuplicateTransitionError, type EnqueueContext, Event, type EventInterface, Factory, type FactoryInterface, FilterStateByEvent, FilterStateByFinalState, FilterStateByTransition, FilterTransitionByEvent, FinitaError, type Graph, GraphBuilder, type GraphEdge, type GraphNode, type GraphValidationCode, GraphValidationError, InvalidSubjectError, type LastStateHasChangedDateInterface, type LockAdapterInterface, LockAdapterMutex, LockCanNotBeAcquiredError, type LoggerInterface, type MaybePromise, type MermaidOptions, type Metadata, MutexFactory, type MutexFactoryInterface, type MutexInterface, type Named, Not, NullMutex, type ObservableSubject, type Observer, OnEnterObserver, OneOrNoneActiveTransition, OrComposite, Process, ProcessBuilder, type ProcessDetectorInterface, ProcessFinalizedError, type ProcessInterface, ProcessNotFoundError, type ProposedTransitionFrame, ReentrancyError, ScoreTransition, SingleProcessDetector, State, type StateCollectionInterface, StateEventNotFoundError, type StateInterface, type StateNameDetectorInterface, StateNotFoundError, type StatefulInterface, StatefulStateNameDetector, StatefulStatusChanger, Statemachine, type StatemachineInterface, type StatemachineOptions, type StringConverter, Tautology, Timeout, Transition, type TransitionFrame, type TransitionInterface, TransitionLogger, type TransitionSelectorInterface, WeightTransition, type Weighted, WrongEventForStateError };
852
+ declare class QueueLimitExceededError extends FinitaError {
853
+ readonly code = "queueLimitExceeded";
854
+ constructor(limit: number, eventName: string | null);
855
+ }
856
+
857
+ export { AbstractNamedProcessDetector, ActiveTransitionFilter, type AddStateOptions, type AddTransitionOptions, type AfterTransitionObserver, AmbiguousTransitionError, AndComposite, AutomaticTransitionCycleError, type BeforeTransitionObserver, type BuildOptions, CallbackCondition, type CallbackInterface, CallbackObserver, type ConditionCallbackFn, type ConditionInterface, Contradiction, type DispatcherInterface, type DotOptions, DuplicateStateError, type DuplicateTransitionConflict, DuplicateTransitionError, type EnqueueContext, Event, type EventInterface, Factory, type FactoryInterface, FilterStateByEvent, FilterStateByFinalState, FilterStateByTransition, FilterTransitionByEvent, FinitaError, type Graph, GraphBuilder, type GraphDirection, type GraphEdge, type GraphNode, type GraphValidationCode, GraphValidationError, InvalidSubjectError, type LastStateHasChangedDateInterface, type LockAdapterInterface, LockAdapterMutex, LockCanNotBeAcquiredError, type LoggerInterface, type MaybePromise, type MermaidOptions, type Metadata, MutexFactory, type MutexFactoryInterface, type MutexInterface, type Named, Not, NullMutex, type ObservableSubject, type Observer, OnEnterObserver, OneOrNoneActiveTransition, OrComposite, Process, ProcessBuilder, type ProcessDetectorInterface, ProcessFinalizedError, type ProcessInterface, ProcessNotFoundError, type ProposedTransitionFrame, QueueLimitExceededError, ReentrancyError, ScoreTransition, SingleProcessDetector, State, type StateCollectionInterface, StateEventNotFoundError, type StateInterface, type StateNameDetectorInterface, StateNotFoundError, type StatefulInterface, StatefulStateNameDetector, StatefulStatusChanger, Statemachine, type StatemachineInterface, type StatemachineOptions, type StringConverter, Tautology, Timeout, Transition, type TransitionFrame, type TransitionInterface, TransitionLogger, type TransitionSelectorInterface, WeightTransition, type Weighted, WrongEventForStateError };
package/dist/index.js CHANGED
@@ -184,6 +184,7 @@ var State = class {
184
184
  throw new Error(`State "${this.name}" transitions already set`);
185
185
  }
186
186
  this._transitions = new Set(transitions);
187
+ Object.freeze(this);
187
188
  }
188
189
  getName() {
189
190
  return this.name;
@@ -234,6 +235,7 @@ var Transition = class {
234
235
  this.eventName = eventName;
235
236
  this.condition = condition;
236
237
  this.weight = weight;
238
+ Object.freeze(this);
237
239
  }
238
240
  getTargetState() {
239
241
  return this.targetState;
@@ -368,12 +370,20 @@ var ProcessBuilder = class _ProcessBuilder {
368
370
  { fromState, toState, conditionName }
369
371
  );
370
372
  }
373
+ const weight = options.weight ?? 1;
374
+ if (!Number.isFinite(weight)) {
375
+ throw new GraphValidationError(
376
+ "invalidTransitionWeight",
377
+ `addTransition from "${fromState}" to "${toState}": weight must be a finite number; got ${String(weight)}`,
378
+ { fromState, toState, eventName, weight }
379
+ );
380
+ }
371
381
  this.transitionSpecs.push({
372
382
  fromState,
373
383
  toState,
374
384
  eventName,
375
385
  condition: options.condition ?? null,
376
- weight: options.weight ?? 1
386
+ weight
377
387
  });
378
388
  return this;
379
389
  }
@@ -381,7 +391,6 @@ var ProcessBuilder = class _ProcessBuilder {
381
391
  if (this.built) {
382
392
  throw new ProcessFinalizedError(this.processName);
383
393
  }
384
- this.built = true;
385
394
  this.validateInitialState();
386
395
  this.validateTransitionEndpoints();
387
396
  this.validateNoConflictingDuplicates();
@@ -392,6 +401,7 @@ var ProcessBuilder = class _ProcessBuilder {
392
401
  this.validateOrphans(finalStates, initialName);
393
402
  }
394
403
  const initialState = finalStates.get(initialName);
404
+ this.built = true;
395
405
  return new Process(
396
406
  INTERNAL_CONSTRUCTION_KEY,
397
407
  this.processName,
@@ -636,6 +646,9 @@ var OperationQueue = class {
636
646
  isEmpty() {
637
647
  return this.items.length === 0;
638
648
  }
649
+ size() {
650
+ return this.items.length;
651
+ }
639
652
  };
640
653
 
641
654
  // src/filter/ActiveTransitionFilter.ts
@@ -700,6 +713,17 @@ var ReentrancyError = class extends FinitaError {
700
713
  }
701
714
  };
702
715
 
716
+ // src/error/QueueLimitExceededError.ts
717
+ var QueueLimitExceededError = class extends FinitaError {
718
+ code = "queueLimitExceeded";
719
+ constructor(limit, eventName) {
720
+ super(
721
+ `${eventName === null ? "checkTransitions()" : `triggerEvent("${eventName}")`} rejected: the operation queue already holds ${limit} pending operation(s) (maxQueueLength = ${limit}).`
722
+ );
723
+ this.name = "QueueLimitExceededError";
724
+ }
725
+ };
726
+
703
727
  // src/Statemachine.ts
704
728
  var Statemachine = class {
705
729
  subject;
@@ -710,11 +734,15 @@ var Statemachine = class {
710
734
  lastState = null;
711
735
  autoreleaseLock;
712
736
  maxAutomaticHops;
737
+ maxQueueLength;
713
738
  queue = new OperationQueue();
714
739
  running = false;
740
+ idleWaiters = [];
715
741
  inSyncCallback = false;
716
742
  beforeObservers = [];
717
743
  afterObservers = [];
744
+ onChainedOperationError;
745
+ onReleaseError;
718
746
  constructor(subject, process, options = {}) {
719
747
  this.subject = subject;
720
748
  this.process = process;
@@ -729,6 +757,15 @@ var Statemachine = class {
729
757
  );
730
758
  }
731
759
  this.maxAutomaticHops = hops;
760
+ const maxQueue = options.maxQueueLength ?? Infinity;
761
+ if (maxQueue !== Infinity && (!Number.isInteger(maxQueue) || maxQueue < 1)) {
762
+ throw new RangeError(
763
+ `maxQueueLength must be a positive integer; got ${String(options.maxQueueLength)}`
764
+ );
765
+ }
766
+ this.maxQueueLength = maxQueue;
767
+ this.onChainedOperationError = options.onChainedOperationError;
768
+ this.onReleaseError = options.onReleaseError;
732
769
  }
733
770
  // --- public getters ---
734
771
  getCurrentState() {
@@ -745,6 +782,7 @@ var Statemachine = class {
745
782
  }
746
783
  // --- public observer attach/detach ---
747
784
  attachBefore(observer) {
785
+ if (this.beforeObservers.includes(observer)) return;
748
786
  this.beforeObservers.push(observer);
749
787
  }
750
788
  detachBefore(observer) {
@@ -755,6 +793,7 @@ var Statemachine = class {
755
793
  return this.beforeObservers;
756
794
  }
757
795
  attachAfter(observer) {
796
+ if (this.afterObservers.includes(observer)) return;
758
797
  this.afterObservers.push(observer);
759
798
  }
760
799
  detachAfter(observer) {
@@ -793,6 +832,21 @@ var Statemachine = class {
793
832
  this.enqueueOperation(null, context, resolve, reject);
794
833
  });
795
834
  }
835
+ /**
836
+ * Resolves once the operation queue is empty and the runner is idle —
837
+ * i.e. every operation enqueued so far, including operations chained via
838
+ * EnqueueContext.enqueue(), has completed. Resolves immediately if the
839
+ * machine is already idle. Note this is a quiescence point, not a
840
+ * receipt: work scheduled later (e.g. from a timer) starts a new drain.
841
+ */
842
+ whenIdle() {
843
+ if (!this.running && this.queue.isEmpty()) {
844
+ return Promise.resolve();
845
+ }
846
+ return new Promise((resolve) => {
847
+ this.idleWaiters.push(resolve);
848
+ });
849
+ }
796
850
  /** Runs fn with the re-entrancy flag set for its SYNCHRONOUS portion only:
797
851
  * the flag is cleared as soon as fn returns (before any promise it returned
798
852
  * is awaited), so concurrent external callers are never affected. This
@@ -814,6 +868,9 @@ var Statemachine = class {
814
868
  }
815
869
  /** Single entry point to the operation queue — every enqueue kicks the runner. */
816
870
  enqueueOperation(eventName, context, resolve, reject, ifStateName) {
871
+ if (this.queue.size() >= this.maxQueueLength) {
872
+ throw new QueueLimitExceededError(this.maxQueueLength, eventName);
873
+ }
817
874
  this.queue.enqueue({
818
875
  eventName,
819
876
  context: context ?? /* @__PURE__ */ new Map(),
@@ -834,6 +891,11 @@ var Statemachine = class {
834
891
  }
835
892
  } finally {
836
893
  this.running = false;
894
+ if (this.queue.isEmpty() && this.idleWaiters.length > 0) {
895
+ const waiters = this.idleWaiters;
896
+ this.idleWaiters = [];
897
+ for (const waiter of waiters) waiter();
898
+ }
837
899
  }
838
900
  }
839
901
  async runOperation(op) {
@@ -859,6 +921,10 @@ var Statemachine = class {
859
921
  try {
860
922
  await this.mutex.releaseLock();
861
923
  } catch (err) {
924
+ try {
925
+ this.onReleaseError?.(err);
926
+ } catch {
927
+ }
862
928
  if (!failure) failure = { err };
863
929
  }
864
930
  }
@@ -939,7 +1005,13 @@ var Statemachine = class {
939
1005
  chainedCtx,
940
1006
  () => {
941
1007
  },
942
- () => {
1008
+ (err) => {
1009
+ try {
1010
+ this.onChainedOperationError?.(err, {
1011
+ eventName: chainedEventName
1012
+ });
1013
+ } catch {
1014
+ }
943
1015
  },
944
1016
  ifStateName
945
1017
  );
@@ -1304,6 +1376,11 @@ var WeightTransition = class {
1304
1376
  innerSelector;
1305
1377
  epsilon;
1306
1378
  constructor(innerSelector, epsilon = 1e-3) {
1379
+ if (!Number.isFinite(epsilon) || epsilon <= 0) {
1380
+ throw new RangeError(
1381
+ `WeightTransition epsilon must be a finite number greater than 0; got ${String(epsilon)}`
1382
+ );
1383
+ }
1307
1384
  this.innerSelector = innerSelector ?? new OneOrNoneActiveTransition();
1308
1385
  this.epsilon = epsilon;
1309
1386
  }
@@ -1312,6 +1389,11 @@ var WeightTransition = class {
1312
1389
  let maxWeight = Number.NEGATIVE_INFINITY;
1313
1390
  for (const transition of all) {
1314
1391
  const weight = transition.getWeight();
1392
+ if (!Number.isFinite(weight)) {
1393
+ throw new RangeError(
1394
+ `WeightTransition: transition weights must be finite numbers; got ${String(weight)}`
1395
+ );
1396
+ }
1315
1397
  if (weight > maxWeight) maxWeight = weight;
1316
1398
  }
1317
1399
  const best = all.filter(
@@ -1486,6 +1568,14 @@ function toMermaidId(name) {
1486
1568
  function escapeMermaidLabel(str) {
1487
1569
  return str.replace(/\\/g, "#92;").replace(/"/g, "#quot;");
1488
1570
  }
1571
+ var VALID_DIRECTIONS = /* @__PURE__ */ new Set(["TB", "BT", "LR", "RL"]);
1572
+ function assertDirection(value, optionName) {
1573
+ if (!VALID_DIRECTIONS.has(value)) {
1574
+ throw new RangeError(
1575
+ `${optionName} must be one of "TB", "BT", "LR", "RL"; got ${JSON.stringify(value)}`
1576
+ );
1577
+ }
1578
+ }
1489
1579
  var GraphBuilder = class {
1490
1580
  nodes = /* @__PURE__ */ new Map();
1491
1581
  edges = [];
@@ -1561,6 +1651,7 @@ var GraphBuilder = class {
1561
1651
  toDot(options) {
1562
1652
  const graph = this.getGraph();
1563
1653
  const rankdir = options?.rankdir ?? "LR";
1654
+ assertDirection(rankdir, "rankdir");
1564
1655
  const lines = [];
1565
1656
  lines.push("digraph {");
1566
1657
  lines.push(` rankdir=${rankdir};`);
@@ -1580,6 +1671,7 @@ var GraphBuilder = class {
1580
1671
  toMermaid(options) {
1581
1672
  const graph = this.getGraph();
1582
1673
  const direction = options?.direction ?? "LR";
1674
+ assertDirection(direction, "direction");
1583
1675
  const lines = [];
1584
1676
  lines.push(`stateDiagram-v2`);
1585
1677
  lines.push(` direction ${direction}`);
@@ -1634,6 +1726,7 @@ export {
1634
1726
  ProcessBuilder,
1635
1727
  ProcessFinalizedError,
1636
1728
  ProcessNotFoundError,
1729
+ QueueLimitExceededError,
1637
1730
  ReentrancyError,
1638
1731
  ScoreTransition,
1639
1732
  SingleProcessDetector,