Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions ARCHITECTURE_REVIEW.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ class Subscription {

The condition calculates `lastStateChangedDate + timeoutMs` and returns `true` if that time is in the past (i.e., the timeout has elapsed).

> **`Timeout` is passive — it schedules nothing.** Like every condition, it is only evaluated when something drives the machine. A transition guarded by `Timeout` fires when the elapsed time has passed **and** someone calls `checkTransitions()` (or triggers an event). Nothing happens on its own at the deadline, so drive expiry from your own scheduler:
>
> ```typescript
> setInterval(() => {
> void sm.checkTransitions().catch(handleError);
> }, 60_000);
> ```

---

## AndComposite
Expand Down
37 changes: 22 additions & 15 deletions docs/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,21 +370,22 @@ new Statemachine<TSubject = unknown>(

### Methods

| Method | Return Type | Description |
| ------------------------------ | ------------------------ | --------------------------------------------------- |
| `getCurrentState()` | `StateInterface` | Returns the current state |
| `getLastState()` | `StateInterface \| null` | Returns the state before the most recent transition |
| `getSubject()` | `TSubject` | Returns the managed subject |
| `getProcess()` | `ProcessInterface` | Returns the process |
| `triggerEvent(name, context?)` | `Promise<void>` | Triggers a named event on the current state |
| `checkTransitions(context?)` | `Promise<void>` | Evaluates automatic transitions |
| `acquireLock()` | `Promise<boolean>` | Manually acquires the lock |
| `releaseLock()` | `Promise<void>` | Manually releases the lock |
| `isLockAcquired()` | `boolean` | Checks if the lock is currently acquired |
| `isAutoreleaseLock()` | `boolean` | Checks if auto-release is enabled |
| `setAutoreleaseLock(value)` | `void` | Enables/disables auto-release |
| `attachBefore(observer)` | `void` | Attaches a `BeforeTransitionObserver` |
| `attachAfter(observer)` | `void` | Attaches an `AfterTransitionObserver` |
| Method | Return Type | Description |
| ------------------------------ | ------------------------ | ----------------------------------------------------------- |
| `getCurrentState()` | `StateInterface` | Returns the current state |
| `getLastState()` | `StateInterface \| null` | Returns the state before the most recent transition |
| `getSubject()` | `TSubject` | Returns the managed subject |
| `getProcess()` | `ProcessInterface` | Returns the process |
| `triggerEvent(name, context?)` | `Promise<void>` | Triggers a named event on the current state |
| `checkTransitions(context?)` | `Promise<void>` | Evaluates automatic transitions |
| `whenIdle()` | `Promise<void>` | Resolves once the queue is drained and the runner is idle |
| `acquireLock()` | `Promise<boolean>` | Manually acquires the lock |
| `releaseLock()` | `Promise<void>` | Manually releases the lock; failures go to `onReleaseError` |
| `isLockAcquired()` | `boolean` | Checks if the lock is currently acquired |
| `isAutoreleaseLock()` | `boolean` | Checks if auto-release is enabled |
| `setAutoreleaseLock(value)` | `void` | Enables/disables auto-release |
| `attachBefore(observer)` | `void` | Attaches a `BeforeTransitionObserver` |
| `attachAfter(observer)` | `void` | Attaches an `AfterTransitionObserver` |

### Event Processing Flow

Expand Down Expand Up @@ -441,6 +442,12 @@ const sm = new Statemachine(subject, process);
sm.attachAfter(new AuditObserver());
```

`getBeforeObservers()` and `getAfterObservers()` return a **snapshot**: detaching an observer afterwards does not change a list you already hold, and mutating that list does not change the machine's registrations.

`triggerEvent()`, `checkTransitions()` and `whenIdle()` must not be called on the same machine from inside an observer, condition or selector — see [ReentrancyError](errors.md#reentrancyerror). Use the `EnqueueContext` given to after-observers to chain events instead.

The `frame.context` map is a per-transition copy shared by every observer of that frame; it is `ReadonlyMap` at compile time only. Treat it as read-only — a caller that casts it and mutates will affect the observers that run after it.

### Concurrency

Concurrent calls to `triggerEvent` and `checkTransitions` on the same instance are automatically serialized into a FIFO queue. There is no longer any "already running" error for same-instance concurrency.
Expand Down
71 changes: 65 additions & 6 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Custom error classes thrown by the state machine.
- [FinitaError](#finitaerror)
- [WrongEventForStateError](#wrongeventforstateerror)
- [LockCanNotBeAcquiredError](#lockcannotbeacquirederror)
- [LockCanNotBeReleasedError](#lockcannotbereleasederror)
- [DuplicateStateError](#duplicatestateerror)
- [ProcessFinalizedError](#processfinalizederror)
- [GraphValidationError](#graphvalidationerror)
Expand Down Expand Up @@ -109,6 +110,39 @@ With the default `NullMutex`, this error is never thrown because `acquireLock()`

---

## LockCanNotBeReleasedError

**Import:** `import { LockCanNotBeReleasedError } from '@camcima/finita'`

Thrown when the mutex reports a failed release by returning `false` — the failure signal `MutexInterface` and `LockAdapterInterface` define (a PostgreSQL advisory unlock that returns `false`, a Redis `DEL` that removed nothing).

### Properties

| Property | Type | Description |
| --------- | -------- | --------------------------------------------------------------------------------------- |
| `code` | `string` | `'lockCanNotBeReleased'` |
| `message` | `string` | `'Lock can not be released! releaseLock() returned false; the lock may still be held.'` |

### When It's Thrown

After a top-level operation completes, the engine releases the lock it acquired. If that release fails — whether the mutex **throws** or **returns `false`** — the failure is reported to `onReleaseError`, and:

- if the operation itself succeeded, the caller's promise rejects with the release error, because the lock may still be held;
- if the operation itself failed, the caller's promise rejects with the **operation** error (the release error is not masked over it) and `onReleaseError` is the only place the release failure appears.

A failed release must never be mistaken for a successful one: the engine skips acquisition when the mutex reports it is already held, so a silently stuck lock would let every later operation piggyback on it and never release it.

`Statemachine.releaseLock()` (manual lock management) reports failures through `onReleaseError` but does **not** throw, preserving its `Promise<void>` contract. Inspect `isLockAcquired()` to confirm the lock was freed.

```typescript
const sm = new Statemachine(order, process, {
mutex,
onReleaseError: (error) => logger.error("lock release failed", { error }),
});
```

---

## DuplicateStateError

**Import:** `import { DuplicateStateError } from '@camcima/finita'`
Expand Down Expand Up @@ -401,11 +435,36 @@ Thrown by `OneOrNoneActiveTransition.selectTransition(transitions)` when more th

### Properties

| Property | Type | Description |
| ------------- | ----------------------- | -------------------------------- |
| `code` | `"ambiguousTransition"` | Discriminator |
| `activeCount` | `number` | How many transitions were active |
| `name` | `string` | `'AmbiguousTransitionError'` |
| Property | Type | Description |
| ------------- | ----------------------------------------- | ------------------------------------- |
| `code` | `"ambiguousTransition"` | Discriminator |
| `activeCount` | `number` | How many transitions were active |
| `candidates` | `readonly AmbiguousTransitionCandidate[]` | The competing transitions (see below) |
| `name` | `string` | `'AmbiguousTransitionError'` |

### `AmbiguousTransitionCandidate` shape

Each candidate describes one of the simultaneously-active transitions, which is what you need to resolve the ambiguity — a count alone does not identify the culprits. The candidates are also rendered into the error message.

```typescript
interface AmbiguousTransitionCandidate {
targetStateName: string;
eventName: string | null; // null for automatic transitions
conditionName: string | null;
weight: number;
}
```

```typescript
try {
await sm.triggerEvent("submit");
} catch (error) {
if (error instanceof AmbiguousTransitionError) {
// e.g. ['approved', 'rejected'] — both guards passed
console.log(error.candidates.map((c) => c.targetStateName));
}
}
```

---

Expand Down Expand Up @@ -434,7 +493,7 @@ Concretely, the error is thrown on the automatic hop _after_ `maxAutomaticHops`

**Import:** `import { ReentrancyError } from '@camcima/finita'`

Rejects the promise returned by `triggerEvent()` / `checkTransitions()` when either is called from inside an observer, condition, or transition selector of the **same** `Statemachine` — before the callback's first `await`. Awaiting such a call would deadlock permanently: the machine runs one operation at a time, and the runner is blocked waiting for your callback to finish.
Rejects the promise returned by `triggerEvent()` / `checkTransitions()` / `whenIdle()` when any of them is called from inside an observer, condition, or transition selector of the **same** `Statemachine` — before the callback's first `await`. Awaiting such a call would deadlock permanently: the machine runs one operation at a time, and the runner is blocked waiting for your callback to finish. (`whenIdle()` is guarded for the same reason: the machine cannot reach idle while the runner is blocked on that very callback.)

**Detection scope:** only the _synchronous portion_ of a callback is guarded. A re-entrant call made **after** a prior `await` inside the callback cannot be detected (that would require Node-only `AsyncLocalStorage`) and will still deadlock silently. Keep re-entrant calls out of callbacks entirely.

Expand Down
30 changes: 29 additions & 1 deletion docs/factory.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,42 @@ flowchart TD
```typescript
new Factory<TSubject = unknown>(
processDetector: ProcessDetectorInterface<TSubject>,
stateNameDetector?: StateNameDetectorInterface<TSubject> | null
stateNameDetector?: StateNameDetectorInterface<TSubject> | null,
options?: FactoryStatemachineOptions<TSubject>
)
```

| Parameter | Type | Default | Description |
| ------------------- | ---------------------------------------------- | ---------- | ------------------------------------------------------------------------- |
| `processDetector` | `ProcessDetectorInterface<TSubject>` | (required) | Determines which process to use for the subject |
| `stateNameDetector` | `StateNameDetectorInterface<TSubject> \| null` | `null` | Detects the current state from the subject (for restoring state machines) |
| `options` | `FactoryStatemachineOptions<TSubject>` | `{}` | Engine options applied to every machine the factory creates |

### Engine options

`FactoryStatemachineOptions` is `StatemachineOptions` without the three fields
the factory derives per subject — `initialStateName` (from the state-name
detector), `mutex` (from the mutex factory) and `transitionSelector` (from
`setTransitionSelector`). Everything else is forwarded unchanged:
`autoreleaseLock`, `maxAutomaticHops`, `maxQueueLength`,
`onChainedOperationError` and `onReleaseError`.

This matters most for the fleet use case the factory exists to serve: without
it, a service creating one machine per order would silently run every machine
on the defaults, with no back-pressure and no diagnostic sinks.

```typescript
const factory = new Factory(
new SingleProcessDetector(orderProcess),
new StatefulStateNameDetector(),
{
maxQueueLength: 100,
onChainedOperationError: (error, info) =>
logger.error("chained op failed", { error, event: info.eventName }),
onReleaseError: (error) => logger.error("lock release failed", { error }),
},
);
```

### Methods

Expand Down
22 changes: 15 additions & 7 deletions docs/mutex.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ new LockAdapterMutex(lockAdapter: LockAdapterInterface, resourceName: string)

### Methods

| Method | Returns | Behavior |
| --------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `acquireLock()` | `Promise<boolean>` | If not already acquired, delegates to `lockAdapter.acquireLock(resourceName)`. Returns result. |
| `releaseLock()` | `Promise<boolean>` | If acquired, delegates to `lockAdapter.releaseLock(resourceName)`. Returns result. If not acquired, returns `false`. |
| `isAcquired()` | `boolean` | Returns local acquired state |
| `isLocked()` | `Promise<boolean>` | Delegates to `lockAdapter.isLocked(resourceName)` |
| Method | Returns | Behavior |
| --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `acquireLock()` | `Promise<boolean>` | If not already acquired, delegates to `lockAdapter.acquireLock(resourceName)`. Returns result. Overlapping calls share one underlying acquire, so a non-idempotent adapter is never acquired twice; a failed acquire can still be retried. |
| `releaseLock()` | `Promise<boolean>` | If acquired, delegates to `lockAdapter.releaseLock(resourceName)`. Returns result. If not acquired, returns `false`. |
| `isAcquired()` | `boolean` | Returns local acquired state |
| `isLocked()` | `Promise<boolean>` | Delegates to `lockAdapter.isLocked(resourceName)` |

### Example

Expand Down Expand Up @@ -337,7 +337,15 @@ sequenceDiagram

## Release Error Behavior

If the automatic release throws while the operation also failed, the caller's rejection carries the operation error; pass `onReleaseError` in `StatemachineOptions` to observe the release failure (e.g. to alert that the lock may still be held).
A release fails in one of two ways, and the engine treats them identically: the mutex **throws**, or it **returns `false`** — the failure signal this interface defines (a PostgreSQL advisory unlock returning `false`, a Redis `DEL` that removed nothing).

When the automatic release fails:

- `onReleaseError` (in `StatemachineOptions`) is called with the error — a `LockCanNotBeReleasedError` for the `false` case;
- if the operation itself succeeded, the caller's promise **rejects**, because the lock may still be held. Silently resolving would let every later operation piggyback on a stuck lock and never release it (the engine skips acquisition whenever the mutex reports it is already held);
- if the operation itself failed, the caller's rejection carries the **operation** error, and `onReleaseError` is the only place the release failure appears.

`Statemachine.releaseLock()` (manual lock management) reports failures through `onReleaseError` but does not throw, preserving its `Promise<void>` contract; check `isLockAcquired()` to confirm the lock was actually freed.

## Manual Lock Management

Expand Down
2 changes: 2 additions & 0 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,8 @@ class SendEmailCommand implements Observer {
process.getState("shipped").getEvent("ship").attach(new SendEmailCommand());
```

> **Event observers are shared by every machine built from the process.** `Event` objects live on the `Process` graph, and neither `Statemachine` nor `Factory` clones it. Attaching a command "for one order" attaches it to every machine that uses that process, so attach event commands once at setup and read the per-transition subject from `args[0]` (as above) rather than closing over one subject. For behavior that belongs to a single machine, use `attachAfter`/`attachBefore` on that machine instead.

### State Machine Observer (After)

React to any state change using the frame parameter:
Expand Down
4 changes: 3 additions & 1 deletion src/Event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ export class Event implements EventInterface {
}
}

/** Snapshot — detaching later does not change an already-returned list,
* and mutating it does not change the event's registrations. */
getObservers(): Iterable<Observer> {
return this.observers;
return [...this.observers];
}

getMetadata(): Record<string, unknown> {
Expand Down
Loading