Skip to content

Commit 7a1fffa

Browse files
cevrclaude
andcommitted
refactor!: simplify API to spawn-only effects
BREAKING CHANGES: - Remove `.always()` - use inline logic in handlers - Remove `.onEnter()` - use `.spawn()` with entry code at start - Remove `.onExit()` - use `Effect.addFinalizer()` inside `.spawn()` - Remove `.invoke()` - use `.spawn()` for state-scoped, `.background()` for machine-lifetime - Remove `Machine.assign` - trivial one-liner - Remove `yieldFibers` - use `Effect.yieldNow()` Single way to define state effects: `.spawn()` with `Effect.addFinalizer()` for cleanup. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8cf1937 commit 7a1fffa

31 files changed

Lines changed: 773 additions & 2367 deletions

‎AGENTS.md‎

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ bun run fmt # oxfmt
1313

1414
## Conventions
1515

16-
- Files: kebab-case (`actor-system.ts`, `on-enter.ts`)
16+
- Files: kebab-case (`actor-system.ts`, `spawn.ts`)
1717
- States/Events: schema-first with `State({...})` / `Event({...})` - they ARE schemas
1818
- Empty structs: plain values - `State.Idle` (not callable)
1919
- Non-empty: `State.Loading({ url })` - constructor requiring args
@@ -35,17 +35,16 @@ const machine = Machine.make({ state, event, initial })
3535

3636
- All builder methods mutate internal state, return `this`
3737
- Exception: `provide()` creates new instance (supports reusing base machine with different effects)
38-
- Internal fields prefixed `_` (`_transitions`, `_effectSlots`), public getters expose readonly views
38+
- Internal fields prefixed `_` (`_transitions`, `_spawnEffects`), public getters expose readonly views
3939

4040
## Gotchas
4141

