effect-machine 0.1.0
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/README.md +221 -0
- package/package.json +63 -0
- package/src/actor.ts +942 -0
- package/src/cluster/entity-machine.ts +202 -0
- package/src/cluster/index.ts +43 -0
- package/src/cluster/to-entity.ts +99 -0
- package/src/errors.ts +64 -0
- package/src/index.ts +102 -0
- package/src/inspection.ts +132 -0
- package/src/internal/brands.ts +51 -0
- package/src/internal/transition.ts +427 -0
- package/src/internal/utils.ts +80 -0
- package/src/machine.ts +685 -0
- package/src/persistence/adapter.ts +169 -0
- package/src/persistence/adapters/in-memory.ts +275 -0
- package/src/persistence/index.ts +24 -0
- package/src/persistence/persistent-actor.ts +601 -0
- package/src/persistence/persistent-machine.ts +131 -0
- package/src/schema.ts +316 -0
- package/src/slot.ts +281 -0
- package/src/testing.ts +282 -0
- package/tsconfig.json +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# effect-machine
|
|
2
|
+
|
|
3
|
+
Type-safe state machines for Effect.
|
|
4
|
+
|
|
5
|
+
## Why State Machines?
|
|
6
|
+
|
|
7
|
+
State machines eliminate entire categories of bugs:
|
|
8
|
+
|
|
9
|
+
- **No invalid states** - Compile-time enforcement of valid transitions
|
|
10
|
+
- **Explicit side effects** - Effects scoped to states, auto-cancelled on exit
|
|
11
|
+
- **Testable** - Simulate transitions without actors, assert paths deterministically
|
|
12
|
+
- **Serializable** - Schemas power persistence and cluster distribution
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun add effect-machine effect
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Example
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Effect, Schema } from "effect";
|
|
24
|
+
import { Machine, State, Event, Slot } from "effect-machine";
|
|
25
|
+
|
|
26
|
+
// Define state schema - states ARE schemas
|
|
27
|
+
const OrderState = State({
|
|
28
|
+
Pending: { orderId: Schema.String },
|
|
29
|
+
Processing: { orderId: Schema.String },
|
|
30
|
+
Shipped: { trackingId: Schema.String },
|
|
31
|
+
Cancelled: {},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Define event schema
|
|
35
|
+
const OrderEvent = Event({
|
|
36
|
+
Process: {},
|
|
37
|
+
Ship: { trackingId: Schema.String },
|
|
38
|
+
Cancel: {},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Define effects (side effects scoped to states)
|
|
42
|
+
const OrderEffects = Slot.Effects({
|
|
43
|
+
notifyWarehouse: { orderId: Schema.String },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Build machine with fluent API
|
|
47
|
+
const orderMachine = Machine.make({
|
|
48
|
+
state: OrderState,
|
|
49
|
+
event: OrderEvent,
|
|
50
|
+
effects: OrderEffects,
|
|
51
|
+
initial: OrderState.Pending({ orderId: "order-1" }),
|
|
52
|
+
})
|
|
53
|
+
.on(OrderState.Pending, OrderEvent.Process, ({ state }) =>
|
|
54
|
+
OrderState.Processing({ orderId: state.orderId }),
|
|
55
|
+
)
|
|
56
|
+
.on(OrderState.Processing, OrderEvent.Ship, ({ event }) =>
|
|
57
|
+
OrderState.Shipped({ trackingId: event.trackingId }),
|
|
58
|
+
)
|
|
59
|
+
.on(OrderState.Pending, OrderEvent.Cancel, () => OrderState.Cancelled)
|
|
60
|
+
.on(OrderState.Processing, OrderEvent.Cancel, () => OrderState.Cancelled)
|
|
61
|
+
// Effect runs when entering Processing, cancelled on exit
|
|
62
|
+
.spawn(OrderState.Processing, ({ effects, state }) =>
|
|
63
|
+
effects.notifyWarehouse({ orderId: state.orderId }),
|
|
64
|
+
)
|
|
65
|
+
.provide({
|
|
66
|
+
notifyWarehouse: ({ orderId }) => Effect.log(`Warehouse notified: ${orderId}`),
|
|
67
|
+
})
|
|
68
|
+
.final(OrderState.Shipped)
|
|
69
|
+
.final(OrderState.Cancelled);
|
|
70
|
+
|
|
71
|
+
// Run as actor (simple)
|
|
72
|
+
const program = Effect.gen(function* () {
|
|
73
|
+
const actor = yield* Machine.spawn(orderMachine);
|
|
74
|
+
|
|
75
|
+
yield* actor.send(OrderEvent.Process);
|
|
76
|
+
yield* actor.send(OrderEvent.Ship({ trackingId: "TRACK-123" }));
|
|
77
|
+
|
|
78
|
+
const state = yield* actor.snapshot;
|
|
79
|
+
console.log(state); // Shipped { trackingId: "TRACK-123" }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
Effect.runPromise(Effect.scoped(program));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Core Concepts
|
|
86
|
+
|
|
87
|
+
### Schema-First
|
|
88
|
+
|
|
89
|
+
States and events ARE schemas. Single source of truth for types and serialization:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const MyState = State({
|
|
93
|
+
Idle: {}, // Empty = plain value
|
|
94
|
+
Loading: { url: Schema.String }, // Non-empty = constructor
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
MyState.Idle; // Value (no parens)
|
|
98
|
+
MyState.Loading({ url: "/api" }); // Constructor
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Guards and Effects as Slots
|
|
102
|
+
|
|
103
|
+
Define parameterized guards and effects, provide implementations:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const MyGuards = Slot.Guards({
|
|
107
|
+
canRetry: { max: Schema.Number },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const MyEffects = Slot.Effects({
|
|
111
|
+
fetchData: { url: Schema.String },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
machine
|
|
115
|
+
.on(MyState.Error, MyEvent.Retry, ({ state, guards }) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
if (yield* guards.canRetry({ max: 3 })) {
|
|
118
|
+
return MyState.Loading({ url: state.url }); // Transition first
|
|
119
|
+
}
|
|
120
|
+
return MyState.Failed;
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
// Fetch runs when entering Loading, auto-cancelled if state changes
|
|
124
|
+
.spawn(MyState.Loading, ({ effects, state }) => effects.fetchData({ url: state.url }))
|
|
125
|
+
.provide({
|
|
126
|
+
canRetry: ({ max }, { state }) => state.attempts < max,
|
|
127
|
+
fetchData: ({ url }, { self }) =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const data = yield* Http.get(url);
|
|
130
|
+
yield* self.send(MyEvent.Resolve({ data }));
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### State-Scoped Effects
|
|
136
|
+
|
|
137
|
+
`.spawn()` runs effects when entering a state, auto-cancelled on exit:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
machine
|
|
141
|
+
.spawn(MyState.Loading, ({ effects, state }) => effects.fetchData({ url: state.url }))
|
|
142
|
+
.spawn(MyState.Polling, ({ effects }) => effects.poll({ interval: "5 seconds" }));
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Testing
|
|
146
|
+
|
|
147
|
+
Test transitions without actors:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { simulate, assertPath } from "effect-machine";
|
|
151
|
+
|
|
152
|
+
// Simulate events and check path
|
|
153
|
+
const result = yield * simulate(machine, [MyEvent.Start, MyEvent.Complete]);
|
|
154
|
+
expect(result.states.map((s) => s._tag)).toEqual(["Idle", "Loading", "Done"]);
|
|
155
|
+
|
|
156
|
+
// Assert specific path
|
|
157
|
+
yield * assertPath(machine, events, ["Idle", "Loading", "Done"]);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Documentation
|
|
161
|
+
|
|
162
|
+
See the [primer](./primer/) for comprehensive documentation:
|
|
163
|
+
|
|
164
|
+
| Topic | File | Description |
|
|
165
|
+
| ----------- | ----------------------------------------- | ------------------------------ |
|
|
166
|
+
| Overview | [index.md](./primer/index.md) | Navigation and quick reference |
|
|
167
|
+
| Basics | [basics.md](./primer/basics.md) | Core concepts |
|
|
168
|
+
| Handlers | [handlers.md](./primer/handlers.md) | Transitions and guards |
|
|
169
|
+
| Effects | [effects.md](./primer/effects.md) | spawn, background, timeouts |
|
|
170
|
+
| Testing | [testing.md](./primer/testing.md) | simulate, harness, assertions |
|
|
171
|
+
| Actors | [actors.md](./primer/actors.md) | ActorSystem, ActorRef |
|
|
172
|
+
| Persistence | [persistence.md](./primer/persistence.md) | Snapshots, event sourcing |
|
|
173
|
+
| Gotchas | [gotchas.md](./primer/gotchas.md) | Common mistakes |
|
|
174
|
+
|
|
175
|
+
## API Quick Reference
|
|
176
|
+
|
|
177
|
+
### Building
|
|
178
|
+
|
|
179
|
+
| Method | Purpose |
|
|
180
|
+
| ----------------------------------------- | ---------------------------- |
|
|
181
|
+
| `Machine.make({ state, event, initial })` | Create machine |
|
|
182
|
+
| `.on(State.X, Event.Y, handler)` | Add transition |
|
|
183
|
+
| `.reenter(State.X, Event.Y, handler)` | Force re-entry on same state |
|
|
184
|
+
| `.spawn(State.X, handler)` | State-scoped effect |
|
|
185
|
+
| `.background(handler)` | Machine-lifetime effect |
|
|
186
|
+
| `.provide({ slot: impl })` | Provide implementations |
|
|
187
|
+
| `.final(State.X)` | Mark final state |
|
|
188
|
+
| `.persist(config)` | Enable persistence |
|
|
189
|
+
|
|
190
|
+
### Running
|
|
191
|
+
|
|
192
|
+
| Method | Purpose |
|
|
193
|
+
| ---------------------------- | ---------------------------------------- |
|
|
194
|
+
| `Machine.spawn(machine)` | Spawn actor (simple, no registry) |
|
|
195
|
+
| `Machine.spawn(machine, id)` | Spawn actor with custom ID |
|
|
196
|
+
| `system.spawn(id, machine)` | Spawn via ActorSystem (registry/persist) |
|
|
197
|
+
|
|
198
|
+
### Testing
|
|
199
|
+
|
|
200
|
+
| Function | Description |
|
|
201
|
+
| ------------------------------------------ | -------------------------- |
|
|
202
|
+
| `simulate(machine, events)` | Run events, get all states |
|
|
203
|
+
| `createTestHarness(machine)` | Step-by-step testing |
|
|
204
|
+
| `assertPath(machine, events, path)` | Assert exact path |
|
|
205
|
+
| `assertReaches(machine, events, tag)` | Assert final state |
|
|
206
|
+
| `assertNeverReaches(machine, events, tag)` | Assert state never visited |
|
|
207
|
+
|
|
208
|
+
### Actor
|
|
209
|
+
|
|
210
|
+
| Method | Description |
|
|
211
|
+
| --------------------- | ----------------- |
|
|
212
|
+
| `actor.send(event)` | Queue event |
|
|
213
|
+
| `actor.snapshot` | Get current state |
|
|
214
|
+
| `actor.matches(tag)` | Check state tag |
|
|
215
|
+
| `actor.can(event)` | Can handle event? |
|
|
216
|
+
| `actor.changes` | Stream of changes |
|
|
217
|
+
| `actor.subscribe(fn)` | Sync callback |
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "effect-machine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/cevr/effect-machine.git"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"tsconfig.json"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./cluster": "./src/cluster/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "oxlint",
|
|
23
|
+
"lint:fix": "oxlint --fix",
|
|
24
|
+
"fmt": "oxfmt",
|
|
25
|
+
"fmt:check": "oxfmt --check",
|
|
26
|
+
"test": "bun test",
|
|
27
|
+
"test:watch": "bun test --watch",
|
|
28
|
+
"gate": "concurrently -n type,lint,fmt,test -c blue,yellow,magenta,green \"bun run typecheck\" \"bun run lint\" \"bun run fmt\" \"bun run test\"",
|
|
29
|
+
"prepare": "lefthook install || true && effect-language-service patch",
|
|
30
|
+
"version": "changeset version",
|
|
31
|
+
"release": "changeset publish"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"effect": "^3.19.15"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@changesets/changelog-github": "^0.5.2",
|
|
38
|
+
"@changesets/cli": "^2.29.8",
|
|
39
|
+
"@effect/cluster": "^0.56.1",
|
|
40
|
+
"@effect/experimental": "^0.58.0",
|
|
41
|
+
"@effect/language-service": "^0.72.0",
|
|
42
|
+
"@effect/rpc": "^0.73.0",
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"concurrently": "^9.2.1",
|
|
45
|
+
"effect-bun-test": "^0.1.0",
|
|
46
|
+
"lefthook": "^2.0.15",
|
|
47
|
+
"oxfmt": "^0.26.0",
|
|
48
|
+
"oxlint": "^1.41.0",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@effect/cluster": "^0.56.0",
|
|
53
|
+
"@effect/rpc": "^0.73.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@effect/cluster": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"@effect/rpc": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|