@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 CHANGED
@@ -1,24 +1,29 @@
1
1
  {
2
2
  "name": "@strav/machine",
3
- "version": "0.4.31",
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
- "description": "State machine for the Strav framework",
6
- "license": "MIT",
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
- "package.json",
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
- "@strav/kernel": "0.4.31",
18
- "@strav/database": "0.4.31"
26
+ "@types/bun": ">=1.3.14"
19
27
  },
20
- "scripts": {
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
- 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'
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
- * Define a state machine.
2
+ * `Machine` the runtime interface produced by `defineMachine(...)`.
7
3
  *
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.
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
- * @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
- * })
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
- return meta
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
- * Mixin that adds state machine methods to a BaseModel subclass.
2
+ * `stateful(Base, machine)` class-mixin that bolts state-machine
3
+ * methods onto a `Repository` subclass.
7
4
  *
8
- * @example
9
- * import { BaseModel } from '@strav/database'
10
- * import { defineMachine, stateful } from '@strav/machine'
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
- * 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
- * })
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
- * class Order extends stateful(BaseModel, orderMachine) {
23
- * declare id: number
24
- * declare status: string
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
- * 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
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
- * // Composable with other mixins:
33
- * import { compose } from '@strav/kernel'
34
- * class Order extends compose(BaseModel, searchable, m => stateful(m, orderMachine)) { }
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
- 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)
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
- /** Check if a transition can be applied to this model instance. */
47
- can(transition: string): boolean | Promise<boolean> {
48
- return machine.can(this, transition)
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
- /** List all transitions available from this model's current state. */
52
- availableTransitions(): string[] {
53
- return machine.availableTransitions(this)
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: 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.
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(name: string): Promise<TransitionMeta> {
62
- const meta = await machine.apply(this, name)
63
- await this.save()
64
- return meta
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
- /** 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 any).query().whereIn(machine.definition.field, states)
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
- }
package/tsconfig.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "include": ["src/**/*.ts"],
4
- "exclude": ["node_modules", "tests"]
5
- }