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 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
+ }