Skip to content

Commit 8cedf71

Browse files
authored
readd the top-level GetStateChan method, similar to the v1 API (#33)
* readd the top-level GetStateChan method, similar to the v1 API * improve comment
1 parent fce9a9d commit 8cedf71

File tree

5 files changed

+460
-53
lines changed

5 files changed

+460
-53
lines changed

callbacks.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ limitations under the License.
1616

1717
package fsm
1818

19-
import "context"
19+
import (
20+
"context"
21+
22+
"github.com/robbyt/go-fsm/v2/hooks"
23+
)
2024

2125
// CallbackExecutor defines the interface for executing state transition callbacks.
2226
// Implementations handle iteration, panic recovery, and error wrapping internally.
@@ -33,3 +37,11 @@ type CallbackExecutor interface {
3337
// The context is passed to all hooks, allowing access to request-scoped values.
3438
ExecutePostTransitionHooks(ctx context.Context, from, to string)
3539
}
40+
41+
// HookRegistrar extends CallbackExecutor with dynamic hook registration.
42+
// This interface is used by GetStateChan to register broadcast hooks dynamically.
43+
// The hooks.Registry type implements this interface.
44+
type HookRegistrar interface {
45+
RegisterPostTransitionHook(config hooks.PostTransitionHookConfig) error
46+
RegisterPreTransitionHook(config hooks.PreTransitionHookConfig) error
47+
}

docs/v2-upgrade.md

Lines changed: 119 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,23 @@ This document provides a migration prompt for Large Language Models to help migr
1111
| **Logger Setup** | Required as first parameter | Optional via `fsm.WithLogHandler(handler)` |
1212
| **State Constants** | `fsm.StatusNew`, `fsm.StatusBooting`, etc. | `transitions.StatusNew`, `transitions.StatusBooting`, etc. |
1313
| **Transitions Type** | `map[string][]string` (also `fsm.TypicalTransitions`) | `*transitions.Config` (use `transitions.Typical`) |
14-
| **State Broadcasting** | `machine.GetStateChan(ctx)` method | Separate `broadcast.Manager` + hooks |
15-
| **Hooks/Callbacks** | Built into machine with `WithSyncTimeout`, etc. | Separate `hooks.Registry` with pre/post hooks |
14+
| **State Broadcasting** | `stateChan := machine.GetStateChan(ctx)` | `err := machine.GetStateChan(ctx, chan)` with `hooks.Registry` |
15+
| **Broadcast Timeout** | `fsm.WithSyncTimeout(duration)` | `fsm.WithBroadcastTimeout(duration)` option |
16+
| **Hooks/Callbacks** | Built into machine | Separate `hooks.Registry` with pre/post hooks |
17+
18+
## What's New in v2
19+
20+
**Key Feature:** v2 provides a **built-in helper method** for state broadcasting that's simpler than manual setup:
21+
22+
-**One Method Call**: `machine.GetStateChan(ctx, chan)` handles everything
23+
-**Automatic Hook Registration**: Broadcast hook registered automatically on first call
24+
-**v1 Compatible**: Sends initial state immediately (just like v1)
25+
-**You Control the Channel**: Create buffered or unbuffered channels as needed
26+
-**Clean Error Handling**: Returns errors instead of panicking
27+
28+
**Migration Impact**: Most codebases can use the built-in method instead of manually wiring up `broadcast.Manager` + `hooks.Registry`. This simplifies migration significantly.
29+
30+
**When You Need Advanced Control**: You can use `broadcast.Manager` directly for custom broadcast logic, multiple managers, or fine-grained control over hook execution order.
1631

1732
## Migration Instructions for LLMs
1833

@@ -152,7 +167,31 @@ machine, err := fsm.New(
152167

153168
### Step 7: Migrate GetStateChan Usage (Critical)
154169

155-
This is the most complex change. v2 moves state broadcasting to a separate system.
170+
This changed significantly in v2. The v2 API provides a built-in helper method on the FSM.
171+
172+
#### Decision Guide: Which Broadcasting Pattern Should You Use?
173+
174+
```
175+
Do you need state change notifications?
176+
177+
├─ NO → Skip this step, use fsm.New() without hooks.Registry
178+
179+
└─ YES → Choose your approach:
180+
181+
├─ SIMPLE (Recommended for 90% of use cases)
182+
│ ✅ Use: machine.GetStateChan(ctx, chan)
183+
│ ✅ When: Standard broadcasting, single FSM, v1 compatibility
184+
│ ✅ Benefits: Automatic setup, simpler code, less boilerplate
185+
186+
└─ ADVANCED (Only when you need special control)
187+
✅ Use: broadcast.Manager directly
188+
✅ When: Multiple broadcast managers, custom delivery logic,
189+
fine-grained hook ordering, or managing broadcasts
190+
across multiple FSMs
191+
✅ Tradeoff: More code, manual hook registration
192+
```
193+
194+
**👉 Start with the SIMPLE pattern below. Only use ADVANCED if you have specific requirements.**
156195

157196
#### v1 Pattern:
158197
```go
@@ -167,10 +206,45 @@ for state := range stateChan {
167206
}
168207
```
169208

170-
#### v2 Pattern:
209+
#### v2 Pattern (SIMPLE - Use This):
171210
```go
172-
// v2 code - requires setup
211+
// v2 code - simpler pattern using built-in helper
212+
213+
// 1. Create a hooks registry with transitions (required for broadcast)
214+
registry, err := hooks.NewRegistry(
215+
hooks.WithLogHandler(handler),
216+
hooks.WithTransitions(transitions.Typical),
217+
)
218+
219+
// 2. Create FSM with the registry
220+
machine, err := fsm.New(
221+
transitions.StatusNew,
222+
transitions.Typical,
223+
fsm.WithLogHandler(handler),
224+
fsm.WithCallbackRegistry(registry),
225+
fsm.WithBroadcastTimeout(5*time.Second), // Optional: configure broadcast timeout
226+
)
227+
228+
// 3. Create your channel (you control buffer size)
229+
stateChan := make(chan string, 10)
230+
231+
// 4. Register the channel with GetStateChan
232+
err = machine.GetStateChan(ctx, stateChan)
233+
if err != nil {
234+
// Handle error
235+
}
236+
237+
// Channel immediately receives current state (v1 compatible!)
238+
// Then receives all future state changes
239+
for state := range stateChan {
240+
// Handle state
241+
}
242+
```
173243

244+
#### v2 Pattern (Advanced - Using broadcast.Manager directly):
245+
If you need more control, you can still use the broadcast manager directly:
246+
247+
```go
174248
// 1. Create a broadcast manager
175249
broadcastManager := broadcast.NewManager(handler)
176250

@@ -199,7 +273,7 @@ machine, err := fsm.New(
199273
// 5. Get state channel
200274
stateChan, err := broadcastManager.GetStateChan(ctx, broadcast.WithTimeout(5*time.Second))
201275

202-
// 6. IMPORTANT: v2 does NOT send initial state automatically
276+
// 6. IMPORTANT: broadcast.Manager does NOT send initial state automatically
203277
// You must manually broadcast the initial state if needed (for v1 compatibility)
204278
broadcastManager.Broadcast(machine.GetState())
205279

@@ -209,13 +283,13 @@ for state := range stateChan {
209283
}
210284
```
211285

212-
#### Critical Behavioral Difference:
286+
#### Key Changes from v1:
213287

214-
**v1 behavior:** `GetStateChan()` immediately sends the current state to newly created channels.
215-
216-
**v2 behavior:** `GetStateChan()` only receives future state changes. Initial state is NOT sent automatically.
217-
218-
**For v1 compatibility:** After creating a channel, manually call `broadcastManager.Broadcast(machine.GetState())` to send the initial state.
288+
1. **Channel Creation**: You create and own the channel (control buffer size)
289+
2. **Registry Required**: Must use `hooks.Registry` with `WithTransitions()`
290+
3. **v1 Compatible**: `machine.GetStateChan()` sends initial state immediately (like v1)
291+
4. **Configurable Timeout**: Use `WithBroadcastTimeout()` option (replaces v1's `WithSyncTimeout`)
292+
5. **Error Handling**: Returns error instead of just returning a channel
219293

220294
### Step 8: Create Abstraction Layer (Recommended)
221295

@@ -264,30 +338,20 @@ var TypicalTransitions = transitions.Typical
264338
// Machine wraps v2 FSM with v1-compatible API
265339
type Machine struct {
266340
*fsm.Machine
267-
broadcastManager *broadcast.Manager
268341
}
269342

270343
// GetStateChan maintains v1 behavior - sends current state immediately
271344
func (m *Machine) GetStateChan(ctx context.Context) <-chan string {
272-
wrappedCh := make(chan string, 1)
345+
ch := make(chan string, 10)
273346

274-
userCh, err := m.broadcastManager.GetStateChan(ctx)
347+
err := m.Machine.GetStateChan(ctx, ch)
275348
if err != nil {
276-
close(wrappedCh)
277-
return wrappedCh
349+
close(ch)
350+
return ch
278351
}
279352

280-
// v1 compatibility: send current state immediately
281-
wrappedCh <- m.GetState()
282-
283-
go func() {
284-
defer close(wrappedCh)
285-
for state := range userCh {
286-
wrappedCh <- state
287-
}
288-
}()
289-
290-
return wrappedCh
353+
// machine.GetStateChan already sends current state immediately (v1 compatible!)
354+
return ch
291355
}
292356

293357
// New creates a new FSM with v1-like API
@@ -300,18 +364,6 @@ func New(handler slog.Handler) (*Machine, error) {
300364
return nil, err
301365
}
302366

303-
broadcastManager := broadcast.NewManager(handler)
304-
305-
err = registry.RegisterPostTransitionHook(hooks.PostTransitionHookConfig{
306-
Name: "broadcast",
307-
From: []string{"*"},
308-
To: []string{"*"},
309-
Action: broadcastManager.BroadcastHook,
310-
})
311-
if err != nil {
312-
return nil, err
313-
}
314-
315367
f, err := fsm.New(
316368
StatusNew,
317369
TypicalTransitions,
@@ -323,8 +375,7 @@ func New(handler slog.Handler) (*Machine, error) {
323375
}
324376

325377
return &Machine{
326-
Machine: f,
327-
broadcastManager: broadcastManager,
378+
Machine: f,
328379
}, nil
329380
}
330381
```
@@ -446,8 +497,26 @@ machine, err := fsm.New(
446497
machine, err := fsm.New(handler, fsm.StatusNew, fsm.TypicalTransitions)
447498
stateChan := machine.GetStateChan(ctx)
448499

449-
// v2 (see Step 7 for full pattern)
450-
// Requires broadcast.Manager setup + hooks registry
500+
// v2 - using built-in GetStateChan helper
501+
registry, err := hooks.NewRegistry(
502+
hooks.WithLogHandler(handler),
503+
hooks.WithTransitions(transitions.Typical),
504+
)
505+
506+
machine, err := fsm.New(
507+
transitions.StatusNew,
508+
transitions.Typical,
509+
fsm.WithLogHandler(handler),
510+
fsm.WithCallbackRegistry(registry),
511+
)
512+
513+
stateChan := make(chan string, 10)
514+
err = machine.GetStateChan(ctx, stateChan)
515+
516+
// Use the channel
517+
for state := range stateChan {
518+
// Handle state changes
519+
}
451520
```
452521

453522
### Pattern 4: Custom Transitions with Type Safety
@@ -476,11 +545,11 @@ machine, err := fsm.New("draft", customTrans)
476545
### Error: "cannot use map[string][]string as type transitionDB"
477546
**Solution:** Wrap map with `transitions.MustNew(yourMap)` or use `transitions.New(yourMap)`
478547

479-
### Error: "GetStateChan undefined"
480-
**Solution:** Set up `broadcast.Manager` (see Step 7)
548+
### Error: "GetStateChan requires a callback registry"
549+
**Solution:** Use `fsm.WithCallbackRegistry(registry)` when creating the FSM. The registry must be created with `hooks.WithTransitions()` for wildcard support.
481550

482-
### Tests fail: "expected state X, got state Y"
483-
**Solution:** Check if test expected immediate state broadcast from `GetStateChan`. In v2, manually broadcast initial state or use wrapper.
551+
### Error: "requires a callback registry that supports dynamic hook registration"
552+
**Solution:** Use `hooks.Registry` instead of a custom CallbackExecutor. The FSM's built-in `GetStateChan` requires the registry to support dynamic hook registration.
484553

485554
### Error: "wildcard '*' cannot be used without state table"
486555
**Solution:** When using wildcard hooks (`"*"`), you must pass `hooks.WithTransitions()` to the registry.
@@ -500,8 +569,8 @@ Use this checklist to verify your migration:
500569
- [ ] Replaced `fsm.StatusX` with `transitions.StatusX`
501570
- [ ] Replaced `fsm.TypicalTransitions` with `transitions.Typical`
502571
- [ ] Updated `fsm.New()` constructor calls (moved handler to options)
503-
- [ ] Migrated `GetStateChan()` to use `broadcast.Manager`
504-
- [ ] Handled initial state broadcast behavior difference
572+
- [ ] Migrated `GetStateChan()` to use `machine.GetStateChan(ctx, chan)` with hooks.Registry
573+
- [ ] Added `WithBroadcastTimeout()` option if custom timeout needed (replaces `WithSyncTimeout`)
505574
- [ ] **Created single abstraction constructor (if architecture supports it)**
506575
- [ ] Updated all tests
507576
- [ ] Verified with `go build ./...`

0 commit comments

Comments
 (0)