42-
- `always` transitions max 100 iterations (infinite loop protection)
4342
- `delay` requires `Effect.scoped` + `ActorSystemDefault` layer
4443
- TestClock: use `Layer.merge(ActorSystemDefault, TestContext.TestContext)`
45-
- `simulate`/`createTestHarness` run guard/effect slots in handlers but no onEnter/onExit/invoke
46-
- Actor testing needs `yieldFibers` after `send()` to let effects run
47-
- Same-state transitions skip exit/enter by default
48-
- `.reenter()` runs exit/enter even on same state tag - use to restart timers/invoke
44+
- `simulate`/`createTestHarness` run guard/effect slots in handlers but no spawn/background effects
45+
- Actor testing needs `Effect.yieldNow()` after `send()` to let effects run
46+
- Same-state transitions skip spawn/finalizers by default
47+
- `.reenter()` runs exit/enter even on same state tag - use to restart timers/spawn
4948
- Dynamic delay: duration fn evaluated at state entry, not registration time
5049
- `namespace.ts` exports Machine namespace (not `Machine.ts` - macOS case-insensitivity)
5150
- Branded types: `State<T>` / `Event<T>` prevent accidental swap at compile time
@@ -106,21 +105,34 @@ const machine = Machine.make({
106105
- Provide signature: `(params, ctx) => ...` where ctx = `{ state, event, self }`
107106
- Guard logic in handler body - no more `guard:` option on `.on()`
108107

109-
## Invoke Slots
108+
## State Effects with spawn
110109

111-
State-scoped or root-level effects (string names, not parameterized):
110+
Use `.spawn()` for state-scoped effects. Use `Effect.addFinalizer` for cleanup:
112111

113112
```ts
114-
machine
115-
.invoke(State.Loading, "fetchData") // state-scoped
116-
.invoke("background") // root-level (machine lifetime)
117-
.invoke(State.X, ["a", "b"]) // parallel state invokes
118-
.invoke(["a", "b"]) // parallel root invokes
119-
.provide({ fetchData: ({ state, self }) => ... })
113+
machine.spawn(State.Loading, ({ state, self }) =>
114+
Effect.gen(function* () {
115+
// Cleanup via finalizer
116+
yield* Effect.addFinalizer(() => Effect.log("Leaving Loading"));
117+
118+
// Main work - auto-cancelled when exiting Loading
119+
const data = yield* Http.get(state.url);
120+
yield* self.send(Event.Loaded({ data }));
121+
}),
122+
);
120123
```
121124

122-
- Spawning machine with unprovided slots -> runtime error
123-
- `simulate()` works without providing invoke effects (pure transitions only)
125+
Use `.background()` for machine-lifetime effects:
126+
127+
```ts
128+
machine.background("heartbeat", ({ self }) =>
129+
Effect.forever(Effect.sleep("30 seconds").pipe(Effect.andThen(self.send(Event.Ping)))),
130+
);
131+
```
132+
133+
- Spawn effects are forked into state scope - cancelled on state exit
134+
- Background effects are forked into machine scope - cancelled on stop/final
135+
- `simulate()` works without providing spawn/background effects (pure transitions only)
124136

125137
## Effect Language Service
126138

‎CODEMAP.md‎

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ src/
1414
├── testing.ts # Test utilities (simulate, harness, assertions)
1515
├── inspection.ts # Inspector service for debugging
1616
├── errors.ts # TaggedError classes
17-
├── combinators/
18-
│ └── assign.ts # Partial state update helper (assign function)
1917
├── persistence/
2018
│ ├── adapter.ts # PersistenceAdapter interface + tags
2119
│ ├── persistent-machine.ts # Machine.persist combinator
@@ -26,7 +24,7 @@ src/
2624
│ └── entity-machine.ts # toEntity - generates Entity from machine
2725
└── internal/
2826
├── loop.ts # Event loop, transition resolver, lifecycle effects
29-
├── transition-index.ts # O(1) lookup for transitions and effects
27+
├── transition-index.ts # O(1) lookup for transitions
3028
├── fiber-storage.ts # Per-actor WeakMap fiber storage utility
3129
├── brands.ts # StateBrand/EventBrand + BrandedState/BrandedEvent
3230
├── is-effect.ts # Shared isEffect type guard
@@ -42,16 +40,13 @@ test/
4240
├── transition-index.test.ts # O(1) transition lookup tests
4341
├── inspection.test.ts # Inspector tests
4442
├── features/ # Feature-specific tests
45-
│ ├── always.test.ts
4643
│ ├── any.test.ts
47-
│ ├── assign.test.ts
4844
│ ├── delay.test.ts
4945
│ ├── dynamic-delay.test.ts
5046
│ ├── effects.test.ts
5147
│ ├── force.test.ts
5248
│ ├── from.test.ts
5349
│ ├── guards.test.ts
54-
│ ├── invoke.test.ts # Root/parallel invokes
5550
│ └── same-state.test.ts
5651
├── patterns/ # Real-world pattern tests
5752
│ ├── payment-flow.test.ts
@@ -70,8 +65,8 @@ test/
7065
| `machine-schema.ts` | Schema-first `State`/`Event` - single source of truth |
7166
| `slot.ts` | `Slot.Guards`/`Slot.Effects` - parameterized slot factories |
7267
| `namespace.ts` | Machine namespace export (named for macOS compat) |
73-
| `internal/loop.ts` | Event processing, `resolveTransition`, lifecycle effects |
74-
| `internal/transition-index.ts` | O(1) lookup for transitions, always, onEnter, onExit |
68+
| `internal/loop.ts` | Event processing, `resolveTransition`, spawn effect forking |
69+
| `internal/transition-index.ts` | O(1) lookup for transitions |
7570
| `internal/brands.ts` | Branded types: `StateBrand`, `BrandedState`, etc. |
7671
| `internal/fiber-storage.ts` | `createFiberStorage()` - per-actor WeakMap utility |
7772
| `persistence/persistent-machine.ts` | `Machine.persist` - schemas from machine |
@@ -99,14 +94,13 @@ get transitions(): ReadonlyArray<Transition<...>> { return this._transitions; }
9994
## Event Flow
10095

10196
```
102-
Event → resolveTransition → onExit → handler (w/ guards/effects) → applyAlways → onEnter → update state
97+
Event → resolveTransition → handler (w/ guards/effects) → update state → spawn effects
10398
```
10499

105100
- Handler receives `{ state, event, guards, effects }` context
106101
- Guards checked inside handler via `yield* guards.xxx(params)`
107-
- Same-state transitions skip onExit/onEnter by default
108-
- `.reenter()`: force onExit/onEnter even for same state tag
109-
- `applyAlways`: loops until no match or final state (max 100 iterations)
102+
- Same-state transitions skip spawn/finalizer by default
103+
- `.reenter()`: force spawn/finalizer even for same state tag
110104
- Final states stop the actor
111105

112106
## Schema-First Pattern
@@ -178,7 +172,7 @@ const MyEffects = Slot.Effects({
178172

179173
## Fiber Storage Pattern
180174

181-
`delay` and `provide` (for invoke) use shared utility from `internal/fiber-storage.ts`:
175+
`delay` and `spawn` use shared utility from `internal/fiber-storage.ts`:
182176

183177
```ts
184178
import { createFiberStorage } from "../internal/fiber-storage.js";
@@ -193,35 +187,40 @@ getFiberMap(self).set(instanceKey, fiber);
193187
`internal/transition-index.ts` provides O(1) lookup via lazy-built WeakMap cache:
194188

195189
- `findTransitions(machine, stateTag, eventTag)` - event transitions
196-
- `findAlwaysTransitions(machine, stateTag)` - always transitions
197-
- `findOnEnterEffects(machine, stateTag)` - entry effects
198-
- `findOnExitEffects(machine, stateTag)` - exit effects
199190

200191
Index built on first access, cached per machine instance.
201192

202-
## Invoke Slots Pattern
193+
## spawn Pattern
203194

204195
```ts
205196
machine
206-
.invoke(State.Loading, "fetchData") // state-scoped invoke
207-
.invoke("background") // root-level (machine lifetime)
208-
.invoke(State.X, ["a", "b"]) // parallel state invokes
209-
.invoke(["a", "b"]) // parallel root invokes
210-
.provide({ fetchData: ... }) // wires handler, returns NEW machine
197+
.spawn(State.Loading, ({ state, self }) =>
198+
Effect.gen(function* () {
199+
// Cleanup via finalizer
200+
yield* Effect.addFinalizer(() => Effect.log("Leaving"));
201+
202+
// Main work - auto-cancelled on exit
203+
const data = yield* fetchData(state.url);
204+
yield* self.send(Event.Resolve({ data }));
205+
}),
206+
)
207+
.background("heartbeat", ({ self }) =>
208+
Effect.forever(Effect.sleep("30 seconds").pipe(Effect.andThen(self.send(Event.Ping)))),
209+
);
211210
```
212211

213-
- `provide()` creates new machine (original reusable with different handlers)
214-
- Spawning validates all slots have handlers (runtime check)
215-
- `simulate()` ignores invoke effects - works with unprovided machines
216-
- Root invokes start on spawn, interrupted on stop or final state
212+
- `spawn` forks into state scope - cancelled on state exit
213+
- `background` forks into machine scope - cancelled on stop/final
214+
- Use `Effect.addFinalizer` for cleanup logic
215+
- `simulate()` ignores spawn/background effects - works without real actor
217216

218217
## Testing
219218

220-
| Function | Guard/Effect Slots | Invoke Effects | Always | Observer |
221-
| --------------------- | ------------------ | -------------- | ------ | -------- |
222-
| `simulate` | Yes | No | Yes | No |
223-
| `createTestHarness` | Yes | No | Yes | Yes |
224-
| Actor + `yieldFibers` | Yes | Yes | Yes | No |
219+
| Function | Guard/Effect Slots | Spawn Effects | Observer |
220+
| ------------------- | ------------------ | ------------- | -------- |
221+
| `simulate` | Yes | No | No |
222+
| `createTestHarness` | Yes | No | Yes |
223+
| Actor + yieldNow | Yes | Yes | No |
225224

226225
Use `Layer.merge(ActorSystemDefault, TestContext.TestContext)` for TestClock.
227226

‎README.md‎

Lines changed: 53 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ Type-safe state machines for Effect. XState-inspired API with full Effect integr
55
## Features
66

77
- **Schema-first** - `State` and `Event` ARE schemas. Single source of truth for types and serialization
8-
- **Fluent builder** - Chain `.on()`, `.always()`, `.delay()` etc. with full type inference
8+
- **Fluent builder** - Chain `.on()`, `.spawn()`, `.delay()` etc. with full type inference
99
- **Type-safe transitions** - types inferred from schemas, no manual type params needed
1010
- **Guard composition** - `Guard.and`, `Guard.or`, `Guard.not`
11-
- **Eventless transitions** - `always` for computed state changes
1211
- **Delayed transitions** - `delay` with TestClock support
1312
- **Actor model** - spawn machines as actors with lifecycle management
1413
- **Persistence** - snapshot and event sourcing with pluggable adapters
@@ -70,41 +69,40 @@ Effect.runPromise(
7069
);
7170
```
7271

73-
## Effect Slots
72+
## State Effects with spawn
7473

75-
Effects (`invoke`, `onEnter`, `onExit`) use named slots. Provide handlers via `.provide()`:
74+
Use `.spawn()` for state-scoped effects. Use `Effect.addFinalizer` for cleanup:
7675

7776
```typescript
78-
// Define machine with effect slots
79-
const baseMachine = Machine.make({
77+
const machine = Machine.make({
8078
state: MyState,
8179
event: MyEvent,
8280
initial: MyState.Idle,
8381
})
8482
.on(MyState.Idle, MyEvent.Fetch, ({ event }) => MyState.Loading({ url: event.url }))
8583
.on(MyState.Loading, MyEvent.Resolve, ({ event }) => MyState.Success({ data: event.data }))
86-
.invoke(MyState.Loading, "fetchData")
87-
.onEnter(MyState.Success, "notifyUser")
88-
.final(MyState.Success);
89-
90-
// Production: provide real implementations
91-
const machine = baseMachine.provide({
92-
fetchData: ({ state, self }) =>
84+
.spawn(MyState.Loading, ({ state, self }) =>
9385
Effect.gen(function* () {
86+
// Cleanup runs when state exits
87+
yield* Effect.addFinalizer(() => Effect.log("Leaving Loading"));
88+
89+
// Main work - auto-cancelled on state exit
9490
const data = yield* fetchFromApi(state.url);
9591
yield* self.send(MyEvent.Resolve({ data }));
9692
}),
97-
notifyUser: ({ state }) => Effect.log(`Success: ${state.data}`),
98-
});
93+
)
94+
.final(MyState.Success);
95+
```
9996

100-
// Test: provide mock implementations
101-
const testMachine = baseMachine.provide({
102-
fetchData: ({ self }) => self.send(MyEvent.Resolve({ data: "mock" })),
103-
notifyUser: () => Effect.void,
104-
});
97+
Use `.background()` for machine-lifetime effects:
98+
99+
```typescript
100+
machine.background("heartbeat", ({ self }) =>
101+
Effect.forever(Effect.sleep("30 seconds").pipe(Effect.andThen(self.send(MyEvent.Ping)))),
102+
);
105103
```
106104

107-
`simulate()` works without providing effects (pure transitions only).
105+
`simulate()` works without spawn/background effects (pure transitions only).
108106

109107
## Persistence
110108

@@ -149,61 +147,50 @@ See the [primer](./primer/) for comprehensive documentation:
149147

150148
### Fluent Methods
151149

152-
| Method | Description |
153-
| ------------ | ------------------------------------------------ |
154-
| `.on()` | Add state/event transition |
155-
| `.reenter()` | Transition with forced reentry (runs exit/enter) |
156-
| `.always()` | Eventless transitions with guard cascade |
157-
| `.delay()` | Schedule event after duration |
158-
| `.invoke()` | Register invoke slot (state-scoped or root) |
159-
| `.onEnter()` | Register entry effect slot |
160-
| `.onExit()` | Register exit effect slot |
161-
| `.provide()` | Wire effect handlers to named slots |
162-
| `.final()` | Mark state as final |
163-
| `.persist()` | Add persistence (schemas from machine) |
164-
165-
### Helpers
166-
167-
| Export | Description |
168-
| ---------------- | -------------------------------- |
169-
| `Machine.assign` | Helper for partial state updates |
150+
| Method | Description |
151+
| --------------- | ------------------------------------------------ |
152+
| `.on()` | Add state/event transition |
153+
| `.reenter()` | Transition with forced reentry (runs exit/enter) |
154+
| `.delay()` | Schedule event after duration |
155+
| `.spawn()` | State-scoped effect (cancelled on exit) |
156+
| `.background()` | Machine-lifetime effect |
157+
| `.provide()` | Wire handlers to guard/effect slots |
158+
| `.final()` | Mark state as final |
159+
| `.persist()` | Add persistence (schemas from machine) |
170160

171161
### Guards
172162

173-
| Export | Description |
174-
| ------------ | ---------------------------------- |
175-
| `Guard.make` | Create named guard slot |
176-
| `Guard.and` | Combine guards with AND (parallel) |
177-
| `Guard.or` | Combine guards with OR (parallel) |
178-
| `Guard.not` | Negate a guard |
163+
| Export | Description |
164+
| ------------- | ---------------------------------- |
165+
| `Slot.Guards` | Define guard slots with params |
166+
| `Guard.and` | Combine guards with AND (parallel) |
167+
| `Guard.or` | Combine guards with OR (parallel) |
168+
| `Guard.not` | Negate a guard |
179169

180-
Guards are **slots-only** - declare with name, provide implementation via `.provide()`:
170+
Guards are **slots-only** - declare with schema params, provide implementation via `.provide()`:
181171

182172
```typescript
183-
// Declare guard slot
184-
const canRetry = Guard.make("canRetry");
185-
186-
// Composition with string shorthand
187-
Guard.and("isAdmin", "isActive");
188-
Guard.or("isOwner", "isAdmin");
189-
Guard.and(Guard.or("isAdmin", "isMod"), "isActive"); // nested
190-
191-
// Use in transition
192-
machine.on(State.Idle, Event.Start, () => State.Running, {
193-
guard: canRetry,
173+
const MyGuards = Slot.Guards({
174+
canRetry: { max: Schema.Number },
194175
});
195176

196-
// Provide implementation (sync boolean or async Effect<boolean>)
197-
const provided = machine.provide({
198-
canRetry: ({ state }) => state.retries < 3, // sync
199-
hasPermission: (
200-
{ state }, // async
201-
) =>
177+
const machine = Machine.make({
178+
state: MyState,
179+
event: MyEvent,
180+
guards: MyGuards,
181+
initial: MyState.Idle,
182+
})
183+
.on(MyState.Error, MyEvent.Retry, ({ state, guards }) =>
202184
Effect.gen(function* () {
203-
const auth = yield* AuthService;
204-
return yield* auth.check(state.userId);
185+
if (yield* guards.canRetry({ max: 3 })) {
186+
return MyState.Retrying;
187+
}
188+
return MyState.Failed;
205189
}),
206-
});
190+
)
191+
.provide({
192+
canRetry: ({ max }, { state }) => state.attempts < max,
193+
});
207194
```
208195

209196
### Testing
@@ -213,7 +200,6 @@ const provided = machine.provide({
213200
| `simulate` | Run events and get all states |
214201
| `createTestHarness` | Step-by-step testing |
215202
| `assertReaches` | Assert machine reaches state |
216-
| `yieldFibers` | Yield to background fibers |
217203

218204
### Actors
219205

0 commit comments

Comments
 (0)