@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,270 @@
1
+ // observable — data-effect runtime with state observation.
2
+ //
3
+ // createObservableProgram() is the data-effect counterpart to runtime().
4
+ // Where runtime() executes closure effects (Effect<Msg>), this function
5
+ // accepts a custom executor for data effects (Fx). It also provides
6
+ // state observation: subscribeToTransitions, waitForState, waitForStatus.
7
+ //
8
+ // This subsumes ClientStateMachine's observation API and the peer program's
9
+ // hand-rolled dispatch loop. Transition delivery is synchronous — the
10
+ // listener fires after each update. The microtask-batched delivery from
11
+ // ClientStateMachine is unnecessary complexity that no consumer depends on.
12
+
13
+ import type { Dispatch, Program } from "./machine.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Ambient declarations for timer APIs (not in lib: ["ESNext"])
17
+ // ---------------------------------------------------------------------------
18
+
19
+ declare function setTimeout(callback: () => void, ms: number): unknown
20
+ declare function clearTimeout(id: unknown): void
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Observation types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * A state transition event — from one model to another.
28
+ *
29
+ * Generic over the model type. This is the machine-level primitive;
30
+ * transport packages re-export or alias it for their specific state types.
31
+ */
32
+ export type StateTransition<S> = {
33
+ from: S
34
+ to: S
35
+ timestamp: number
36
+ }
37
+
38
+ /**
39
+ * Listener for state transitions.
40
+ */
41
+ export type TransitionListener<S> = (transition: StateTransition<S>) => void
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // ObservableHandle
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Handle for a running observable program.
49
+ *
50
+ * Provides dispatch, state access, transition observation, and disposal.
51
+ * The observation API (`subscribeToTransitions`, `waitForState`, `waitForStatus`)
52
+ * matches the surface of the former `ClientStateMachine<S>`.
53
+ */
54
+ export interface ObservableHandle<Msg, Model> {
55
+ /** Dispatch a message into the program. */
56
+ dispatch: Dispatch<Msg>
57
+
58
+ /** Get the current model synchronously. */
59
+ getState(): Model
60
+
61
+ /**
62
+ * Subscribe to state transitions.
63
+ *
64
+ * Transitions are delivered synchronously after each update.
65
+ * Returns an unsubscribe function.
66
+ */
67
+ subscribeToTransitions(listener: TransitionListener<Model>): () => void
68
+
69
+ /**
70
+ * Wait for a specific state.
71
+ *
72
+ * Resolves immediately if the current state matches the predicate.
73
+ * Otherwise waits for a transition that matches.
74
+ */
75
+ waitForState(
76
+ predicate: (state: Model) => boolean,
77
+ options?: { timeoutMs?: number },
78
+ ): Promise<Model>
79
+
80
+ /**
81
+ * Wait for a specific status string on a model with a `status` discriminant.
82
+ *
83
+ * Convenience wrapper around `waitForState()`.
84
+ */
85
+ waitForStatus<S extends { status: string }>(
86
+ this: ObservableHandle<Msg, S>,
87
+ status: S["status"],
88
+ options?: { timeoutMs?: number },
89
+ ): Promise<S>
90
+
91
+ /**
92
+ * Dispose the program — stops dispatch and calls `program.done`.
93
+ */
94
+ dispose(): void
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // createObservableProgram
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Run a program with data effects and state observation.
103
+ *
104
+ * Like `runtime()`, but instead of executing closure effects directly,
105
+ * it delegates to a custom `executor` for each data effect. This enables
106
+ * programs whose effects are inspectable data types (not opaque closures).
107
+ *
108
+ * The runtime:
109
+ * 1. Extracts `[model, ...effects]` from `program.init`.
110
+ * 2. Executes each initial effect via `executor(effect, dispatch)`.
111
+ * 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state,
112
+ * notifies transition listeners, executes effects.
113
+ * 4. Re-entrant dispatch (effect calls dispatch) is queued and processed
114
+ * after the current dispatch cycle completes.
115
+ * 5. `dispose()` stops dispatch and calls `program.done`.
116
+ *
117
+ * @param program - The program algebra: init, update, done.
118
+ * @param executor - Interprets data effects as I/O.
119
+ * @returns An observable handle for the running program.
120
+ */
121
+ export function createObservableProgram<Msg, Model, Fx>(
122
+ program: Program<Msg, Model, Fx>,
123
+ executor: (effect: Fx, dispatch: Dispatch<Msg>) => void,
124
+ ): ObservableHandle<Msg, Model> {
125
+ let state: Model
126
+ let isRunning = true
127
+ const pending: Msg[] = []
128
+ let isDispatching = false
129
+ const listeners = new Set<TransitionListener<Model>>()
130
+
131
+ // --------------------------------------------------------------------------
132
+ // Transition notification
133
+ // --------------------------------------------------------------------------
134
+
135
+ function notifyTransition(from: Model, to: Model): void {
136
+ if (from === to) return
137
+
138
+ const transition: StateTransition<Model> = {
139
+ from,
140
+ to,
141
+ timestamp: Date.now(),
142
+ }
143
+
144
+ for (const listener of listeners) {
145
+ try {
146
+ listener(transition)
147
+ } catch {
148
+ // Swallow listener errors — observers must not break dispatch.
149
+ }
150
+ }
151
+ }
152
+
153
+ // --------------------------------------------------------------------------
154
+ // Dispatch
155
+ // --------------------------------------------------------------------------
156
+
157
+ function dispatch(msg: Msg): void {
158
+ if (!isRunning) return
159
+
160
+ pending.push(msg)
161
+ if (isDispatching) return
162
+
163
+ isDispatching = true
164
+ try {
165
+ while (pending.length > 0) {
166
+ const next = pending.shift()!
167
+ const prev = state
168
+ const [newModel, ...effects] = program.update(next, state)
169
+ state = newModel
170
+ notifyTransition(prev, state)
171
+ for (const effect of effects) {
172
+ executor(effect, dispatch)
173
+ }
174
+ }
175
+ } finally {
176
+ isDispatching = false
177
+ }
178
+ }
179
+
180
+ // --------------------------------------------------------------------------
181
+ // Observation
182
+ // --------------------------------------------------------------------------
183
+
184
+ function getState(): Model {
185
+ return state
186
+ }
187
+
188
+ function subscribeToTransitions(
189
+ listener: TransitionListener<Model>,
190
+ ): () => void {
191
+ listeners.add(listener)
192
+ return () => {
193
+ listeners.delete(listener)
194
+ }
195
+ }
196
+
197
+ function waitForState(
198
+ predicate: (state: Model) => boolean,
199
+ options?: { timeoutMs?: number },
200
+ ): Promise<Model> {
201
+ // Resolve immediately if already matching
202
+ if (predicate(state)) {
203
+ return Promise.resolve(state)
204
+ }
205
+
206
+ return new Promise((resolve, reject) => {
207
+ let timeoutId: unknown
208
+
209
+ const unsubscribe = subscribeToTransitions(transition => {
210
+ if (predicate(transition.to)) {
211
+ cleanup()
212
+ resolve(transition.to)
213
+ }
214
+ })
215
+
216
+ const cleanup = () => {
217
+ unsubscribe()
218
+ if (timeoutId !== undefined) {
219
+ clearTimeout(timeoutId)
220
+ }
221
+ }
222
+
223
+ if (options?.timeoutMs !== undefined) {
224
+ timeoutId = setTimeout(() => {
225
+ cleanup()
226
+ reject(
227
+ new Error(`Timeout waiting for state after ${options.timeoutMs}ms`),
228
+ )
229
+ }, options.timeoutMs)
230
+ }
231
+ })
232
+ }
233
+
234
+ function waitForStatus<S extends { status: string }>(
235
+ this: ObservableHandle<Msg, S>,
236
+ status: S["status"],
237
+ options?: { timeoutMs?: number },
238
+ ): Promise<S> {
239
+ return this.waitForState((s: S) => s.status === status, options)
240
+ }
241
+
242
+ function dispose(): void {
243
+ if (!isRunning) return
244
+ isRunning = false
245
+ program.done?.(state)
246
+ }
247
+
248
+ // --------------------------------------------------------------------------
249
+ // Initialize
250
+ // --------------------------------------------------------------------------
251
+
252
+ const [initialModel, ...initialEffects] = program.init
253
+ state = initialModel
254
+ for (const effect of initialEffects) {
255
+ executor(effect, dispatch)
256
+ }
257
+
258
+ // --------------------------------------------------------------------------
259
+ // Return handle
260
+ // --------------------------------------------------------------------------
261
+
262
+ return {
263
+ dispatch,
264
+ getState,
265
+ subscribeToTransitions,
266
+ waitForState,
267
+ waitForStatus,
268
+ dispose,
269
+ }
270
+ }