@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 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
@@ -0,0 +1,4 @@
1
+ export { defineMachine } from './machine.ts'
2
+ export { stateful } from './stateful.ts'
3
+ export { TransitionError, GuardError } from './errors.ts'
4
+ export type { MachineDefinition, TransitionDefinition, TransitionMeta, Machine } from './types.ts'
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
+ }
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }