@stravigor/machine 0.4.6
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/README.md +98 -0
- package/package.json +23 -0
- package/src/errors.ts +25 -0
- package/src/index.ts +4 -0
- package/src/machine.ts +134 -0
- package/src/stateful.ts +73 -0
- package/src/types.ts +68 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# @stravigor/machine
|
|
2
|
+
|
|
3
|
+
State machine for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Declarative state definitions with transitions, guards, side effects, and event emission.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/machine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Define a Machine
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineMachine } from '@stravigor/machine'
|
|
19
|
+
|
|
20
|
+
const orderMachine = defineMachine({
|
|
21
|
+
field: 'status',
|
|
22
|
+
initial: 'pending',
|
|
23
|
+
states: ['pending', 'processing', 'shipped', 'delivered', 'canceled'],
|
|
24
|
+
transitions: {
|
|
25
|
+
process: { from: 'pending', to: 'processing' },
|
|
26
|
+
ship: { from: 'processing', to: 'shipped' },
|
|
27
|
+
deliver: { from: 'shipped', to: 'delivered' },
|
|
28
|
+
cancel: { from: ['pending', 'processing'], to: 'canceled' },
|
|
29
|
+
},
|
|
30
|
+
guards: {
|
|
31
|
+
cancel: (order) => !order.locked,
|
|
32
|
+
},
|
|
33
|
+
effects: {
|
|
34
|
+
ship: async (order) => {
|
|
35
|
+
await sendShippingNotification(order)
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
events: {
|
|
39
|
+
ship: 'order:shipped',
|
|
40
|
+
deliver: 'order:delivered',
|
|
41
|
+
cancel: 'order:canceled',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Standalone (Any Object)
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const order = { status: 'pending', id: 1, locked: false }
|
|
50
|
+
|
|
51
|
+
orderMachine.state(order) // 'pending'
|
|
52
|
+
orderMachine.is(order, 'pending') // true
|
|
53
|
+
orderMachine.can(order, 'process') // true
|
|
54
|
+
orderMachine.can(order, 'ship') // false
|
|
55
|
+
orderMachine.availableTransitions(order) // ['process', 'cancel']
|
|
56
|
+
|
|
57
|
+
await orderMachine.apply(order, 'process')
|
|
58
|
+
// order.status === 'processing'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### ORM Mixin
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { BaseModel } from '@stravigor/core/orm'
|
|
65
|
+
import { stateful } from '@stravigor/machine'
|
|
66
|
+
|
|
67
|
+
class Order extends stateful(BaseModel, orderMachine) {
|
|
68
|
+
declare id: number
|
|
69
|
+
declare status: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const order = await Order.find(1)
|
|
73
|
+
order.is('pending') // true
|
|
74
|
+
order.can('ship') // false
|
|
75
|
+
order.availableTransitions() // ['process', 'cancel']
|
|
76
|
+
|
|
77
|
+
await order.transition('process')
|
|
78
|
+
// validates → mutates → saves → emits event
|
|
79
|
+
|
|
80
|
+
// Query helpers
|
|
81
|
+
const shipped = await Order.inState('shipped').get()
|
|
82
|
+
const active = await Order.inState(['processing', 'shipped']).get()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Features
|
|
86
|
+
|
|
87
|
+
- **Guards** — sync or async functions that must return `true` for a transition to proceed
|
|
88
|
+
- **Effects** — side effects that run after the state is mutated (before persistence in the mixin)
|
|
89
|
+
- **Events** — automatic `Emitter.emit()` after transitions complete
|
|
90
|
+
- **Composable** — works with `compose()` alongside other mixins like `searchable()`
|
|
91
|
+
|
|
92
|
+
## Documentation
|
|
93
|
+
|
|
94
|
+
See the full [Machine guide](../../guides/machine.md).
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/machine",
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "State machine for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"package.json",
|
|
14
|
+
"tsconfig.json"
|
|
15
|
+
],
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@stravigor/core": "0.4.5"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "bun test tests/",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/core/exceptions'
|
|
2
|
+
|
|
3
|
+
/** Thrown when a transition is not valid from the entity's current state. */
|
|
4
|
+
export class TransitionError extends StravError {
|
|
5
|
+
constructor(
|
|
6
|
+
public readonly transition: string,
|
|
7
|
+
public readonly currentState: string,
|
|
8
|
+
public readonly allowedFrom: string[]
|
|
9
|
+
) {
|
|
10
|
+
super(
|
|
11
|
+
`Cannot apply transition "${transition}" from state "${currentState}". ` +
|
|
12
|
+
`Allowed from: [${allowedFrom.join(', ')}]`
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Thrown when a transition's guard rejects the transition. */
|
|
18
|
+
export class GuardError extends StravError {
|
|
19
|
+
constructor(
|
|
20
|
+
public readonly transition: string,
|
|
21
|
+
public readonly currentState: string
|
|
22
|
+
) {
|
|
23
|
+
super(`Guard rejected transition "${transition}" from state "${currentState}".`)
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
package/src/machine.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
2
|
+
import type { MachineDefinition, Machine, TransitionMeta } from './types.ts'
|
|
3
|
+
import { TransitionError, GuardError } from './errors.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Define a state machine.
|
|
7
|
+
*
|
|
8
|
+
* Returns a `Machine` object that can validate and apply transitions to any entity.
|
|
9
|
+
* The machine operates on a single field of the entity object.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const orderMachine = defineMachine({
|
|
13
|
+
* field: 'status',
|
|
14
|
+
* initial: 'pending',
|
|
15
|
+
* states: ['pending', 'processing', 'shipped', 'delivered', 'canceled'],
|
|
16
|
+
* transitions: {
|
|
17
|
+
* process: { from: 'pending', to: 'processing' },
|
|
18
|
+
* ship: { from: 'processing', to: 'shipped' },
|
|
19
|
+
* deliver: { from: 'shipped', to: 'delivered' },
|
|
20
|
+
* cancel: { from: ['pending', 'processing'], to: 'canceled' },
|
|
21
|
+
* },
|
|
22
|
+
* guards: {
|
|
23
|
+
* cancel: (order) => !order.locked,
|
|
24
|
+
* },
|
|
25
|
+
* effects: {
|
|
26
|
+
* ship: async (order) => await sendShippingNotification(order),
|
|
27
|
+
* },
|
|
28
|
+
* events: {
|
|
29
|
+
* ship: 'order:shipped',
|
|
30
|
+
* deliver: 'order:delivered',
|
|
31
|
+
* },
|
|
32
|
+
* })
|
|
33
|
+
*/
|
|
34
|
+
export function defineMachine<TState extends string, TTransition extends string>(
|
|
35
|
+
definition: MachineDefinition<TState, TTransition>
|
|
36
|
+
): Machine<TState, TTransition> {
|
|
37
|
+
// Pre-compute normalized from-arrays for each transition
|
|
38
|
+
const fromMap = new Map<TTransition, TState[]>()
|
|
39
|
+
for (const [name, def] of Object.entries(definition.transitions) as [
|
|
40
|
+
TTransition,
|
|
41
|
+
{ from: TState | TState[]; to: TState },
|
|
42
|
+
][]) {
|
|
43
|
+
fromMap.set(name, Array.isArray(def.from) ? def.from : [def.from])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
definition,
|
|
48
|
+
|
|
49
|
+
state(entity: any): TState {
|
|
50
|
+
return entity[definition.field] as TState
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
is(entity: any, state: TState): boolean {
|
|
54
|
+
return entity[definition.field] === state
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
can(entity: any, transition: TTransition): boolean | Promise<boolean> {
|
|
58
|
+
const currentState = entity[definition.field] as TState
|
|
59
|
+
const allowed = fromMap.get(transition)
|
|
60
|
+
if (!allowed || !allowed.includes(currentState)) return false
|
|
61
|
+
|
|
62
|
+
const guard = definition.guards?.[transition]
|
|
63
|
+
if (!guard) return true
|
|
64
|
+
|
|
65
|
+
const result = guard(entity)
|
|
66
|
+
// Support both sync and async guards
|
|
67
|
+
if (typeof (result as any)?.then === 'function') {
|
|
68
|
+
return (result as Promise<boolean>).catch(() => false)
|
|
69
|
+
}
|
|
70
|
+
return result as boolean
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
availableTransitions(entity: any): TTransition[] {
|
|
74
|
+
const currentState = entity[definition.field] as TState
|
|
75
|
+
const available: TTransition[] = []
|
|
76
|
+
|
|
77
|
+
for (const [name, fromStates] of fromMap) {
|
|
78
|
+
if (fromStates.includes(currentState)) {
|
|
79
|
+
available.push(name)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return available
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async apply(
|
|
87
|
+
entity: any,
|
|
88
|
+
transition: TTransition
|
|
89
|
+
): Promise<TransitionMeta<TState, TTransition>> {
|
|
90
|
+
const currentState = entity[definition.field] as TState
|
|
91
|
+
const transitionDef = definition.transitions[transition]
|
|
92
|
+
if (!transitionDef) {
|
|
93
|
+
throw new TransitionError(transition, currentState, [])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const allowed = fromMap.get(transition)!
|
|
97
|
+
if (!allowed.includes(currentState)) {
|
|
98
|
+
throw new TransitionError(transition, currentState, allowed)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Run guard
|
|
102
|
+
const guard = definition.guards?.[transition]
|
|
103
|
+
if (guard) {
|
|
104
|
+
const passed = await guard(entity)
|
|
105
|
+
if (!passed) {
|
|
106
|
+
throw new GuardError(transition, currentState)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const meta: TransitionMeta<TState, TTransition> = {
|
|
111
|
+
from: currentState,
|
|
112
|
+
to: transitionDef.to,
|
|
113
|
+
transition,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Mutate field
|
|
117
|
+
entity[definition.field] = transitionDef.to
|
|
118
|
+
|
|
119
|
+
// Run effect
|
|
120
|
+
const effect = definition.effects?.[transition]
|
|
121
|
+
if (effect) {
|
|
122
|
+
await effect(entity, meta)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Emit event
|
|
126
|
+
const eventName = definition.events?.[transition]
|
|
127
|
+
if (eventName && Emitter.listenerCount(eventName) > 0) {
|
|
128
|
+
Emitter.emit(eventName, { entity, ...meta }).catch(() => {})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return meta
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/stateful.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type BaseModel from '@stravigor/core/orm/base_model'
|
|
2
|
+
import type { NormalizeConstructor } from '@stravigor/core/helpers'
|
|
3
|
+
import type { Machine, TransitionMeta } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mixin that adds state machine methods to a BaseModel subclass.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { BaseModel } from '@stravigor/core/orm'
|
|
10
|
+
* import { defineMachine, stateful } from '@stravigor/machine'
|
|
11
|
+
*
|
|
12
|
+
* const orderMachine = defineMachine({
|
|
13
|
+
* field: 'status',
|
|
14
|
+
* initial: 'pending',
|
|
15
|
+
* states: ['pending', 'processing', 'shipped'],
|
|
16
|
+
* transitions: {
|
|
17
|
+
* process: { from: 'pending', to: 'processing' },
|
|
18
|
+
* ship: { from: 'processing', to: 'shipped' },
|
|
19
|
+
* },
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* class Order extends stateful(BaseModel, orderMachine) {
|
|
23
|
+
* declare id: number
|
|
24
|
+
* declare status: string
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* const order = await Order.find(1)
|
|
28
|
+
* order.is('pending') // true
|
|
29
|
+
* order.can('process') // true
|
|
30
|
+
* await order.transition('process') // validates, mutates, saves, emits
|
|
31
|
+
*
|
|
32
|
+
* // Composable with other mixins:
|
|
33
|
+
* import { compose } from '@stravigor/core/helpers'
|
|
34
|
+
* class Order extends compose(BaseModel, searchable, m => stateful(m, orderMachine)) { }
|
|
35
|
+
*/
|
|
36
|
+
export function stateful<T extends NormalizeConstructor<typeof BaseModel>>(
|
|
37
|
+
Base: T,
|
|
38
|
+
machine: Machine
|
|
39
|
+
) {
|
|
40
|
+
return class Stateful extends Base {
|
|
41
|
+
/** Check if this model instance is in the given state. */
|
|
42
|
+
is(state: string): boolean {
|
|
43
|
+
return machine.is(this, state)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Check if a transition can be applied to this model instance. */
|
|
47
|
+
can(transition: string): boolean | Promise<boolean> {
|
|
48
|
+
return machine.can(this, transition)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** List all transitions available from this model's current state. */
|
|
52
|
+
availableTransitions(): string[] {
|
|
53
|
+
return machine.availableTransitions(this)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply a transition: validate, mutate, save, and emit event.
|
|
58
|
+
* Throws `TransitionError` if the transition is invalid from the current state.
|
|
59
|
+
* Throws `GuardError` if the guard rejects the transition.
|
|
60
|
+
*/
|
|
61
|
+
async transition(name: string): Promise<TransitionMeta> {
|
|
62
|
+
const meta = await machine.apply(this, name)
|
|
63
|
+
await this.save()
|
|
64
|
+
return meta
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Query scope: filter records in the given state(s). */
|
|
68
|
+
static inState(state: string | string[]) {
|
|
69
|
+
const states = Array.isArray(state) ? state : [state]
|
|
70
|
+
return (this as unknown as typeof BaseModel).query().whereIn(machine.definition.field, states)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ── Machine Definition ──────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface TransitionDefinition<TState extends string = string> {
|
|
4
|
+
from: TState | TState[]
|
|
5
|
+
to: TState
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface MachineDefinition<
|
|
9
|
+
TState extends string = string,
|
|
10
|
+
TTransition extends string = string,
|
|
11
|
+
> {
|
|
12
|
+
/** The field on the entity that holds the state value. */
|
|
13
|
+
field: string
|
|
14
|
+
/** The initial state for new entities. */
|
|
15
|
+
initial: TState
|
|
16
|
+
/** All valid states. */
|
|
17
|
+
states: TState[]
|
|
18
|
+
/** Named transitions with from/to state definitions. */
|
|
19
|
+
transitions: Record<TTransition, TransitionDefinition<TState>>
|
|
20
|
+
/** Guard functions that must return true for a transition to proceed. */
|
|
21
|
+
guards?: Partial<Record<TTransition, (entity: any) => boolean | Promise<boolean>>>
|
|
22
|
+
/** Side effects to run after a transition (before persistence). */
|
|
23
|
+
effects?: Partial<
|
|
24
|
+
Record<
|
|
25
|
+
TTransition,
|
|
26
|
+
(entity: any, meta: TransitionMeta<TState, TTransition>) => void | Promise<void>
|
|
27
|
+
>
|
|
28
|
+
>
|
|
29
|
+
/** Event names to emit via Emitter after a transition completes. */
|
|
30
|
+
events?: Partial<Record<TTransition, string>>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Transition Meta ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface TransitionMeta<
|
|
36
|
+
TState extends string = string,
|
|
37
|
+
TTransition extends string = string,
|
|
38
|
+
> {
|
|
39
|
+
from: TState
|
|
40
|
+
to: TState
|
|
41
|
+
transition: TTransition
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Machine Interface ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface Machine<TState extends string = string, TTransition extends string = string> {
|
|
47
|
+
/** The machine definition. */
|
|
48
|
+
readonly definition: MachineDefinition<TState, TTransition>
|
|
49
|
+
|
|
50
|
+
/** Get the current state of an entity. */
|
|
51
|
+
state(entity: any): TState
|
|
52
|
+
|
|
53
|
+
/** Check if an entity is in a specific state. */
|
|
54
|
+
is(entity: any, state: TState): boolean
|
|
55
|
+
|
|
56
|
+
/** Check if a transition can be applied (valid from-state + guard passes). */
|
|
57
|
+
can(entity: any, transition: TTransition): boolean | Promise<boolean>
|
|
58
|
+
|
|
59
|
+
/** List all transitions available from the entity's current state. */
|
|
60
|
+
availableTransitions(entity: any): TTransition[]
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apply a transition to an entity.
|
|
64
|
+
* Validates the from-state, runs the guard, mutates the field, runs the effect, and emits the event.
|
|
65
|
+
* Does NOT persist — caller is responsible for saving.
|
|
66
|
+
*/
|
|
67
|
+
apply(entity: any, transition: TTransition): Promise<TransitionMeta<TState, TTransition>>
|
|
68
|
+
}
|
package/tsconfig.json
ADDED