@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Duane Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # @kyneta/machine
2
+
3
+ Universal Mealy machine algebra — pure state transitions with effect outputs.
4
+
5
+ ## Overview
6
+
7
+ `@kyneta/machine` provides `Program`, a minimal algebraic type for state machines: an initial state, a pure update function, and optionally a teardown hook. Each transition returns a new state plus zero or more effects. The `runtime()` function interprets programs whose effects are closures.
8
+
9
+ This is the same architecture as [raj](https://github.com/andrejewski/raj) and the Elm Architecture, distilled to its core algebra. The key difference: `Program` is parameterized over its effect type `Fx`, so the same shape works for both closure-based effects (interpreted by `runtime()`) and data effects (interpreted by a custom executor).
10
+
11
+ ## Install
12
+
13
+ ```/dev/null/shell.sh#L1
14
+ pnpm add @kyneta/machine
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ A counter that increments every second and stops at 5:
20
+
21
+ ```/dev/null/counter.ts#L1-25
22
+ import { type Program, runtime } from "@kyneta/machine"
23
+
24
+ type Msg = "tick"
25
+ type Model = { count: number }
26
+
27
+ const tick: Program<Msg, Model> = {
28
+ init: [
29
+ { count: 0 },
30
+ (dispatch) => {
31
+ const id = setInterval(() => dispatch("tick"), 1000)
32
+ // effect is fire-and-forget; cleanup goes in `done`
33
+ ;(globalThis as any).__intervalId = id
34
+ },
35
+ ],
36
+ update(_msg, model) {
37
+ return [{ count: model.count + 1 }]
38
+ },
39
+ done() {
40
+ clearInterval((globalThis as any).__intervalId)
41
+ },
42
+ }
43
+
44
+ const dispose = runtime(tick, (model) => {
45
+ console.log(`count: ${model.count}`)
46
+ if (model.count >= 5) dispose()
47
+ })
48
+ ```
49
+
50
+ ## Data Effects
51
+
52
+ When `Fx` is a data type instead of a closure, `runtime()` no longer applies — you write a custom executor that pattern-matches on the effect values. This is the free monad interpreter pattern.
53
+
54
+ ```/dev/null/data-effects.ts#L1-30
55
+ import type { Program } from "@kyneta/machine"
56
+
57
+ // Effects as data
58
+ type Fx =
59
+ | { type: "http"; url: string }
60
+ | { type: "log"; message: string }
61
+
62
+ type Msg = { type: "fetched"; data: string }
63
+ type Model = { status: string }
64
+
65
+ const app: Program<Msg, Model, Fx> = {
66
+ init: [{ status: "loading" }, { type: "http", url: "/api" }],
67
+ update(msg, _model) {
68
+ return [{ status: msg.data }, { type: "log", message: "done" }]
69
+ },
70
+ }
71
+
72
+ // Custom executor — you control how effects are interpreted
73
+ function run(program: Program<Msg, Model, Fx>) {
74
+ let [state, ...effects] = program.init
75
+ function dispatch(msg: Msg) {
76
+ const [next, ...fxs] = program.update(msg, state)
77
+ state = next
78
+ fxs.forEach(execute)
79
+ }
80
+ function execute(fx: Fx) {
81
+ if (fx.type === "http") fetch(fx.url).then((r) => r.text()).then((data) => dispatch({ type: "fetched", data }))
82
+ if (fx.type === "log") console.log(fx.message)
83
+ }
84
+ effects.forEach(execute)
85
+ }
86
+ ```
87
+
88
+ The Synchronizer in `@kyneta/exchange` uses exactly this pattern: `Program<SynchronizerMessage, SynchronizerModel, Command>` with a batched interpreter that coalesces network sends.
89
+
90
+ ## API Reference
91
+
92
+ ### `Program<Msg, Model, Fx = Effect<Msg>>`
93
+
94
+ ```/dev/null/types.ts#L1-5
95
+ type Program<Msg, Model, Fx = Effect<Msg>> = {
96
+ init: [Model, ...Fx[]]
97
+ update(msg: Msg, model: Model): [Model, ...Fx[]]
98
+ done?(model: Model): void
99
+ }
100
+ ```
101
+
102
+ The universal Mealy machine algebra. `init` provides the initial state and startup effects. `update` is a pure transition function. `done` is an optional teardown hook called when the runtime is disposed.
103
+
104
+ ### `Effect<Msg>`
105
+
106
+ ```/dev/null/types.ts#L1
107
+ type Effect<Msg> = (dispatch: Dispatch<Msg>) => void
108
+ ```
109
+
110
+ A continuation that may asynchronously dispatch messages back into the program. This is the default `Fx` type — opaque closures executed by `runtime()`.
111
+
112
+ ### `Dispatch<Msg>`
113
+
114
+ ```/dev/null/types.ts#L1
115
+ type Dispatch<Msg> = (msg: Msg) => void
116
+ ```
117
+
118
+ ### `Disposer`
119
+
120
+ ```/dev/null/types.ts#L1
121
+ type Disposer = () => void
122
+ ```
123
+
124
+ Returned by `runtime()`. Calling it stops message processing and invokes `program.done`.
125
+
126
+ ### `StateTransition<S>`
127
+
128
+ ```/dev/null/types.ts#L1-5
129
+ type StateTransition<S> = {
130
+ from: S
131
+ to: S
132
+ timestamp: number
133
+ }
134
+ ```
135
+
136
+ A state transition event emitted after each `update` call. `from` and `to` are the model before and after the transition. `timestamp` is `Date.now()` at the moment of transition. Transitions where `from === to` (referential identity) are suppressed.
137
+
138
+ ### `TransitionListener<S>`
139
+
140
+ ```/dev/null/types.ts#L1
141
+ type TransitionListener<S> = (transition: StateTransition<S>) => void
142
+ ```
143
+
144
+ Callback type for `subscribeToTransitions`. Listeners fire synchronously after each state transition. Listener exceptions are swallowed — observers must not break dispatch.
145
+
146
+ ### `ObservableHandle<Msg, Model>`
147
+
148
+ ```/dev/null/types.ts#L1-8
149
+ interface ObservableHandle<Msg, Model> {
150
+ dispatch: Dispatch<Msg>
151
+ getState(): Model
152
+ subscribeToTransitions(listener: TransitionListener<Model>): () => void
153
+ waitForState(predicate: (state: Model) => boolean, options?: { timeoutMs?: number }): Promise<Model>
154
+ waitForStatus(status: string, options?: { timeoutMs?: number }): Promise<Model>
155
+ dispose(): void
156
+ }
157
+ ```
158
+
159
+ Handle returned by `createObservableProgram`. Methods:
160
+
161
+ - **`dispatch(msg)`** — send a message into the program. Re-entrant dispatches (effects calling dispatch) are queued and processed after the current cycle.
162
+ - **`getState()`** — synchronous access to the current model.
163
+ - **`subscribeToTransitions(listener)`** — register a `TransitionListener`. Returns an unsubscribe function.
164
+ - **`waitForState(predicate, options?)`** — returns a `Promise` that resolves with the first model matching `predicate`. Resolves immediately if the current state matches. Rejects on timeout if `timeoutMs` is set.
165
+ - **`waitForStatus(status, options?)`** — convenience wrapper for models with a `status` discriminant. Equivalent to `waitForState(s => s.status === status)`.
166
+ - **`dispose()`** — stops dispatch and calls `program.done`.
167
+
168
+ ### `runtime(program, view?)`
169
+
170
+ ```/dev/null/types.ts#L1-4
171
+ function runtime<Msg, Model>(
172
+ program: Program<Msg, Model>,
173
+ view?: (model: Model, dispatch: Dispatch<Msg>) => void,
174
+ ): Disposer
175
+ ```
176
+
177
+ Interprets a program whose effects are `Effect<Msg>` closures. Dispatch is synchronous; re-entrant dispatches are queued and processed in order. Effects execute immediately after each state transition. The optional `view` callback fires after every transition (including init).
178
+
179
+ ### `createObservableProgram(program, executor)`
180
+
181
+ ```/dev/null/types.ts#L1-4
182
+ function createObservableProgram<Msg, Model, Fx>(
183
+ program: Program<Msg, Model, Fx>,
184
+ executor: (effect: Fx, dispatch: Dispatch<Msg>) => void,
185
+ ): ObservableHandle<Msg, Model>
186
+ ```
187
+
188
+ The data-effect counterpart to `runtime()`. Where `runtime()` executes closure effects (`Effect<Msg>`) directly, `createObservableProgram` delegates each effect to a custom `executor` — enabling programs whose effects are inspectable data types rather than opaque closures. It also provides state observation via `subscribeToTransitions`, `waitForState`, and `waitForStatus`.
189
+
190
+ The runtime lifecycle:
191
+ 1. Extracts `[model, ...effects]` from `program.init`.
192
+ 2. Executes each initial effect via `executor(effect, dispatch)`.
193
+ 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state, notifies transition listeners, executes effects.
194
+ 4. Re-entrant dispatch is queued and processed after the current cycle.
195
+ 5. `dispose()` stops dispatch and calls `program.done`.
196
+
197
+ ```/dev/null/observable-example.ts#L1-42
198
+ import { type Program, createObservableProgram } from "@kyneta/machine"
199
+
200
+ // Effects as data
201
+ type Fx =
202
+ | { type: "http"; url: string }
203
+ | { type: "log"; message: string }
204
+
205
+ type Msg = { type: "loaded"; data: string }
206
+ type Model = { status: "loading" | "ready"; data?: string }
207
+
208
+ const app: Program<Msg, Model, Fx> = {
209
+ init: [{ status: "loading" }, { type: "http", url: "/api" }],
210
+ update(msg, _model) {
211
+ return [
212
+ { status: "ready", data: msg.data },
213
+ { type: "log", message: "done" },
214
+ ]
215
+ },
216
+ }
217
+
218
+ // Executor interprets data effects as I/O
219
+ function executor(fx: Fx, dispatch: (msg: Msg) => void) {
220
+ switch (fx.type) {
221
+ case "http":
222
+ fetch(fx.url)
223
+ .then((r) => r.text())
224
+ .then((data) => dispatch({ type: "loaded", data }))
225
+ break
226
+ case "log":
227
+ console.log(fx.message)
228
+ break
229
+ }
230
+ }
231
+
232
+ const handle = createObservableProgram(app, executor)
233
+
234
+ // Observe transitions
235
+ const unsub = handle.subscribeToTransitions(({ from, to }) => {
236
+ console.log(`${from.status} → ${to.status}`)
237
+ })
238
+
239
+ // Wait for a specific status
240
+ await handle.waitForStatus("ready", { timeoutMs: 5000 })
241
+ console.log(handle.getState()) // { status: "ready", data: "..." }
242
+ handle.dispose()
243
+ ```
244
+
245
+ **`runtime()` vs `createObservableProgram()`** — `runtime()` is for closure effects (`Effect<Msg>`) where effects are opaque fire-and-forget continuations. `createObservableProgram()` is for data effects (`Fx`) where effects are inspectable values interpreted by a custom executor. Both share the same `Program` algebra; only the effect interpretation differs. `createObservableProgram` additionally provides state observation, making it the right choice when external code needs to react to state changes.
246
+
247
+ ## Design Decisions
248
+
249
+ **View is external to Program.** The `Program` type is pure algebra — it knows nothing about rendering. View is an optional callback passed to `runtime()`, keeping the state machine testable without mocks.
250
+
251
+ **Variadic effects.** Transitions return `[Model, ...Fx[]]` rather than `[Model, Fx[]]`. Zero effects is `[model]`, one is `[model, fx]`, many is `[model, fx1, fx2]`. This eliminates empty-array noise at call sites.
252
+
253
+ **Fx parameterization.** The third type parameter `Fx` defaults to `Effect<Msg>` for the common closure case, but accepts any type. This single generic makes the same `Program` shape work for both `runtime()`-interpreted programs and programs with data effects that use a custom executor — no wrapper types, no separate interfaces.
254
+
255
+ ## Relationship to the Synchronizer
256
+
257
+ The Synchronizer in `@kyneta/exchange` is a `Program<SynchronizerMessage, SynchronizerModel, Command>` where `Command` is a discriminated union of data effects (send message, build offer, apply snapshot, etc.). Its interpreter batches commands and executes them against live channels and substrates. The pure `update` function is tested exhaustively without any I/O.
258
+
259
+ ## Peer Dependencies
260
+
261
+ None.
262
+
263
+ ## License
264
+
265
+ MIT
@@ -0,0 +1,121 @@
1
+ /** Dispatch a message into a running program. */
2
+ type Dispatch<Msg> = (msg: Msg) => void;
3
+ /** An effect is a continuation that may dispatch messages. */
4
+ type Effect<Msg> = (dispatch: Dispatch<Msg>) => void;
5
+ /**
6
+ * A Mealy machine — pure state transitions with effect outputs.
7
+ *
8
+ * `Fx` defaults to `Effect<Msg>` (closure effects) but can be any
9
+ * data type for programs with custom effect executors.
10
+ *
11
+ * - `init`: initial state and zero or more effects to execute at startup.
12
+ * - `update`: pure transition — given a message and the current state,
13
+ * return the new state and zero or more effects.
14
+ * - `done`: optional teardown hook, called with the final state when
15
+ * the runtime is disposed.
16
+ */
17
+ type Program<Msg, Model, Fx = Effect<Msg>> = {
18
+ init: [Model, ...Fx[]];
19
+ update(msg: Msg, model: Model): [Model, ...Fx[]];
20
+ done?(model: Model): void;
21
+ };
22
+ /** Dispose a running program — stops message processing and calls `done`. */
23
+ type Disposer = () => void;
24
+ /**
25
+ * Run a program whose effects are `Effect<Msg>` closures.
26
+ *
27
+ * The runtime:
28
+ * 1. Extracts `[model, ...effects]` from `program.init`.
29
+ * 2. Executes each initial effect with `dispatch`.
30
+ * 3. Calls `view(model, dispatch)` if provided.
31
+ * 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,
32
+ * executes effects, calls `view`.
33
+ * 5. Returns a `Disposer` that stops dispatch and calls `program.done`.
34
+ *
35
+ * Effects are executed synchronously in order. An effect may call
36
+ * `dispatch` re-entrantly — the runtime processes re-entrant messages
37
+ * after the current dispatch cycle completes (queue-based).
38
+ */
39
+ declare function runtime<Msg, Model>(program: Program<Msg, Model>, view?: (model: Model, dispatch: Dispatch<Msg>) => void): Disposer;
40
+
41
+ /**
42
+ * A state transition event — from one model to another.
43
+ *
44
+ * Generic over the model type. This is the machine-level primitive;
45
+ * transport packages re-export or alias it for their specific state types.
46
+ */
47
+ type StateTransition<S> = {
48
+ from: S;
49
+ to: S;
50
+ timestamp: number;
51
+ };
52
+ /**
53
+ * Listener for state transitions.
54
+ */
55
+ type TransitionListener<S> = (transition: StateTransition<S>) => void;
56
+ /**
57
+ * Handle for a running observable program.
58
+ *
59
+ * Provides dispatch, state access, transition observation, and disposal.
60
+ * The observation API (`subscribeToTransitions`, `waitForState`, `waitForStatus`)
61
+ * matches the surface of the former `ClientStateMachine<S>`.
62
+ */
63
+ interface ObservableHandle<Msg, Model> {
64
+ /** Dispatch a message into the program. */
65
+ dispatch: Dispatch<Msg>;
66
+ /** Get the current model synchronously. */
67
+ getState(): Model;
68
+ /**
69
+ * Subscribe to state transitions.
70
+ *
71
+ * Transitions are delivered synchronously after each update.
72
+ * Returns an unsubscribe function.
73
+ */
74
+ subscribeToTransitions(listener: TransitionListener<Model>): () => void;
75
+ /**
76
+ * Wait for a specific state.
77
+ *
78
+ * Resolves immediately if the current state matches the predicate.
79
+ * Otherwise waits for a transition that matches.
80
+ */
81
+ waitForState(predicate: (state: Model) => boolean, options?: {
82
+ timeoutMs?: number;
83
+ }): Promise<Model>;
84
+ /**
85
+ * Wait for a specific status string on a model with a `status` discriminant.
86
+ *
87
+ * Convenience wrapper around `waitForState()`.
88
+ */
89
+ waitForStatus<S extends {
90
+ status: string;
91
+ }>(this: ObservableHandle<Msg, S>, status: S["status"], options?: {
92
+ timeoutMs?: number;
93
+ }): Promise<S>;
94
+ /**
95
+ * Dispose the program — stops dispatch and calls `program.done`.
96
+ */
97
+ dispose(): void;
98
+ }
99
+ /**
100
+ * Run a program with data effects and state observation.
101
+ *
102
+ * Like `runtime()`, but instead of executing closure effects directly,
103
+ * it delegates to a custom `executor` for each data effect. This enables
104
+ * programs whose effects are inspectable data types (not opaque closures).
105
+ *
106
+ * The runtime:
107
+ * 1. Extracts `[model, ...effects]` from `program.init`.
108
+ * 2. Executes each initial effect via `executor(effect, dispatch)`.
109
+ * 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state,
110
+ * notifies transition listeners, executes effects.
111
+ * 4. Re-entrant dispatch (effect calls dispatch) is queued and processed
112
+ * after the current dispatch cycle completes.
113
+ * 5. `dispose()` stops dispatch and calls `program.done`.
114
+ *
115
+ * @param program - The program algebra: init, update, done.
116
+ * @param executor - Interprets data effects as I/O.
117
+ * @returns An observable handle for the running program.
118
+ */
119
+ declare function createObservableProgram<Msg, Model, Fx>(program: Program<Msg, Model, Fx>, executor: (effect: Fx, dispatch: Dispatch<Msg>) => void): ObservableHandle<Msg, Model>;
120
+
121
+ export { type Dispatch, type Disposer, type Effect, type ObservableHandle, type Program, type StateTransition, type TransitionListener, createObservableProgram, runtime };
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ // src/machine.ts
2
+ function runtime(program, view) {
3
+ let state;
4
+ let isRunning = true;
5
+ const pending = [];
6
+ let isDispatching = false;
7
+ function dispatch(msg) {
8
+ if (!isRunning) return;
9
+ pending.push(msg);
10
+ if (isDispatching) return;
11
+ isDispatching = true;
12
+ try {
13
+ while (pending.length > 0) {
14
+ const next = pending.shift();
15
+ const [newModel, ...effects] = program.update(next, state);
16
+ state = newModel;
17
+ for (const effect of effects) {
18
+ effect(dispatch);
19
+ }
20
+ if (view) view(state, dispatch);
21
+ }
22
+ } finally {
23
+ isDispatching = false;
24
+ }
25
+ }
26
+ const [initialModel, ...initialEffects] = program.init;
27
+ state = initialModel;
28
+ for (const effect of initialEffects) {
29
+ effect(dispatch);
30
+ }
31
+ if (view) view(state, dispatch);
32
+ return () => {
33
+ if (!isRunning) return;
34
+ isRunning = false;
35
+ program.done?.(state);
36
+ };
37
+ }
38
+
39
+ // src/observable.ts
40
+ function createObservableProgram(program, executor) {
41
+ let state;
42
+ let isRunning = true;
43
+ const pending = [];
44
+ let isDispatching = false;
45
+ const listeners = /* @__PURE__ */ new Set();
46
+ function notifyTransition(from, to) {
47
+ if (from === to) return;
48
+ const transition = {
49
+ from,
50
+ to,
51
+ timestamp: Date.now()
52
+ };
53
+ for (const listener of listeners) {
54
+ try {
55
+ listener(transition);
56
+ } catch {
57
+ }
58
+ }
59
+ }
60
+ function dispatch(msg) {
61
+ if (!isRunning) return;
62
+ pending.push(msg);
63
+ if (isDispatching) return;
64
+ isDispatching = true;
65
+ try {
66
+ while (pending.length > 0) {
67
+ const next = pending.shift();
68
+ const prev = state;
69
+ const [newModel, ...effects] = program.update(next, state);
70
+ state = newModel;
71
+ notifyTransition(prev, state);
72
+ for (const effect of effects) {
73
+ executor(effect, dispatch);
74
+ }
75
+ }
76
+ } finally {
77
+ isDispatching = false;
78
+ }
79
+ }
80
+ function getState() {
81
+ return state;
82
+ }
83
+ function subscribeToTransitions(listener) {
84
+ listeners.add(listener);
85
+ return () => {
86
+ listeners.delete(listener);
87
+ };
88
+ }
89
+ function waitForState(predicate, options) {
90
+ if (predicate(state)) {
91
+ return Promise.resolve(state);
92
+ }
93
+ return new Promise((resolve, reject) => {
94
+ let timeoutId;
95
+ const unsubscribe = subscribeToTransitions((transition) => {
96
+ if (predicate(transition.to)) {
97
+ cleanup();
98
+ resolve(transition.to);
99
+ }
100
+ });
101
+ const cleanup = () => {
102
+ unsubscribe();
103
+ if (timeoutId !== void 0) {
104
+ clearTimeout(timeoutId);
105
+ }
106
+ };
107
+ if (options?.timeoutMs !== void 0) {
108
+ timeoutId = setTimeout(() => {
109
+ cleanup();
110
+ reject(
111
+ new Error(`Timeout waiting for state after ${options.timeoutMs}ms`)
112
+ );
113
+ }, options.timeoutMs);
114
+ }
115
+ });
116
+ }
117
+ function waitForStatus(status, options) {
118
+ return this.waitForState((s) => s.status === status, options);
119
+ }
120
+ function dispose() {
121
+ if (!isRunning) return;
122
+ isRunning = false;
123
+ program.done?.(state);
124
+ }
125
+ const [initialModel, ...initialEffects] = program.init;
126
+ state = initialModel;
127
+ for (const effect of initialEffects) {
128
+ executor(effect, dispatch);
129
+ }
130
+ return {
131
+ dispatch,
132
+ getState,
133
+ subscribeToTransitions,
134
+ waitForState,
135
+ waitForStatus,
136
+ dispose
137
+ };
138
+ }
139
+ export {
140
+ createObservableProgram,
141
+ runtime
142
+ };
143
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/machine.ts","../src/observable.ts"],"sourcesContent":["/** Dispatch a message into a running program. */\nexport type Dispatch<Msg> = (msg: Msg) => void\n\n/** An effect is a continuation that may dispatch messages. */\nexport type Effect<Msg> = (dispatch: Dispatch<Msg>) => void\n\n/**\n * A Mealy machine — pure state transitions with effect outputs.\n *\n * `Fx` defaults to `Effect<Msg>` (closure effects) but can be any\n * data type for programs with custom effect executors.\n *\n * - `init`: initial state and zero or more effects to execute at startup.\n * - `update`: pure transition — given a message and the current state,\n * return the new state and zero or more effects.\n * - `done`: optional teardown hook, called with the final state when\n * the runtime is disposed.\n */\nexport type Program<Msg, Model, Fx = Effect<Msg>> = {\n init: [Model, ...Fx[]]\n update(msg: Msg, model: Model): [Model, ...Fx[]]\n done?(model: Model): void\n}\n\n/** Dispose a running program — stops message processing and calls `done`. */\nexport type Disposer = () => void\n\n/**\n * Run a program whose effects are `Effect<Msg>` closures.\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect with `dispatch`.\n * 3. Calls `view(model, dispatch)` if provided.\n * 4. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * executes effects, calls `view`.\n * 5. Returns a `Disposer` that stops dispatch and calls `program.done`.\n *\n * Effects are executed synchronously in order. An effect may call\n * `dispatch` re-entrantly — the runtime processes re-entrant messages\n * after the current dispatch cycle completes (queue-based).\n */\nexport function runtime<Msg, Model>(\n program: Program<Msg, Model>,\n view?: (model: Model, dispatch: Dispatch<Msg>) => void,\n): Disposer {\n let state: Model\n let isRunning = true\n const pending: Msg[] = []\n let isDispatching = false\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n const [newModel, ...effects] = program.update(next, state)\n state = newModel\n for (const effect of effects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n }\n } finally {\n isDispatching = false\n }\n }\n\n // Initialize\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n effect(dispatch)\n }\n if (view) view(state, dispatch)\n\n // Return disposer\n return () => {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n}\n","// observable — data-effect runtime with state observation.\n//\n// createObservableProgram() is the data-effect counterpart to runtime().\n// Where runtime() executes closure effects (Effect<Msg>), this function\n// accepts a custom executor for data effects (Fx). It also provides\n// state observation: subscribeToTransitions, waitForState, waitForStatus.\n//\n// This subsumes ClientStateMachine's observation API and the peer program's\n// hand-rolled dispatch loop. Transition delivery is synchronous — the\n// listener fires after each update. The microtask-batched delivery from\n// ClientStateMachine is unnecessary complexity that no consumer depends on.\n\nimport type { Dispatch, Program } from \"./machine.js\"\n\n// ---------------------------------------------------------------------------\n// Ambient declarations for timer APIs (not in lib: [\"ESNext\"])\n// ---------------------------------------------------------------------------\n\ndeclare function setTimeout(callback: () => void, ms: number): unknown\ndeclare function clearTimeout(id: unknown): void\n\n// ---------------------------------------------------------------------------\n// Observation types\n// ---------------------------------------------------------------------------\n\n/**\n * A state transition event — from one model to another.\n *\n * Generic over the model type. This is the machine-level primitive;\n * transport packages re-export or alias it for their specific state types.\n */\nexport type StateTransition<S> = {\n from: S\n to: S\n timestamp: number\n}\n\n/**\n * Listener for state transitions.\n */\nexport type TransitionListener<S> = (transition: StateTransition<S>) => void\n\n// ---------------------------------------------------------------------------\n// ObservableHandle\n// ---------------------------------------------------------------------------\n\n/**\n * Handle for a running observable program.\n *\n * Provides dispatch, state access, transition observation, and disposal.\n * The observation API (`subscribeToTransitions`, `waitForState`, `waitForStatus`)\n * matches the surface of the former `ClientStateMachine<S>`.\n */\nexport interface ObservableHandle<Msg, Model> {\n /** Dispatch a message into the program. */\n dispatch: Dispatch<Msg>\n\n /** Get the current model synchronously. */\n getState(): Model\n\n /**\n * Subscribe to state transitions.\n *\n * Transitions are delivered synchronously after each update.\n * Returns an unsubscribe function.\n */\n subscribeToTransitions(listener: TransitionListener<Model>): () => void\n\n /**\n * Wait for a specific state.\n *\n * Resolves immediately if the current state matches the predicate.\n * Otherwise waits for a transition that matches.\n */\n waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model>\n\n /**\n * Wait for a specific status string on a model with a `status` discriminant.\n *\n * Convenience wrapper around `waitForState()`.\n */\n waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S>\n\n /**\n * Dispose the program — stops dispatch and calls `program.done`.\n */\n dispose(): void\n}\n\n// ---------------------------------------------------------------------------\n// createObservableProgram\n// ---------------------------------------------------------------------------\n\n/**\n * Run a program with data effects and state observation.\n *\n * Like `runtime()`, but instead of executing closure effects directly,\n * it delegates to a custom `executor` for each data effect. This enables\n * programs whose effects are inspectable data types (not opaque closures).\n *\n * The runtime:\n * 1. Extracts `[model, ...effects]` from `program.init`.\n * 2. Executes each initial effect via `executor(effect, dispatch)`.\n * 3. On `dispatch(msg)`: calls `update(msg, state)`, updates state,\n * notifies transition listeners, executes effects.\n * 4. Re-entrant dispatch (effect calls dispatch) is queued and processed\n * after the current dispatch cycle completes.\n * 5. `dispose()` stops dispatch and calls `program.done`.\n *\n * @param program - The program algebra: init, update, done.\n * @param executor - Interprets data effects as I/O.\n * @returns An observable handle for the running program.\n */\nexport function createObservableProgram<Msg, Model, Fx>(\n program: Program<Msg, Model, Fx>,\n executor: (effect: Fx, dispatch: Dispatch<Msg>) => void,\n): ObservableHandle<Msg, Model> {\n let state: Model\n let isRunning = true\n const pending: Msg[] = []\n let isDispatching = false\n const listeners = new Set<TransitionListener<Model>>()\n\n // --------------------------------------------------------------------------\n // Transition notification\n // --------------------------------------------------------------------------\n\n function notifyTransition(from: Model, to: Model): void {\n if (from === to) return\n\n const transition: StateTransition<Model> = {\n from,\n to,\n timestamp: Date.now(),\n }\n\n for (const listener of listeners) {\n try {\n listener(transition)\n } catch {\n // Swallow listener errors — observers must not break dispatch.\n }\n }\n }\n\n // --------------------------------------------------------------------------\n // Dispatch\n // --------------------------------------------------------------------------\n\n function dispatch(msg: Msg): void {\n if (!isRunning) return\n\n pending.push(msg)\n if (isDispatching) return\n\n isDispatching = true\n try {\n while (pending.length > 0) {\n const next = pending.shift()!\n const prev = state\n const [newModel, ...effects] = program.update(next, state)\n state = newModel\n notifyTransition(prev, state)\n for (const effect of effects) {\n executor(effect, dispatch)\n }\n }\n } finally {\n isDispatching = false\n }\n }\n\n // --------------------------------------------------------------------------\n // Observation\n // --------------------------------------------------------------------------\n\n function getState(): Model {\n return state\n }\n\n function subscribeToTransitions(\n listener: TransitionListener<Model>,\n ): () => void {\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n }\n\n function waitForState(\n predicate: (state: Model) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<Model> {\n // Resolve immediately if already matching\n if (predicate(state)) {\n return Promise.resolve(state)\n }\n\n return new Promise((resolve, reject) => {\n let timeoutId: unknown\n\n const unsubscribe = subscribeToTransitions(transition => {\n if (predicate(transition.to)) {\n cleanup()\n resolve(transition.to)\n }\n })\n\n const cleanup = () => {\n unsubscribe()\n if (timeoutId !== undefined) {\n clearTimeout(timeoutId)\n }\n }\n\n if (options?.timeoutMs !== undefined) {\n timeoutId = setTimeout(() => {\n cleanup()\n reject(\n new Error(`Timeout waiting for state after ${options.timeoutMs}ms`),\n )\n }, options.timeoutMs)\n }\n })\n }\n\n function waitForStatus<S extends { status: string }>(\n this: ObservableHandle<Msg, S>,\n status: S[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<S> {\n return this.waitForState((s: S) => s.status === status, options)\n }\n\n function dispose(): void {\n if (!isRunning) return\n isRunning = false\n program.done?.(state)\n }\n\n // --------------------------------------------------------------------------\n // Initialize\n // --------------------------------------------------------------------------\n\n const [initialModel, ...initialEffects] = program.init\n state = initialModel\n for (const effect of initialEffects) {\n executor(effect, dispatch)\n }\n\n // --------------------------------------------------------------------------\n // Return handle\n // --------------------------------------------------------------------------\n\n return {\n dispatch,\n getState,\n subscribeToTransitions,\n waitForState,\n waitForStatus,\n dispose,\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@kyneta/machine",
3
+ "version": "1.3.0",
4
+ "description": "Universal Mealy machine algebra — Program, Effect, Dispatch, runtime",
5
+ "author": "Duane Johnson",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/halecraft/kyneta",
10
+ "directory": "packages/machine"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./src": "./src/index.ts",
30
+ "./src/*": "./src/*"
31
+ },
32
+ "devDependencies": {
33
+ "tsup": "^8.5.0",
34
+ "typescript": "^5.9.2",
35
+ "vitest": "^4.0.17"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "test": "verify logic",
40
+ "verify": "verify"
41
+ }
42
+ }