@strav/machine 0.4.31 → 1.0.0-alpha.8
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 +19 -14
- package/src/define_machine.ts +136 -0
- package/src/guard_error.ts +21 -0
- package/src/index.ts +17 -4
- package/src/machine.ts +52 -140
- package/src/machine_definition.ts +83 -0
- package/src/stateful.ts +96 -54
- package/src/transition_error.ts +35 -0
- package/README.md +0 -98
- package/src/errors.ts +0 -27
- package/src/types.ts +0 -68
- package/tsconfig.json +0 -5
package/package.json
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/machine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
4
|
+
"description": "Strav state machines — declarative transitions with guards, effects, events + a stateful() Repository mixin.",
|
|
4
5
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
7
8
|
"exports": {
|
|
8
|
-
".": "./src/index.ts"
|
|
9
|
-
"./*": "./src/*.ts"
|
|
9
|
+
".": "./src/index.ts"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"src
|
|
13
|
-
"
|
|
14
|
-
"tsconfig.json"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
15
14
|
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@strav/kernel": "1.0.0-alpha.8",
|
|
23
|
+
"@strav/database": "1.0.0-alpha.8"
|
|
24
|
+
},
|
|
16
25
|
"peerDependencies": {
|
|
17
|
-
"@
|
|
18
|
-
"@strav/database": "0.4.31"
|
|
26
|
+
"@types/bun": ">=1.3.14"
|
|
19
27
|
},
|
|
20
|
-
"
|
|
21
|
-
"test": "bun test tests/",
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
-
}
|
|
28
|
+
"devDependencies": null
|
|
24
29
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineMachine(...)` — build a `Machine` from a declarative definition.
|
|
3
|
+
*
|
|
4
|
+
* The returned object is a pure (no DI, no DB, no event bus) value: the
|
|
5
|
+
* same machine works for an in-memory POJO, a Repository-managed row,
|
|
6
|
+
* or a request DTO. Persistence and event emission are layered on top
|
|
7
|
+
* via the `stateful(...)` Repository mixin.
|
|
8
|
+
*
|
|
9
|
+
* The `field` and `transitions` are required; `guards`, `effects`, and
|
|
10
|
+
* `events` are optional. A machine with no guards/effects/events is a
|
|
11
|
+
* pure transition validator — still useful for `can()` / `availableTransitions()`.
|
|
12
|
+
*
|
|
13
|
+
* Example:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* type OrderState = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'
|
|
17
|
+
* type OrderTransition = 'process' | 'ship' | 'deliver' | 'cancel'
|
|
18
|
+
*
|
|
19
|
+
* export const orderMachine = defineMachine<Order, OrderState, OrderTransition>({
|
|
20
|
+
* field: 'status',
|
|
21
|
+
* initial: 'pending',
|
|
22
|
+
* states: ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
|
|
23
|
+
* transitions: {
|
|
24
|
+
* process: { from: 'pending', to: 'processing' },
|
|
25
|
+
* ship: { from: 'processing', to: 'shipped' },
|
|
26
|
+
* deliver: { from: 'shipped', to: 'delivered' },
|
|
27
|
+
* cancel: { from: ['pending', 'processing'], to: 'cancelled' },
|
|
28
|
+
* },
|
|
29
|
+
* guards: { cancel: (order) => !order.locked },
|
|
30
|
+
* effects: { ship: async (order) => sendShippingNotification(order) },
|
|
31
|
+
* events: { ship: 'order.shipped' },
|
|
32
|
+
* })
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { GuardError } from './guard_error.ts'
|
|
37
|
+
import type { Machine } from './machine.ts'
|
|
38
|
+
import type {
|
|
39
|
+
MachineDefinition,
|
|
40
|
+
TransitionMeta,
|
|
41
|
+
} from './machine_definition.ts'
|
|
42
|
+
import { TransitionError } from './transition_error.ts'
|
|
43
|
+
|
|
44
|
+
export function defineMachine<
|
|
45
|
+
TEntity extends object,
|
|
46
|
+
TState extends string,
|
|
47
|
+
TTransition extends string,
|
|
48
|
+
>(
|
|
49
|
+
definition: MachineDefinition<TEntity, TState, TTransition>,
|
|
50
|
+
): Machine<TEntity, TState, TTransition> {
|
|
51
|
+
// Pre-compute the `from` array for each transition so `can()` and
|
|
52
|
+
// `availableTransitions()` don't re-normalize on every call.
|
|
53
|
+
const fromMap = new Map<TTransition, readonly TState[]>()
|
|
54
|
+
for (const [name, def] of Object.entries(definition.transitions) as Array<
|
|
55
|
+
[TTransition, TransitionDef<TState>]
|
|
56
|
+
>) {
|
|
57
|
+
fromMap.set(name, Array.isArray(def.from) ? def.from : [def.from])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readField = (entity: TEntity): TState =>
|
|
61
|
+
(entity as Record<string, unknown>)[definition.field] as TState
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
definition,
|
|
65
|
+
|
|
66
|
+
state: readField,
|
|
67
|
+
|
|
68
|
+
is: (entity, state) => readField(entity) === state,
|
|
69
|
+
|
|
70
|
+
can(entity, transition) {
|
|
71
|
+
const allowed = fromMap.get(transition)
|
|
72
|
+
if (!allowed) return false
|
|
73
|
+
const current = readField(entity)
|
|
74
|
+
if (!allowed.includes(current)) return false
|
|
75
|
+
const guard = definition.guards?.[transition]
|
|
76
|
+
if (!guard) return true
|
|
77
|
+
const result = guard(entity)
|
|
78
|
+
// Preserve sync vs async — apps that want a boolean today
|
|
79
|
+
// shouldn't be forced into a Promise when no guard is async.
|
|
80
|
+
if (result instanceof Promise) return result
|
|
81
|
+
return result
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
availableTransitions(entity) {
|
|
85
|
+
const current = readField(entity)
|
|
86
|
+
const available: TTransition[] = []
|
|
87
|
+
for (const [name, allowed] of fromMap) {
|
|
88
|
+
if (allowed.includes(current)) available.push(name)
|
|
89
|
+
}
|
|
90
|
+
return available
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async apply(entity, transition) {
|
|
94
|
+
const def = definition.transitions[transition]
|
|
95
|
+
const current = readField(entity)
|
|
96
|
+
if (!def) {
|
|
97
|
+
throw new TransitionError(transition, current)
|
|
98
|
+
}
|
|
99
|
+
const allowed = fromMap.get(transition) ?? []
|
|
100
|
+
if (!allowed.includes(current)) {
|
|
101
|
+
throw new TransitionError(transition, current, allowed)
|
|
102
|
+
}
|
|
103
|
+
const guard = definition.guards?.[transition]
|
|
104
|
+
if (guard) {
|
|
105
|
+
const passed = await guard(entity)
|
|
106
|
+
if (!passed) {
|
|
107
|
+
throw new GuardError(transition, current)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const meta: TransitionMeta<TState, TTransition> = {
|
|
112
|
+
from: current,
|
|
113
|
+
to: def.to,
|
|
114
|
+
transition,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Mutate first so any effect sees the new state on the entity.
|
|
118
|
+
// Cast through `unknown` because `TState` is more specific than
|
|
119
|
+
// `unknown` and `Record<string, unknown>` widens to it.
|
|
120
|
+
;(entity as Record<string, unknown>)[definition.field] = def.to
|
|
121
|
+
|
|
122
|
+
const effect = definition.effects?.[transition]
|
|
123
|
+
if (effect) {
|
|
124
|
+
await effect(entity, meta)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return meta
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Narrow alias for the inner-loop type assertion above; not exported.
|
|
133
|
+
interface TransitionDef<TState extends string> {
|
|
134
|
+
from: TState | readonly TState[]
|
|
135
|
+
to: TState
|
|
136
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a transition's guard returns `false` (or its Promise
|
|
3
|
+
* resolves to `false`). Distinct from `TransitionError` so apps can
|
|
4
|
+
* render different UX for "you can't do that from this state" vs
|
|
5
|
+
* "you can't do that right now" — both 422, different `code`.
|
|
6
|
+
*
|
|
7
|
+
* A guard that *throws* (rather than returning `false`) propagates the
|
|
8
|
+
* throw verbatim; this error fires only on the "guard said no" path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { StravError } from '@strav/kernel'
|
|
12
|
+
|
|
13
|
+
export class GuardError extends StravError {
|
|
14
|
+
constructor(transition: string, from: string) {
|
|
15
|
+
super(
|
|
16
|
+
`Guard rejected transition "${transition}" from state "${from}".`,
|
|
17
|
+
{ code: 'machine.guard-rejected', status: 422 },
|
|
18
|
+
{ context: { transition, from } },
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Public API of @strav/machine.
|
|
2
|
+
//
|
|
3
|
+
// State machines + a `stateful()` Repository mixin. The machine itself is
|
|
4
|
+
// pure (no DI, no DB, no event bus). The mixin layers in persistence
|
|
5
|
+
// via Repository.update + optional event emission via the Repository's
|
|
6
|
+
// EventBus.
|
|
7
|
+
|
|
8
|
+
export { defineMachine } from './define_machine.ts'
|
|
9
|
+
export { GuardError } from './guard_error.ts'
|
|
10
|
+
export type { Machine } from './machine.ts'
|
|
11
|
+
export type {
|
|
12
|
+
MachineDefinition,
|
|
13
|
+
TransitionDefinition,
|
|
14
|
+
TransitionMeta,
|
|
15
|
+
} from './machine_definition.ts'
|
|
16
|
+
export { type RepositoryConstructor, stateful } from './stateful.ts'
|
|
17
|
+
export { TransitionError } from './transition_error.ts'
|
package/src/machine.ts
CHANGED
|
@@ -1,146 +1,58 @@
|
|
|
1
|
-
import { Emitter } from '@strav/kernel'
|
|
2
|
-
import type { MachineDefinition, Machine, TransitionMeta } from './types.ts'
|
|
3
|
-
import { TransitionError, GuardError } from './errors.ts'
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
2
|
+
* `Machine` — the runtime interface produced by `defineMachine(...)`.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
9
|
-
* The
|
|
4
|
+
* Methods operate on any entity object that has the configured `field`.
|
|
5
|
+
* The interface is decoupled from `@strav/database` so apps can use
|
|
6
|
+
* machines on POJOs, query result rows, request payloads, etc. The
|
|
7
|
+
* `stateful(...)` mixin in `stateful.ts` is the convenience wrapper for
|
|
8
|
+
* the common case ("apply a transition and save it through a
|
|
9
|
+
* Repository, optionally emit").
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
* })
|
|
11
|
+
* `apply()` mutates the entity in-place and returns the `TransitionMeta`
|
|
12
|
+
* for the move. It does NOT persist — callers handle persistence
|
|
13
|
+
* (the `stateful` mixin does it for you).
|
|
33
14
|
*/
|
|
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>
|
|
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 user-defined per-transition event (zero-cost when no listener).
|
|
126
|
-
const eventName = definition.events?.[transition]
|
|
127
|
-
if (eventName && Emitter.listenerCount(eventName) > 0) {
|
|
128
|
-
Emitter.emit(eventName, { entity, ...meta }).catch(() => {})
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Emit a generic state-transition event so a single audit hook can
|
|
132
|
-
// observe every transition across every machine without each
|
|
133
|
-
// definition wiring an `events.*` entry. Zero-cost when no
|
|
134
|
-
// listener is registered.
|
|
135
|
-
if (Emitter.listenerCount('machine:transition') > 0) {
|
|
136
|
-
Emitter.emit('machine:transition', {
|
|
137
|
-
entity,
|
|
138
|
-
field: definition.field,
|
|
139
|
-
...meta,
|
|
140
|
-
}).catch(() => {})
|
|
141
|
-
}
|
|
142
15
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
16
|
+
import type {
|
|
17
|
+
MachineDefinition,
|
|
18
|
+
TransitionMeta,
|
|
19
|
+
} from './machine_definition.ts'
|
|
20
|
+
|
|
21
|
+
export interface Machine<
|
|
22
|
+
TEntity extends object = object,
|
|
23
|
+
TState extends string = string,
|
|
24
|
+
TTransition extends string = string,
|
|
25
|
+
> {
|
|
26
|
+
/** The raw definition passed to `defineMachine`. Exposed for reflection. */
|
|
27
|
+
readonly definition: MachineDefinition<TEntity, TState, TTransition>
|
|
28
|
+
|
|
29
|
+
/** Read the current state directly off the entity's configured field. */
|
|
30
|
+
state(entity: TEntity): TState
|
|
31
|
+
|
|
32
|
+
/** Boolean equality on the current state. */
|
|
33
|
+
is(entity: TEntity, state: TState): boolean
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `true` when (a) the transition is defined, (b) the entity's current
|
|
37
|
+
* state is one of the `from` states, and (c) the guard (if any)
|
|
38
|
+
* resolves to `true`. Falls through to a synchronous boolean when no
|
|
39
|
+
* guard is registered; otherwise returns a `Promise<boolean>` matching
|
|
40
|
+
* the guard's signature.
|
|
41
|
+
*/
|
|
42
|
+
can(entity: TEntity, transition: TTransition): boolean | Promise<boolean>
|
|
43
|
+
|
|
44
|
+
/** Names of every transition currently valid from the entity's state, ignoring guards. */
|
|
45
|
+
availableTransitions(entity: TEntity): TTransition[]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate the transition + run the guard + mutate the field + run the
|
|
49
|
+
* effect, in that order. Throws `TransitionError` for an undefined or
|
|
50
|
+
* disallowed transition; `GuardError` when the guard returns `false`;
|
|
51
|
+
* propagates any throw from guard or effect verbatim. Does **not**
|
|
52
|
+
* persist or emit — the `stateful(...)` mixin does both.
|
|
53
|
+
*/
|
|
54
|
+
apply(
|
|
55
|
+
entity: TEntity,
|
|
56
|
+
transition: TTransition,
|
|
57
|
+
): Promise<TransitionMeta<TState, TTransition>>
|
|
146
58
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type contracts for `defineMachine(...)`. Apps don't typically import
|
|
3
|
+
* these directly — they fall out of the generics on `Machine<TEntity,
|
|
4
|
+
* TState, TTransition>`. They're exported so library code that holds
|
|
5
|
+
* machines generically can still type-narrow.
|
|
6
|
+
*
|
|
7
|
+
* `field` is the property name on the entity that holds the state value.
|
|
8
|
+
* The machine reads + mutates it via `entity[field]`. Convention is a
|
|
9
|
+
* snake_case column name (`status`, `state`, `workflow_phase`), but
|
|
10
|
+
* anything that lives on the entity works.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A single transition: which state(s) can `from`, and which state lands as `to`. */
|
|
14
|
+
export interface TransitionDefinition<TState extends string = string> {
|
|
15
|
+
/** Source state, or a list of source states. */
|
|
16
|
+
from: TState | readonly TState[]
|
|
17
|
+
/** Destination state. */
|
|
18
|
+
to: TState
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a guard / effect / `transition()` callback receives. */
|
|
22
|
+
export interface TransitionMeta<
|
|
23
|
+
TState extends string = string,
|
|
24
|
+
TTransition extends string = string,
|
|
25
|
+
> {
|
|
26
|
+
readonly from: TState
|
|
27
|
+
readonly to: TState
|
|
28
|
+
readonly transition: TTransition
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Full machine definition — the input to `defineMachine(...)`.
|
|
33
|
+
*
|
|
34
|
+
* `guards`, `effects`, and `events` are all keyed on transition names.
|
|
35
|
+
* Each is optional — a machine with no guards/effects/events is valid
|
|
36
|
+
* and useful (pure transition validation).
|
|
37
|
+
*/
|
|
38
|
+
export interface MachineDefinition<
|
|
39
|
+
TEntity extends object = object,
|
|
40
|
+
TState extends string = string,
|
|
41
|
+
TTransition extends string = string,
|
|
42
|
+
> {
|
|
43
|
+
/** Property name on the entity that holds the state. */
|
|
44
|
+
field: string
|
|
45
|
+
/** Initial state for newly-created entities — apps that want to reuse it. */
|
|
46
|
+
initial: TState
|
|
47
|
+
/** All valid state values — used as a validation reference and a published surface. */
|
|
48
|
+
states: readonly TState[]
|
|
49
|
+
/** Named transitions with `from` (one or many) + `to`. */
|
|
50
|
+
transitions: Record<TTransition, TransitionDefinition<TState>>
|
|
51
|
+
/**
|
|
52
|
+
* Guards run AFTER the state-validity check but BEFORE the mutation.
|
|
53
|
+
* Return `false` (sync or async) to block the transition with
|
|
54
|
+
* `GuardError`. Throwing also blocks; the throw propagates verbatim.
|
|
55
|
+
*/
|
|
56
|
+
guards?: Partial<
|
|
57
|
+
Record<TTransition, (entity: TEntity) => boolean | Promise<boolean>>
|
|
58
|
+
>
|
|
59
|
+
/**
|
|
60
|
+
* Effects run AFTER the entity field is mutated. Use them for
|
|
61
|
+
* in-process side work that needs to see the new state on the
|
|
62
|
+
* entity (`order.status === 'shipped'`). For external side effects
|
|
63
|
+
* that must be transactional with the save, dispatch a Job from the
|
|
64
|
+
* effect — `@strav/queue` queues until the surrounding tx commits.
|
|
65
|
+
*/
|
|
66
|
+
effects?: Partial<
|
|
67
|
+
Record<
|
|
68
|
+
TTransition,
|
|
69
|
+
(
|
|
70
|
+
entity: TEntity,
|
|
71
|
+
meta: TransitionMeta<TState, TTransition>,
|
|
72
|
+
) => void | Promise<void>
|
|
73
|
+
>
|
|
74
|
+
>
|
|
75
|
+
/**
|
|
76
|
+
* Optional event-bus names emitted on successful transitions. Only
|
|
77
|
+
* fires when the transition is applied via the `stateful(...)`
|
|
78
|
+
* Repository mixin (which has access to the Repository's EventBus).
|
|
79
|
+
* Standalone `machine.apply(entity, name)` skips emission — it has
|
|
80
|
+
* no EventBus to call.
|
|
81
|
+
*/
|
|
82
|
+
events?: Partial<Record<TTransition, string>>
|
|
83
|
+
}
|
package/src/stateful.ts
CHANGED
|
@@ -1,73 +1,115 @@
|
|
|
1
|
-
import type { BaseModel } from '@strav/database'
|
|
2
|
-
import type { NormalizeConstructor } from '@strav/kernel'
|
|
3
|
-
import type { Machine, TransitionMeta } from './types.ts'
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
*
|
|
2
|
+
* `stateful(Base, machine)` — class-mixin that bolts state-machine
|
|
3
|
+
* methods onto a `Repository` subclass.
|
|
7
4
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* The mixin adds four instance methods that delegate to the machine:
|
|
6
|
+
* - `is(entity, state)` — boolean equality on the state field
|
|
7
|
+
* - `can(entity, transition)` — eligibility check (incl. guard)
|
|
8
|
+
* - `availableTransitions(entity)` — transitions valid from current state
|
|
9
|
+
* - `transition(entity, name)` — validate + mutate + effect + save + emit
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* transitions: {
|
|
17
|
-
* process: { from: 'pending', to: 'processing' },
|
|
18
|
-
* ship: { from: 'processing', to: 'shipped' },
|
|
19
|
-
* },
|
|
20
|
-
* })
|
|
11
|
+
* `transition()` is the load-bearing method: it runs the machine, persists
|
|
12
|
+
* the field change via `Repository.update`, and (when the machine
|
|
13
|
+
* defines an `events.<name>` entry AND the repo was constructed with an
|
|
14
|
+
* EventBus) emits the event with `{ entity, ...meta }` as the payload.
|
|
21
15
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
16
|
+
* Mixins compose: a Repository can stack `stateful(...)` and any other
|
|
17
|
+
* mixin via plain class inheritance. The returned class stays abstract
|
|
18
|
+
* (still needs `static schema = …` + `static model = …` from the
|
|
19
|
+
* concrete subclass) — the mixin doesn't try to know which schema you're on.
|
|
26
20
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
21
|
+
* Example:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* @inject()
|
|
25
|
+
* class OrderRepository extends stateful(Repository<Order>, orderMachine) {
|
|
26
|
+
* static override readonly schema = orderSchema
|
|
27
|
+
* static override readonly model = Order
|
|
28
|
+
* constructor(db: PostgresDatabase, events: EventBus) { super(db, events) }
|
|
29
|
+
* }
|
|
31
30
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* const order = await orderRepo.find('o1')
|
|
32
|
+
* await orderRepo.transition(order, 'ship')
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { Repository } from '@strav/database'
|
|
37
|
+
import type { Machine } from './machine.ts'
|
|
38
|
+
import type { TransitionMeta } from './machine_definition.ts'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Constructor type for an abstract Repository subclass — matches the
|
|
42
|
+
* shape `class extends Repository<T>` produces. Using `abstract new`
|
|
43
|
+
* lets the mixin accept `Repository` itself (which is abstract).
|
|
35
44
|
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: mixin constructor signature requires variadic any[]
|
|
46
|
+
export type RepositoryConstructor<TEntity extends object> = abstract new (
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
48
|
+
...args: any[]
|
|
49
|
+
) => Repository<TEntity>
|
|
50
|
+
|
|
51
|
+
export function stateful<
|
|
52
|
+
TBase extends RepositoryConstructor<TEntity>,
|
|
53
|
+
TEntity extends object,
|
|
54
|
+
TState extends string,
|
|
55
|
+
TTransition extends string,
|
|
56
|
+
>(Base: TBase, machine: Machine<TEntity, TState, TTransition>) {
|
|
57
|
+
abstract class Stateful extends Base {
|
|
58
|
+
/** Boolean equality on the configured state field. Pure delegate to the machine. */
|
|
59
|
+
is(entity: TEntity, state: TState): boolean {
|
|
60
|
+
return machine.is(entity, state)
|
|
44
61
|
}
|
|
45
62
|
|
|
46
|
-
/**
|
|
47
|
-
can(transition:
|
|
48
|
-
return machine.can(
|
|
63
|
+
/** Eligibility check. Returns a sync boolean unless the registered guard is async. */
|
|
64
|
+
can(entity: TEntity, transition: TTransition): boolean | Promise<boolean> {
|
|
65
|
+
return machine.can(entity, transition)
|
|
49
66
|
}
|
|
50
67
|
|
|
51
|
-
/**
|
|
52
|
-
availableTransitions():
|
|
53
|
-
return machine.availableTransitions(
|
|
68
|
+
/** Names of every transition currently valid from the entity's state. */
|
|
69
|
+
availableTransitions(entity: TEntity): TTransition[] {
|
|
70
|
+
return machine.availableTransitions(entity)
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
/**
|
|
57
|
-
* Apply a transition:
|
|
58
|
-
*
|
|
59
|
-
*
|
|
74
|
+
* Apply a transition end-to-end:
|
|
75
|
+
* 1. Validate the move + run the guard via `machine.apply()`.
|
|
76
|
+
* Mutates `entity[field]` in-place.
|
|
77
|
+
* 2. Run the per-transition `effect` (if any). Effects see the
|
|
78
|
+
* new state.
|
|
79
|
+
* 3. Persist by calling `Repository.update(entity, { [field]: to })`.
|
|
80
|
+
* The change propagates through lifecycle events
|
|
81
|
+
* (`<resource>.updating` / `<resource>.updated`) just like any
|
|
82
|
+
* other update — listeners that care about the state column
|
|
83
|
+
* can read the change off the model.
|
|
84
|
+
* 4. Emit the machine-defined event (when both
|
|
85
|
+
* `machine.events[name]` and the Repository's `EventBus` are
|
|
86
|
+
* present). Payload is `{ entity, ...meta }`.
|
|
87
|
+
*
|
|
88
|
+
* Throws `TransitionError` / `GuardError` from step 1. Effects and
|
|
89
|
+
* `update()` errors propagate verbatim — in those cases the entity
|
|
90
|
+
* may already be mutated in-memory but the row hasn't been written.
|
|
91
|
+
* Callers that need atomicity wrap the call in `UnitOfWork.run(...)`
|
|
92
|
+
* or `TenantManager.withTenant(...)`.
|
|
60
93
|
*/
|
|
61
|
-
async transition(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
async transition(
|
|
95
|
+
entity: TEntity,
|
|
96
|
+
name: TTransition,
|
|
97
|
+
): Promise<TransitionMeta<TState, TTransition>> {
|
|
98
|
+
const meta = await machine.apply(entity, name)
|
|
99
|
+
const field = machine.definition.field
|
|
100
|
+
// `Partial<TEntity>` requires the field key + value to type-narrow
|
|
101
|
+
// against the entity's actual shape; we widen via Record<string, unknown>
|
|
102
|
+
// because the mixin doesn't know the entity's exact key set.
|
|
103
|
+
await this.update(entity, { [field]: meta.to } as unknown as Partial<TEntity>)
|
|
66
104
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
const eventName = machine.definition.events?.[name]
|
|
106
|
+
if (eventName && this.events) {
|
|
107
|
+
await this.events.emit(eventName, { entity, ...meta })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return meta
|
|
71
111
|
}
|
|
72
112
|
}
|
|
113
|
+
|
|
114
|
+
return Stateful
|
|
73
115
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when a transition isn't valid from the entity's current state
|
|
3
|
+
* (or the transition name isn't defined on the machine at all).
|
|
4
|
+
*
|
|
5
|
+
* `context.transition` names the transition the caller tried; `context.from`
|
|
6
|
+
* is the entity's current state; `context.allowedFrom` is the array of
|
|
7
|
+
* source states the transition would have accepted — or `null` when the
|
|
8
|
+
* transition name is undefined entirely.
|
|
9
|
+
*
|
|
10
|
+
* Status is 422 (unprocessable) rather than 400 because the request is
|
|
11
|
+
* well-formed; the *entity* is in the wrong state for the requested action.
|
|
12
|
+
* Apps that want a different code can override per-instance via the
|
|
13
|
+
* standard `code` field on `StravErrorOptions`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { StravError } from '@strav/kernel'
|
|
17
|
+
|
|
18
|
+
export class TransitionError extends StravError {
|
|
19
|
+
constructor(transition: string, from: string, allowedFrom?: readonly string[]) {
|
|
20
|
+
const message = allowedFrom
|
|
21
|
+
? `Cannot apply transition "${transition}" from state "${from}". Allowed from: [${allowedFrom.join(', ')}].`
|
|
22
|
+
: `Transition "${transition}" is not defined on this machine.`
|
|
23
|
+
super(
|
|
24
|
+
message,
|
|
25
|
+
{ code: 'machine.invalid-transition', status: 422 },
|
|
26
|
+
{
|
|
27
|
+
context: {
|
|
28
|
+
transition,
|
|
29
|
+
from,
|
|
30
|
+
allowedFrom: allowedFrom ? [...allowedFrom] : null,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
package/README.md
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# @strav/machine
|
|
2
|
-
|
|
3
|
-
State machine for the [Strav](https://www.npmjs.com/package/@strav/core) framework. Declarative state definitions with transitions, guards, side effects, and event emission.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun add @strav/machine
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Requires `@strav/core` as a peer dependency.
|
|
12
|
-
|
|
13
|
-
## Usage
|
|
14
|
-
|
|
15
|
-
### Define a Machine
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
import { defineMachine } from '@strav/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 '@strav/core/orm'
|
|
65
|
-
import { stateful } from '@strav/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/src/errors.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { StravError } from '@strav/kernel'
|
|
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
|
-
allowedFrom
|
|
12
|
-
? `Cannot apply transition "${transition}" from state "${currentState}". ` +
|
|
13
|
-
`Allowed from: [${allowedFrom.join(', ')}]`
|
|
14
|
-
: `Transition "${transition}" is not defined.`
|
|
15
|
-
)
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Thrown when a transition's guard rejects the transition. */
|
|
20
|
-
export class GuardError extends StravError {
|
|
21
|
-
constructor(
|
|
22
|
-
public readonly transition: string,
|
|
23
|
-
public readonly currentState: string
|
|
24
|
-
) {
|
|
25
|
-
super(`Guard rejected transition "${transition}" from state "${currentState}".`)
|
|
26
|
-
}
|
|
27
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|