Skip to content

Commit 019dc0a

Browse files
feat: eventless (Always) transitions, Raise internal events, state Tags (#57)
Adds three XState-class primitives to the core engine: - Always(): eventless transitions evaluated on entry and after every transition; first enabled (guard-passing) wins, target required. - Raise(): enqueue internal events processed in the same macrostep, before control returns and before any external event. - Tags()/HasTag(): lightweight state categorization queryable across the active leaf, ancestors, and parallel-region leaves. A bounded macrostep settles eventless transitions and drains the raised event queue after Start()/Send(). All three round-trip through the Native JSON and XState v5 exporters (always, tags, xstate.raise), closing the previously-missing pieces of the Stately Studio export story. Builder, IR, validation, interpreter, and exporters wired; 10 new tests plus export round-trip coverage. Coverage gates pass.
1 parent f7281bc commit 019dc0a

10 files changed

Lines changed: 637 additions & 25 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Step-by-step migration guides:
6262
- Hierarchical states with event bubbling
6363
- History states (shallow and deep)
6464
- Delayed transitions and parallel/orthogonal regions
65+
- Eventless (`Always`) transitions, `Raise` internal events, and state `Tags`
6566
- Guards, actions, entry/exit hooks
6667
- Reflection DSL — define machines with struct tags
6768
- Build-time validation
@@ -217,6 +218,46 @@ interp.Send(statekit.Event{Type: "TOGGLE_BOLD"})
217218
// bold: on, italic: off (independent regions)
218219
```
219220

221+
## Eventless Transitions, Raise & Tags
222+
223+
**`Always`** transitions fire automatically on state entry (and after every
224+
transition), choosing the first whose guard passes — ideal for conditional
225+
routing without an explicit event. **`Raise`** enqueues an internal event that
226+
is processed in the same step, before control returns and before any external
227+
event. **`Tags`** categorize states for lightweight querying via `HasTag`.
228+
229+
```go
230+
machine, _ := statekit.NewMachine[Ctx]("checkout").
231+
WithInitial("validating").
232+
WithGuard("ok", func(c Ctx, e statekit.Event) bool { return c.Valid }).
233+
State("validating").
234+
Tags("busy").
235+
Always().Target("approved").Guard("ok").End().
236+
Always().Target("rejected").End(). // guardless fallback
237+
Done().
238+
State("approved").
239+
On("SHIP").Target("shipping").Raise("NOTIFY").End(). // raise internal event
240+
Done().
241+
State("shipping").
242+
On("NOTIFY").Target("done").End(). // handled in the same step
243+
Done().
244+
State("rejected").Final().Done().
245+
State("done").Final().Done().
246+
Build()
247+
248+
interp := statekit.NewInterpreter(machine)
249+
interp.Start()
250+
// validating → approved|rejected resolved automatically via Always
251+
252+
fmt.Println(interp.HasTag("busy")) // true while in a tagged active state
253+
```
254+
255+
Key behaviors:
256+
- `Always` transitions are evaluated in declaration order; the first enabled wins. A target is required (build-time validated) to prevent infinite loops.
257+
- A macrostep settles all eventless transitions and drains raised events before `Start()`/`Send()` returns (bounded to guard against always-true cycles).
258+
- `HasTag` matches the active leaf, its ancestors, and active parallel-region leaves.
259+
- All three round-trip through the Native JSON and XState v5 exporters (`always`, `tags`, and `xstate.raise` action descriptors).
260+
220261
## Reflection DSL
221262

222263
Define machines using struct tags:

builder.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type StateBuilder[C any] struct {
3030
entry []ActionType
3131
exit []ActionType
3232
transitions []*TransitionBuilder[C]
33+
always []*TransitionBuilder[C] // eventless transitions (v1.x)
34+
tags []string // state tags (v1.x)
3335

3436
// History state fields (v2.0)
3537
historyType HistoryType
@@ -109,6 +111,12 @@ type TransitionBuilder[C any] struct {
109111

110112
// Delayed transition fields (v2.0)
111113
delay time.Duration
114+
115+
// Eventless ("always") transition flag (v1.x)
116+
eventless bool
117+
118+
// Raised internal events (v1.x)
119+
raise []EventType
112120
}
113121

114122
// NewMachine creates a new MachineBuilder with the given ID
@@ -233,15 +241,28 @@ func buildStateRecursive[C any](sb *StateBuilder[C], parentID ir.StateID, machin
233241
state.Entry = append(state.Entry, sb.entry...)
234242
state.Exit = append(state.Exit, sb.exit...)
235243

244+
// Copy state tags (v1.x)
245+
state.Tags = append(state.Tags, sb.tags...)
246+
236247
// Build transitions
237248
for _, tb := range sb.transitions {
238249
trans := ir.NewTransitionConfig(tb.event, tb.target)
239250
trans.Guard = tb.guard
240251
trans.Actions = append(trans.Actions, tb.actions...)
241252
trans.Delay = tb.delay // Delayed transitions (v2.0)
253+
trans.Raise = append(trans.Raise, tb.raise...)
242254
state.Transitions = append(state.Transitions, trans)
243255
}
244256

257+
// Build eventless ("always") transitions (v1.x)
258+
for _, tb := range sb.always {
259+
trans := ir.NewTransitionConfig("", tb.target)
260+
trans.Guard = tb.guard
261+
trans.Actions = append(trans.Actions, tb.actions...)
262+
trans.Raise = append(trans.Raise, tb.raise...)
263+
state.Always = append(state.Always, trans)
264+
}
265+
245266
// Build invocations (v3.0)
246267
for _, ib := range sb.invocations {
247268
invoke := &ir.InvokeConfig{
@@ -341,6 +362,27 @@ func (b *StateBuilder[C]) On(event EventType) *TransitionBuilder[C] {
341362
return tb
342363
}
343364

365+
// Always starts building an eventless ("always") transition (v1.x). It is
366+
// evaluated when the state is entered and after every transition, in
367+
// declaration order; the first whose guard passes is taken. A target is
368+
// required. Use multiple Always() calls to express guarded routing with a
369+
// final guardless fallback.
370+
func (b *StateBuilder[C]) Always() *TransitionBuilder[C] {
371+
tb := &TransitionBuilder[C]{
372+
state: b,
373+
eventless: true,
374+
}
375+
b.always = append(b.always, tb)
376+
return tb
377+
}
378+
379+
// Tags attaches one or more tags to the state for lightweight querying via
380+
// Interpreter.HasTag (v1.x).
381+
func (b *StateBuilder[C]) Tags(tags ...string) *StateBuilder[C] {
382+
b.tags = append(b.tags, tags...)
383+
return b
384+
}
385+
344386
// Done completes the state definition and returns the root MachineBuilder.
345387
//
346388
// Watch out: when called from a nested StateBuilder, Done() teleports the
@@ -623,6 +665,14 @@ func (b *TransitionBuilder[C]) Do(action ActionType) *TransitionBuilder[C] {
623665
return b
624666
}
625667

668+
// Raise enqueues internal events emitted when this transition is taken (v1.x).
669+
// Raised events are processed in the same macrostep — before control returns
670+
// to the caller and before any externally sent event.
671+
func (b *TransitionBuilder[C]) Raise(events ...EventType) *TransitionBuilder[C] {
672+
b.raise = append(b.raise, events...)
673+
return b
674+
}
675+
626676
// On starts a new transition on the same state (chainable)
627677
func (b *TransitionBuilder[C]) On(event EventType) *TransitionBuilder[C] {
628678
return b.state.On(event)

eventless_raise_tags_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package statekit
2+
3+
import "testing"
4+
5+
// --- Tags ---
6+
7+
func TestTags_HasTagForActiveAndAncestors(t *testing.T) {
8+
m, err := NewMachine[struct{}]("tags").
9+
WithInitial("active").
10+
State("active").
11+
Tags("running").
12+
WithInitial("loading").
13+
State("loading").Tags("busy", "io").End().
14+
State("ready").Tags("idle").End().
15+
Done().
16+
Build()
17+
if err != nil {
18+
t.Fatalf("build: %v", err)
19+
}
20+
21+
i := NewInterpreter(m)
22+
i.Start()
23+
24+
// Leaf "loading" tags
25+
if !i.HasTag("busy") {
26+
t.Errorf("expected HasTag(busy) on leaf")
27+
}
28+
if !i.HasTag("io") {
29+
t.Errorf("expected HasTag(io) on leaf")
30+
}
31+
// Ancestor "active" tag visible from leaf
32+
if !i.HasTag("running") {
33+
t.Errorf("expected HasTag(running) from ancestor")
34+
}
35+
// Tag of a sibling (not active) must not match
36+
if i.HasTag("idle") {
37+
t.Errorf("did not expect HasTag(idle) — sibling not active")
38+
}
39+
// Unknown tag
40+
if i.HasTag("nope") {
41+
t.Errorf("did not expect HasTag(nope)")
42+
}
43+
}
44+
45+
// --- Always (eventless transitions) ---
46+
47+
func TestAlways_FiresOnEntryWhenGuardPasses(t *testing.T) {
48+
m, err := NewMachine[struct{ Ok bool }]("always").
49+
WithInitial("check").
50+
WithGuard("ok", func(c struct{ Ok bool }, e Event) bool { return c.Ok }).
51+
WithContext(struct{ Ok bool }{Ok: true}).
52+
State("check").
53+
Always().Target("approved").Guard("ok").End().
54+
Always().Target("rejected").End(). // fallback (guardless)
55+
Done().
56+
State("approved").Final().Done().
57+
State("rejected").Final().Done().
58+
Build()
59+
if err != nil {
60+
t.Fatalf("build: %v", err)
61+
}
62+
63+
i := NewInterpreter(m)
64+
i.Start()
65+
if got := i.State().Value; got != "approved" {
66+
t.Errorf("state = %q, want approved", got)
67+
}
68+
}
69+
70+
func TestAlways_FallbackWhenGuardFails(t *testing.T) {
71+
m, err := NewMachine[struct{ Ok bool }]("always").
72+
WithInitial("check").
73+
WithGuard("ok", func(c struct{ Ok bool }, e Event) bool { return c.Ok }).
74+
WithContext(struct{ Ok bool }{Ok: false}).
75+
State("check").
76+
Always().Target("approved").Guard("ok").End().
77+
Always().Target("rejected").End().
78+
Done().
79+
State("approved").Final().Done().
80+
State("rejected").Final().Done().
81+
Build()
82+
if err != nil {
83+
t.Fatalf("build: %v", err)
84+
}
85+
86+
i := NewInterpreter(m)
87+
i.Start()
88+
if got := i.State().Value; got != "rejected" {
89+
t.Errorf("state = %q, want rejected", got)
90+
}
91+
}
92+
93+
func TestAlways_ChainsThroughMultipleStates(t *testing.T) {
94+
// a -> b -> c all via eventless transitions in a single macrostep
95+
m, err := NewMachine[struct{}]("chain").
96+
WithInitial("a").
97+
State("a").Always().Target("b").End().Done().
98+
State("b").Always().Target("c").End().Done().
99+
State("c").Final().Done().
100+
Build()
101+
if err != nil {
102+
t.Fatalf("build: %v", err)
103+
}
104+
105+
i := NewInterpreter(m)
106+
i.Start()
107+
if got := i.State().Value; got != "c" {
108+
t.Errorf("state = %q, want c", got)
109+
}
110+
}
111+
112+
func TestAlways_FiresAfterEventTransition(t *testing.T) {
113+
m, err := NewMachine[struct{}]("evt").
114+
WithInitial("idle").
115+
State("idle").On("GO").Target("transient").End().Done().
116+
State("transient").Always().Target("done").End().Done().
117+
State("done").Final().Done().
118+
Build()
119+
if err != nil {
120+
t.Fatalf("build: %v", err)
121+
}
122+
123+
i := NewInterpreter(m)
124+
i.Start()
125+
if got := i.State().Value; got != "idle" {
126+
t.Fatalf("state = %q, want idle", got)
127+
}
128+
i.Send(Event{Type: "GO"})
129+
if got := i.State().Value; got != "done" {
130+
t.Errorf("state = %q, want done (transient should auto-advance)", got)
131+
}
132+
}
133+
134+
// --- Raise (internal self-event) ---
135+
136+
func TestRaise_ProcessedInSameMacrostep(t *testing.T) {
137+
m, err := NewMachine[struct{}]("raise").
138+
WithInitial("idle").
139+
State("idle").On("START").Target("middle").Raise("NEXT").End().Done().
140+
State("middle").On("NEXT").Target("done").End().Done().
141+
State("done").Final().Done().
142+
Build()
143+
if err != nil {
144+
t.Fatalf("build: %v", err)
145+
}
146+
147+
i := NewInterpreter(m)
148+
i.Start()
149+
i.Send(Event{Type: "START"})
150+
if got := i.State().Value; got != "done" {
151+
t.Errorf("state = %q, want done (raised NEXT should be processed)", got)
152+
}
153+
}
154+
155+
func TestRaise_FromAlwaysTransition(t *testing.T) {
156+
m, err := NewMachine[struct{}]("raise2").
157+
WithInitial("start").
158+
State("start").Always().Target("waiting").Raise("PING").End().Done().
159+
State("waiting").On("PING").Target("done").End().Done().
160+
State("done").Final().Done().
161+
Build()
162+
if err != nil {
163+
t.Fatalf("build: %v", err)
164+
}
165+
166+
i := NewInterpreter(m)
167+
i.Start()
168+
if got := i.State().Value; got != "done" {
169+
t.Errorf("state = %q, want done (always→raise→PING)", got)
170+
}
171+
}
172+
173+
// --- Validation ---
174+
175+
func TestAlways_RequiresTarget(t *testing.T) {
176+
_, err := NewMachine[struct{}]("bad").
177+
WithInitial("a").
178+
State("a").Always().End().Done().
179+
State("b").Final().Done().
180+
Build()
181+
if err == nil {
182+
t.Errorf("expected validation error for always transition without target")
183+
}
184+
}

0 commit comments

Comments
 (0)