@kyneta/machine 1.3.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.
@@ -0,0 +1,563 @@
1
+ // observable.test — deterministic tests for the data-effect observable runtime.
2
+ //
3
+ // All tests are pure — no I/O, no real timing (vi.useFakeTimers for timeouts).
4
+
5
+ import { describe, expect, it, vi } from "vitest"
6
+ import type { Program } from "../machine.js"
7
+ import { createObservableProgram, type StateTransition } from "../observable.js"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Test program — a simple counter with data effects
11
+ // ---------------------------------------------------------------------------
12
+
13
+ type CountModel = { status: "idle"; count: number } | { status: "done" }
14
+
15
+ type CountMsg =
16
+ | { type: "inc" }
17
+ | { type: "dec" }
18
+ | { type: "inc-async" }
19
+ | { type: "set"; value: number }
20
+ | { type: "finish" }
21
+
22
+ type CountEffect = { type: "log"; value: number } | { type: "schedule-inc" }
23
+
24
+ function counterProgram(
25
+ initial = 0,
26
+ ): Program<CountMsg, CountModel, CountEffect> {
27
+ return {
28
+ init: [{ status: "idle", count: initial }],
29
+ update(msg, model) {
30
+ if (model.status === "done") return [model]
31
+
32
+ switch (msg.type) {
33
+ case "inc":
34
+ return [
35
+ { status: "idle", count: model.count + 1 },
36
+ { type: "log", value: model.count + 1 },
37
+ ]
38
+ case "dec":
39
+ return [{ status: "idle", count: model.count - 1 }]
40
+ case "inc-async":
41
+ return [model, { type: "schedule-inc" }]
42
+ case "set":
43
+ return [{ status: "idle", count: msg.value }]
44
+ case "finish":
45
+ return [{ status: "done" as const }]
46
+ }
47
+ },
48
+ done(_model) {
49
+ // teardown hook — tracked via spy in tests
50
+ },
51
+ }
52
+ }
53
+
54
+ function setup(initial = 0) {
55
+ const executor =
56
+ vi.fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
57
+ const program = counterProgram(initial)
58
+ const doneSpy = vi.spyOn(program, "done")
59
+ const handle = createObservableProgram(program, executor)
60
+ return { handle, executor, doneSpy }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Init + getState
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("createObservableProgram — init", () => {
68
+ it("initializes with the program's init model", () => {
69
+ const { handle } = setup(5)
70
+ expect(handle.getState()).toEqual({ status: "idle", count: 5 })
71
+ })
72
+
73
+ it("executes initial effects via executor", () => {
74
+ const executor = vi.fn()
75
+ const program: Program<string, number, string> = {
76
+ init: [0, "fx-a", "fx-b"],
77
+ update(_msg, model) {
78
+ return [model]
79
+ },
80
+ }
81
+ createObservableProgram(program, executor)
82
+ expect(executor).toHaveBeenCalledTimes(2)
83
+ expect(executor).toHaveBeenCalledWith("fx-a", expect.any(Function))
84
+ expect(executor).toHaveBeenCalledWith("fx-b", expect.any(Function))
85
+ })
86
+ })
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // dispatch + getState
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("createObservableProgram — dispatch", () => {
93
+ it("updates model on dispatch", () => {
94
+ const { handle } = setup(0)
95
+ handle.dispatch({ type: "inc" })
96
+ expect(handle.getState()).toEqual({ status: "idle", count: 1 })
97
+ })
98
+
99
+ it("calls executor for each effect produced by update", () => {
100
+ const { handle, executor } = setup(0)
101
+ handle.dispatch({ type: "inc" })
102
+ // One "log" effect
103
+ expect(executor).toHaveBeenCalledWith(
104
+ { type: "log", value: 1 },
105
+ expect.any(Function),
106
+ )
107
+ })
108
+
109
+ it("supports multiple sequential dispatches", () => {
110
+ const { handle } = setup(0)
111
+ handle.dispatch({ type: "inc" })
112
+ handle.dispatch({ type: "inc" })
113
+ handle.dispatch({ type: "dec" })
114
+ expect(handle.getState()).toEqual({ status: "idle", count: 1 })
115
+ })
116
+
117
+ it("ignores dispatch after dispose", () => {
118
+ const { handle } = setup(0)
119
+ handle.dispose()
120
+ handle.dispatch({ type: "inc" })
121
+ expect(handle.getState()).toEqual({ status: "idle", count: 0 })
122
+ })
123
+ })
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Re-entrant dispatch
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe("createObservableProgram — re-entrant dispatch", () => {
130
+ it("queues re-entrant messages and processes them after current cycle", () => {
131
+ const executor = vi
132
+ .fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
133
+ .mockImplementation((effect, dispatch) => {
134
+ if (effect.type === "schedule-inc") {
135
+ dispatch({ type: "inc" })
136
+ }
137
+ })
138
+
139
+ const program = counterProgram(0)
140
+ const handle = createObservableProgram(program, executor)
141
+
142
+ // inc-async produces a schedule-inc effect, which re-entrantly dispatches inc
143
+ handle.dispatch({ type: "inc-async" })
144
+
145
+ expect(handle.getState()).toEqual({ status: "idle", count: 1 })
146
+ })
147
+
148
+ it("delivers transitions for both outer and re-entrant dispatch", () => {
149
+ const transitions: Array<StateTransition<CountModel>> = []
150
+ const executor = vi
151
+ .fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
152
+ .mockImplementation((effect, dispatch) => {
153
+ if (effect.type === "schedule-inc") {
154
+ dispatch({ type: "inc" })
155
+ }
156
+ })
157
+
158
+ const program = counterProgram(0)
159
+ const handle = createObservableProgram(program, executor)
160
+
161
+ handle.subscribeToTransitions(t => transitions.push(t))
162
+
163
+ // set to 5 (no effect), then inc-async triggers re-entrant inc
164
+ handle.dispatch({ type: "set", value: 5 })
165
+ handle.dispatch({ type: "inc-async" })
166
+
167
+ // set: idle/0 → idle/5, inc-async: no model change (same ref), inc: idle/5 → idle/6
168
+ // inc-async produces no model change (returns same model), so no transition for it
169
+ expect(transitions).toHaveLength(2)
170
+ expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
171
+ expect(transitions[0]?.to).toEqual({ status: "idle", count: 5 })
172
+ expect(transitions[1]?.from).toEqual({ status: "idle", count: 5 })
173
+ expect(transitions[1]?.to).toEqual({ status: "idle", count: 6 })
174
+ })
175
+ })
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // subscribeToTransitions
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("createObservableProgram — subscribeToTransitions", () => {
182
+ it("fires listener on state change with from, to, timestamp", () => {
183
+ const { handle } = setup(0)
184
+ const transitions: Array<StateTransition<CountModel>> = []
185
+
186
+ handle.subscribeToTransitions(t => transitions.push(t))
187
+ handle.dispatch({ type: "inc" })
188
+
189
+ expect(transitions).toHaveLength(1)
190
+ expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
191
+ expect(transitions[0]?.to).toEqual({ status: "idle", count: 1 })
192
+ expect(typeof transitions[0]?.timestamp).toBe("number")
193
+ })
194
+
195
+ it("does not fire when model reference is unchanged", () => {
196
+ const { handle } = setup(0)
197
+ const transitions: Array<StateTransition<CountModel>> = []
198
+
199
+ handle.subscribeToTransitions(t => transitions.push(t))
200
+ // inc-async returns the same model object (no state change) before the
201
+ // re-entrant dispatch — but we use a non-re-entrant executor here
202
+ const executor2 = vi.fn()
203
+ const program2: Program<string, { v: number }, never> = {
204
+ init: [{ v: 1 }],
205
+ update(_msg, model) {
206
+ return [model] // same reference
207
+ },
208
+ }
209
+ const handle2 = createObservableProgram(program2, executor2)
210
+ const transitions2: Array<StateTransition<{ v: number }>> = []
211
+ handle2.subscribeToTransitions(t => transitions2.push(t))
212
+ handle2.dispatch("noop")
213
+
214
+ expect(transitions2).toHaveLength(0)
215
+ })
216
+
217
+ it("supports multiple listeners", () => {
218
+ const { handle } = setup(0)
219
+ const a: Array<StateTransition<CountModel>> = []
220
+ const b: Array<StateTransition<CountModel>> = []
221
+
222
+ handle.subscribeToTransitions(t => a.push(t))
223
+ handle.subscribeToTransitions(t => b.push(t))
224
+
225
+ handle.dispatch({ type: "inc" })
226
+
227
+ expect(a).toHaveLength(1)
228
+ expect(b).toHaveLength(1)
229
+ })
230
+
231
+ it("returns an unsubscribe function", () => {
232
+ const { handle } = setup(0)
233
+ const transitions: Array<StateTransition<CountModel>> = []
234
+
235
+ const unsub = handle.subscribeToTransitions(t => transitions.push(t))
236
+ handle.dispatch({ type: "inc" })
237
+ expect(transitions).toHaveLength(1)
238
+
239
+ unsub()
240
+ handle.dispatch({ type: "inc" })
241
+ expect(transitions).toHaveLength(1) // no new transition
242
+ })
243
+
244
+ it("swallows errors in listeners without breaking dispatch", () => {
245
+ const { handle } = setup(0)
246
+ const good: Array<StateTransition<CountModel>> = []
247
+
248
+ handle.subscribeToTransitions(() => {
249
+ throw new Error("boom")
250
+ })
251
+ handle.subscribeToTransitions(t => good.push(t))
252
+
253
+ // Should not throw
254
+ handle.dispatch({ type: "inc" })
255
+
256
+ expect(handle.getState()).toEqual({ status: "idle", count: 1 })
257
+ expect(good).toHaveLength(1)
258
+ })
259
+ })
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // waitForState
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe("createObservableProgram — waitForState", () => {
266
+ it("resolves immediately if predicate already matches", async () => {
267
+ const { handle } = setup(5)
268
+ const state = await handle.waitForState(
269
+ s => s.status === "idle" && s.count === 5,
270
+ )
271
+ expect(state).toEqual({ status: "idle", count: 5 })
272
+ })
273
+
274
+ it("resolves when a future transition matches", async () => {
275
+ const { handle } = setup(0)
276
+ const promise = handle.waitForState(
277
+ s => s.status === "idle" && s.count === 3,
278
+ )
279
+
280
+ handle.dispatch({ type: "inc" })
281
+ handle.dispatch({ type: "inc" })
282
+ handle.dispatch({ type: "inc" })
283
+
284
+ const state = await promise
285
+ expect(state).toEqual({ status: "idle", count: 3 })
286
+ })
287
+
288
+ it("rejects on timeout", async () => {
289
+ vi.useFakeTimers()
290
+ try {
291
+ const { handle } = setup(0)
292
+ const promise = handle.waitForState(s => s.status === "done", {
293
+ timeoutMs: 100,
294
+ })
295
+
296
+ vi.advanceTimersByTime(100)
297
+
298
+ await expect(promise).rejects.toThrow(
299
+ "Timeout waiting for state after 100ms",
300
+ )
301
+ } finally {
302
+ vi.useRealTimers()
303
+ }
304
+ })
305
+
306
+ it("cleans up timeout when resolved before timeout", async () => {
307
+ vi.useFakeTimers()
308
+ try {
309
+ const { handle } = setup(0)
310
+ const promise = handle.waitForState(
311
+ s => s.status === "idle" && s.count === 1,
312
+ { timeoutMs: 5000 },
313
+ )
314
+
315
+ handle.dispatch({ type: "inc" })
316
+
317
+ const state = await promise
318
+ expect(state).toEqual({ status: "idle", count: 1 })
319
+
320
+ // Advancing timers should not cause a rejection (timeout was cleared)
321
+ vi.advanceTimersByTime(10000)
322
+ } finally {
323
+ vi.useRealTimers()
324
+ }
325
+ })
326
+
327
+ it("waits indefinitely when no timeout is specified", async () => {
328
+ const { handle } = setup(0)
329
+ const promise = handle.waitForState(
330
+ s => s.status === "idle" && s.count === 1,
331
+ )
332
+
333
+ // Dispatch later
334
+ handle.dispatch({ type: "inc" })
335
+
336
+ const state = await promise
337
+ expect(state).toEqual({ status: "idle", count: 1 })
338
+ })
339
+ })
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // waitForStatus
343
+ // ---------------------------------------------------------------------------
344
+
345
+ describe("createObservableProgram — waitForStatus", () => {
346
+ it("resolves immediately when status already matches", async () => {
347
+ const { handle } = setup(0)
348
+ const state = await handle.waitForStatus("idle")
349
+ expect(state).toEqual({ status: "idle", count: 0 })
350
+ })
351
+
352
+ it("resolves when status changes to match", async () => {
353
+ const { handle } = setup(0)
354
+ const promise = handle.waitForStatus("done")
355
+
356
+ handle.dispatch({ type: "finish" })
357
+
358
+ const state = await promise
359
+ expect(state).toEqual({ status: "done" })
360
+ })
361
+
362
+ it("rejects on timeout", async () => {
363
+ vi.useFakeTimers()
364
+ try {
365
+ const { handle } = setup(0)
366
+ const promise = handle.waitForStatus("done", { timeoutMs: 50 })
367
+
368
+ vi.advanceTimersByTime(50)
369
+
370
+ await expect(promise).rejects.toThrow(
371
+ "Timeout waiting for state after 50ms",
372
+ )
373
+ } finally {
374
+ vi.useRealTimers()
375
+ }
376
+ })
377
+ })
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // dispose
381
+ // ---------------------------------------------------------------------------
382
+
383
+ describe("createObservableProgram — dispose", () => {
384
+ it("calls program.done with the final state", () => {
385
+ const { handle, doneSpy } = setup(7)
386
+ handle.dispose()
387
+ expect(doneSpy).toHaveBeenCalledWith({ status: "idle", count: 7 })
388
+ })
389
+
390
+ it("stops dispatch after dispose", () => {
391
+ const { handle, executor } = setup(0)
392
+ handle.dispose()
393
+ handle.dispatch({ type: "inc" })
394
+ expect(handle.getState()).toEqual({ status: "idle", count: 0 })
395
+ // executor should only have been called for init effects (none in this case)
396
+ expect(executor).not.toHaveBeenCalled()
397
+ })
398
+
399
+ it("is idempotent — second call does nothing", () => {
400
+ const { handle, doneSpy } = setup(0)
401
+ handle.dispose()
402
+ handle.dispose()
403
+ expect(doneSpy).toHaveBeenCalledTimes(1)
404
+ })
405
+
406
+ it("works with programs that have no done hook", () => {
407
+ const executor = vi.fn()
408
+ const program: Program<string, number, never> = {
409
+ init: [0],
410
+ update(_msg, model) {
411
+ return [model]
412
+ },
413
+ }
414
+ const handle = createObservableProgram(program, executor)
415
+ // Should not throw
416
+ expect(() => handle.dispose()).not.toThrow()
417
+ })
418
+ })
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Transition delivery is synchronous
422
+ // ---------------------------------------------------------------------------
423
+
424
+ describe("createObservableProgram — synchronous transitions", () => {
425
+ it("delivers transitions synchronously within dispatch", () => {
426
+ const { handle } = setup(0)
427
+ let stateSeenInListener: CountModel | undefined
428
+
429
+ handle.subscribeToTransitions(t => {
430
+ stateSeenInListener = handle.getState()
431
+ // getState() should already reflect the new state
432
+ expect(t.to).toEqual(stateSeenInListener)
433
+ })
434
+
435
+ handle.dispatch({ type: "inc" })
436
+ expect(stateSeenInListener).toEqual({ status: "idle", count: 1 })
437
+ })
438
+ })
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Transition listeners fire before effects execute
442
+ // ---------------------------------------------------------------------------
443
+
444
+ describe("createObservableProgram — listeners fire before effects", () => {
445
+ it("transition listener sees state change before effect executor runs", () => {
446
+ const order: string[] = []
447
+
448
+ const executor = vi
449
+ .fn<(effect: CountEffect, dispatch: (msg: CountMsg) => void) => void>()
450
+ .mockImplementation(effect => {
451
+ order.push(`effect:${effect.type}`)
452
+ })
453
+
454
+ const program = counterProgram(0)
455
+ const handle = createObservableProgram(program, executor)
456
+
457
+ handle.subscribeToTransitions(() => {
458
+ order.push("transition")
459
+ })
460
+
461
+ handle.dispatch({ type: "inc" })
462
+
463
+ // The transition listener must fire before the "log" effect executes.
464
+ // This ordering is critical: lifecycle callbacks (onStateChange, onReconnected)
465
+ // depend on seeing the transition before I/O effects run.
466
+ expect(order).toEqual(["transition", "effect:log"])
467
+ })
468
+ })
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Dispose stops transition delivery
472
+ // ---------------------------------------------------------------------------
473
+
474
+ describe("createObservableProgram — dispose stops transitions", () => {
475
+ it("transition listeners do not fire after dispose", () => {
476
+ const { handle } = setup(0)
477
+ const transitions: Array<StateTransition<CountModel>> = []
478
+
479
+ handle.subscribeToTransitions(t => transitions.push(t))
480
+ handle.dispatch({ type: "inc" })
481
+ expect(transitions).toHaveLength(1)
482
+
483
+ handle.dispose()
484
+ handle.dispatch({ type: "inc" })
485
+
486
+ // No new transition — dispatch is a no-op after dispose
487
+ expect(transitions).toHaveLength(1)
488
+ })
489
+ })
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // Multiple rapid dispatches preserve transition ordering
493
+ // ---------------------------------------------------------------------------
494
+
495
+ describe("createObservableProgram — transition ordering under rapid dispatch", () => {
496
+ it("delivers transitions in dispatch order across multiple synchronous dispatches", () => {
497
+ const { handle } = setup(0)
498
+ const statuses: Array<{ from: string; to: string }> = []
499
+
500
+ handle.subscribeToTransitions(t => {
501
+ const from =
502
+ t.from.status === "idle"
503
+ ? `idle/${(t.from as { count: number }).count}`
504
+ : t.from.status
505
+ const to =
506
+ t.to.status === "idle"
507
+ ? `idle/${(t.to as { count: number }).count}`
508
+ : t.to.status
509
+ statuses.push({ from, to })
510
+ })
511
+
512
+ // Three rapid dispatches — transitions must arrive in order
513
+ handle.dispatch({ type: "inc" })
514
+ handle.dispatch({ type: "inc" })
515
+ handle.dispatch({ type: "finish" })
516
+
517
+ expect(statuses).toEqual([
518
+ { from: "idle/0", to: "idle/1" },
519
+ { from: "idle/1", to: "idle/2" },
520
+ { from: "idle/2", to: "done" },
521
+ ])
522
+ })
523
+ })
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Full lifecycle
527
+ // ---------------------------------------------------------------------------
528
+
529
+ describe("createObservableProgram — full lifecycle", () => {
530
+ it("init → dispatch → observe → dispose", () => {
531
+ const { handle, executor, doneSpy } = setup(0)
532
+ const transitions: Array<StateTransition<CountModel>> = []
533
+
534
+ handle.subscribeToTransitions(t => transitions.push(t))
535
+
536
+ // Dispatch several messages
537
+ handle.dispatch({ type: "inc" })
538
+ handle.dispatch({ type: "inc" })
539
+ handle.dispatch({ type: "set", value: 10 })
540
+ handle.dispatch({ type: "finish" })
541
+
542
+ expect(handle.getState()).toEqual({ status: "done" })
543
+ expect(transitions).toHaveLength(4)
544
+ expect(transitions[0]?.from).toEqual({ status: "idle", count: 0 })
545
+ expect(transitions[0]?.to).toEqual({ status: "idle", count: 1 })
546
+ expect(transitions[3]?.to).toEqual({ status: "done" })
547
+
548
+ // Effects were called for each inc (2 incs produce 2 log effects)
549
+ const logCalls = executor.mock.calls.filter(
550
+ ([fx]) => (fx as CountEffect).type === "log",
551
+ )
552
+ expect(logCalls).toHaveLength(2)
553
+
554
+ // Dispose
555
+ handle.dispose()
556
+ expect(doneSpy).toHaveBeenCalledWith({ status: "done" })
557
+
558
+ // No further dispatch after dispose
559
+ handle.dispatch({ type: "inc" })
560
+ expect(handle.getState()).toEqual({ status: "done" })
561
+ expect(transitions).toHaveLength(4) // unchanged
562
+ })
563
+ })
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // @kyneta/machine — universal Mealy machine algebra with effect interpreter.
2
+ //
3
+ // Program<Msg, Model, Fx> is the pure algebra: init + update + done.
4
+ // runtime() is the interpreter: wires dispatch to update, executes effects.
5
+ // Effects are continuations (dispatch) => void — opaque to the runtime.
6
+ //
7
+ // createObservableProgram() is the data-effect counterpart to runtime().
8
+ // It accepts a custom executor for data effects and provides state
9
+ // observation: subscribeToTransitions, waitForState, waitForStatus.
10
+
11
+ export type { Dispatch, Disposer, Effect, Program } from "./machine.js"
12
+ export { runtime } from "./machine.js"
13
+
14
+ export type {
15
+ ObservableHandle,
16
+ StateTransition,
17
+ TransitionListener,
18
+ } from "./observable.js"
19
+ export { createObservableProgram } from "./observable.js"
package/src/machine.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** Dispatch a message into a running program. */
2
+ export type Dispatch<Msg> = (msg: Msg) => void
3
+
4
+ /** An effect is a continuation that may dispatch messages. */
5
+ export type Effect<Msg> = (dispatch: Dispatch<Msg>) => void
6
+
7
+ /**
8
+ * A Mealy machine — pure state transitions with effect outputs.
9
+ *
10
+ * `Fx` defaults to `Effect<Msg>` (closure effects) but can be any
11
+ * data type for programs with custom effect executors.
12
+ *
13
+ * - `init`: initial state and zero or more effects to execute at startup.
14
+ * - `update`: pure transition — given a message and the current state,
15
+ * return the new state and zero or more effects.
16
+ * - `done`: optional teardown hook, called with the final state when
17
+ * the runtime is disposed.
18
+ */
19
+ export type Program<Msg, Model, Fx = Effect<Msg>> = {
20
+ init: [Model, ...Fx[]]
21
+ update(msg: Msg, model: Model): [Model, ...Fx[]]
22
+ done?(model: Model): void
23
+ }
24
+
25
+ /** Dispose a running program — stops message processing and calls `done`. */
26
+ export type Disposer = () => void
27
+
28
+ /**
29
+ * Run a program whose effects are `Effect<Msg>` closures.
30
+ *
31
+ * The runtime:
32
+ * 1. Extracts `[model, ...effects]` from `program.init`.
33
+ * 2. Executes each initial effect with `dispatch`.
34
+ * 3. Calls `view(model, dispatch)` if provided.
35
+ * 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,
36
+ * executes effects, calls `view`.
37
+ * 5. Returns a `Disposer` that stops dispatch and calls `program.done`.
38
+ *
39
+ * Effects are executed synchronously in order. An effect may call
40
+ * `dispatch` re-entrantly — the runtime processes re-entrant messages
41
+ * after the current dispatch cycle completes (queue-based).
42
+ */
43
+ export function runtime<Msg, Model>(
44
+ program: Program<Msg, Model>,
45
+ view?: (model: Model, dispatch: Dispatch<Msg>) => void,
46
+ ): Disposer {
47
+ let state: Model
48
+ let isRunning = true
49
+ const pending: Msg[] = []
50
+ let isDispatching = false
51
+
52
+ function dispatch(msg: Msg): void {
53
+ if (!isRunning) return
54
+
55
+ pending.push(msg)
56
+ if (isDispatching) return
57
+
58
+ isDispatching = true
59
+ try {
60
+ while (pending.length > 0) {
61
+ const next = pending.shift()!
62
+ const [newModel, ...effects] = program.update(next, state)
63
+ state = newModel
64
+ for (const effect of effects) {
65
+ effect(dispatch)
66
+ }
67
+ if (view) view(state, dispatch)
68
+ }
69
+ } finally {
70
+ isDispatching = false
71
+ }
72
+ }
73
+
74
+ // Initialize
75
+ const [initialModel, ...initialEffects] = program.init
76
+ state = initialModel
77
+ for (const effect of initialEffects) {
78
+ effect(dispatch)
79
+ }
80
+ if (view) view(state, dispatch)
81
+
82
+ // Return disposer
83
+ return () => {
84
+ if (!isRunning) return
85
+ isRunning = false
86
+ program.done?.(state)
87
+ }
88
+ }