@typeonce/effect-machine 0.6.0 → 0.6.1

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.
Files changed (38) hide show
  1. package/package.json +5 -5
  2. package/src/Machine.ts +6873 -0
  3. package/src/index.ts +1 -0
  4. package/src/internal/machine/activities.ts +108 -0
  5. package/src/internal/machine/atom.ts +636 -0
  6. package/src/internal/machine/cluster.ts +394 -0
  7. package/src/internal/machine/command.ts +58 -0
  8. package/src/internal/machine/commandRuntime.ts +43 -0
  9. package/src/internal/machine/configuration.ts +1331 -0
  10. package/src/internal/machine/errors.ts +87 -0
  11. package/src/internal/machine/executionPlan.ts +996 -0
  12. package/src/internal/machine/invocation.ts +119 -0
  13. package/src/internal/machine/machine.ts +1747 -0
  14. package/src/internal/machine/planner.ts +1933 -0
  15. package/src/internal/machine/process.ts +906 -0
  16. package/src/internal/machine/protocol.ts +322 -0
  17. package/src/internal/machine/readiness.ts +10 -0
  18. package/src/internal/machine/runtime.ts +2512 -0
  19. package/src/internal/machine/serialization.ts +498 -0
  20. package/src/internal/machine/stateDefinition.ts +270 -0
  21. package/src/internal/machine/symbols.ts +2 -0
  22. package/src/internal/machine/topology.ts +479 -0
  23. package/src/internal/testing/machine/arbitrary.ts +102 -0
  24. package/src/internal/testing/machine/exploration.ts +331 -0
  25. package/src/internal/testing/machine/finiteModel.ts +1498 -0
  26. package/src/internal/testing/machine/invariant.ts +372 -0
  27. package/src/internal/testing/machine/probe.ts +79 -0
  28. package/src/internal/testing/machine/referenceModel.ts +1505 -0
  29. package/src/internal/testing/machine/runtime.ts +1710 -0
  30. package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
  31. package/src/internal/testing/machine/trace.ts +150 -0
  32. package/src/internal/testing/machine/verification.ts +1890 -0
  33. package/src/testing/MachineTest.ts +2067 -0
  34. package/src/testing/index.ts +7 -0
  35. package/src/unstable/cluster/ClusterMachine.ts +390 -0
  36. package/src/unstable/cluster/index.ts +1 -0
  37. package/src/unstable/reactivity/AtomMachine.ts +649 -0
  38. package/src/unstable/reactivity/index.ts +1 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Bounded breadth-first exploration over concrete planner events.
3
+ *
4
+ * @since 0.4.0
5
+ */
6
+
7
+ import * as Data from "effect/Data"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Graph from "effect/Graph"
10
+ import * as Schema from "effect/Schema"
11
+ import * as Machine from "../../../Machine.js"
12
+ import type {
13
+ Exploration,
14
+ ExplorationCompleteness,
15
+ ExplorationEdge,
16
+ ExplorationFrontier,
17
+ ExplorationKey,
18
+ ExplorationNode,
19
+ ExplorationPredicate,
20
+ ExplorationStateContext,
21
+ ExploreOptions,
22
+ ReachabilityFailure,
23
+ ResolvedExplorationLimits,
24
+ RunError,
25
+ RunFailure,
26
+ RunServices,
27
+ Scenario,
28
+ Trace
29
+ } from "../../../testing/MachineTest.js"
30
+ import type { EnsureExecutable } from "../../machine/readiness.js"
31
+ import { assertInvariants, type InvariantError } from "./invariant.js"
32
+ import { appendTrace, run } from "./trace.js"
33
+
34
+ type AnyMachine = Machine.Machine.Any
35
+
36
+ type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
37
+
38
+ type ReadyMachine<M extends AnyMachine> =
39
+ & M
40
+ & EnsureExecutable<
41
+ Machine.Machine.States<M>,
42
+ Machine.Machine.UnhandledStates<M>,
43
+ Machine.Machine.OutputStates<M>
44
+ >
45
+
46
+ const defaultLimits: ResolvedExplorationLimits = {
47
+ maxDepth: 20,
48
+ maxStates: 1_000,
49
+ maxTransitions: 10_000
50
+ }
51
+
52
+ const validateLimit = (name: keyof ResolvedExplorationLimits, value: number, minimum: number): void => {
53
+ if (!Number.isSafeInteger(value) || value < minimum) {
54
+ throw new Error(`MachineTest.explore expected ${name} to be a safe integer greater than or equal to ${minimum}`)
55
+ }
56
+ }
57
+
58
+ const resolveLimits = (limits: ExploreOptions<AnyMachine, ExplorationKey>["limits"]): ResolvedExplorationLimits => {
59
+ const resolved = {
60
+ maxDepth: limits?.maxDepth ?? defaultLimits.maxDepth,
61
+ maxStates: limits?.maxStates ?? defaultLimits.maxStates,
62
+ maxTransitions: limits?.maxTransitions ?? defaultLimits.maxTransitions
63
+ }
64
+ validateLimit("maxDepth", resolved.maxDepth, 0)
65
+ validateLimit("maxStates", resolved.maxStates, 1)
66
+ validateLimit("maxTransitions", resolved.maxTransitions, 0)
67
+ return resolved
68
+ }
69
+
70
+ const validateKey = <Key extends ExplorationKey>(key: Key): Key => {
71
+ if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") {
72
+ throw new Error("MachineTest.explore expected stateKey to return a string, number, or symbol")
73
+ }
74
+ return key
75
+ }
76
+
77
+ const stateContext = <M extends AnyMachine>(machine: M, trace: Trace<M>): ExplorationStateContext<M> => ({
78
+ machine,
79
+ snapshot: trace.final,
80
+ configuration: trace.finalConfiguration,
81
+ depth: trace.steps.length,
82
+ trace
83
+ })
84
+
85
+ const scenarioWithEvent = <M extends AnyMachine>(
86
+ trace: Trace<M>,
87
+ event: Machine.Machine.InputEvent<M>
88
+ ): Scenario<M> =>
89
+ ({
90
+ ...trace.scenario,
91
+ events: [...trace.scenario.events, event]
92
+ }) as Scenario<M>
93
+
94
+ interface ExplorationEdgeDraft<M extends AnyMachine> {
95
+ readonly source: number
96
+ readonly target: number
97
+ readonly edge: ExplorationEdge<M>
98
+ }
99
+
100
+ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
101
+ machine: ReadyMachine<M>,
102
+ options: ExploreOptions<M, Key>
103
+ ): Effect.Effect<
104
+ Exploration<M, Key>,
105
+ RunFailure<RunError<M>, M> | InvariantError<M>,
106
+ RunServices<M>
107
+ > => {
108
+ const limits = resolveLimits(options.limits)
109
+ const initialScenario = (machine.input === undefined || machine.input === Schema.Void
110
+ ? { events: [] }
111
+ : { input: (options as { readonly input: InputValue<M> }).input, events: [] }) as unknown as Scenario<M>
112
+
113
+ return Effect.gen(function*() {
114
+ const initialTrace = yield* run(machine, initialScenario)
115
+ const invariants = options.invariants ?? []
116
+ if (invariants.length > 0) {
117
+ yield* assertInvariants(machine, initialTrace, invariants)
118
+ }
119
+
120
+ const initialContext = stateContext(machine, initialTrace)
121
+ const initialKey = validateKey(options.stateKey(initialContext))
122
+ const nodes: Array<ExplorationNode<M, Key>> = [{ ...initialContext, key: initialKey }]
123
+ const nodeDraftsByKey = new Map<Key, number>([[initialKey, 0]])
124
+ const edges: Array<ExplorationEdgeDraft<M>> = []
125
+ const frontier: Array<ExplorationFrontier<M, Key>> = []
126
+ const reasons = new Set<"depth" | "states" | "transitions">()
127
+ let plannedTransitions = 0
128
+ let cursor = 0
129
+ let halted = false
130
+
131
+ while (cursor < nodes.length && !halted) {
132
+ const sourceIndex = cursor
133
+ const source = nodes[cursor++]!
134
+ const candidates = options.events(source)
135
+ if (!Array.isArray(candidates)) {
136
+ throw new Error("MachineTest.explore expected events to return a readonly array")
137
+ }
138
+
139
+ if (source.depth >= limits.maxDepth) {
140
+ if (candidates.length > 0) reasons.add("depth")
141
+ for (const event of candidates) {
142
+ frontier.push({
143
+ _tag: "DepthLimit",
144
+ source: source.key,
145
+ trace: source.trace,
146
+ event
147
+ })
148
+ }
149
+ continue
150
+ }
151
+
152
+ for (const event of candidates) {
153
+ if (plannedTransitions >= limits.maxTransitions) {
154
+ reasons.add("transitions")
155
+ frontier.push({
156
+ _tag: "TransitionLimit",
157
+ source: source.key,
158
+ trace: source.trace,
159
+ event
160
+ })
161
+ halted = true
162
+ break
163
+ }
164
+
165
+ const scenario = scenarioWithEvent(source.trace, event)
166
+ const targetTrace = yield* appendTrace(machine, source.trace, event, scenario)
167
+ plannedTransitions += 1
168
+ if (invariants.length > 0) {
169
+ yield* assertInvariants(machine, targetTrace, invariants)
170
+ }
171
+
172
+ const targetContext = stateContext(machine, targetTrace)
173
+ const targetKey = validateKey(options.stateKey(targetContext))
174
+ const existing = nodeDraftsByKey.get(targetKey)
175
+ if (existing !== undefined) {
176
+ edges.push({
177
+ source: sourceIndex,
178
+ target: existing,
179
+ edge: {
180
+ event,
181
+ step: targetTrace.steps[targetTrace.steps.length - 1]!,
182
+ discovered: false
183
+ }
184
+ })
185
+ continue
186
+ }
187
+
188
+ if (nodes.length >= limits.maxStates) {
189
+ reasons.add("states")
190
+ frontier.push({
191
+ _tag: "StateLimit",
192
+ source: source.key,
193
+ trace: source.trace,
194
+ event,
195
+ target: targetKey,
196
+ targetTrace
197
+ })
198
+ halted = true
199
+ break
200
+ }
201
+
202
+ const targetIndex = nodes.length
203
+ nodeDraftsByKey.set(targetKey, targetIndex)
204
+ nodes.push({ ...targetContext, key: targetKey })
205
+ edges.push({
206
+ source: sourceIndex,
207
+ target: targetIndex,
208
+ edge: {
209
+ event,
210
+ step: targetTrace.steps[targetTrace.steps.length - 1]!,
211
+ discovered: true
212
+ }
213
+ })
214
+ }
215
+ }
216
+
217
+ const nodeIndexes: Array<Graph.NodeIndex> = []
218
+ const graph = Graph.directed<ExplorationNode<M, Key>, ExplorationEdge<M>>((mutable) => {
219
+ for (const node of nodes) nodeIndexes.push(Graph.addNode(mutable, node))
220
+ for (const edge of edges) {
221
+ Graph.addEdge(mutable, nodeIndexes[edge.source]!, nodeIndexes[edge.target]!, edge.edge)
222
+ }
223
+ })
224
+ const nodesByKey = new Map<Key, Graph.NodeIndex>()
225
+ nodes.forEach((node, index) => nodesByKey.set(node.key, nodeIndexes[index]!))
226
+ const completeness: ExplorationCompleteness<M, Key> = reasons.size === 0
227
+ ? { _tag: "Complete" }
228
+ : {
229
+ _tag: "Truncated",
230
+ reasons: (["depth", "states", "transitions"] as const).filter((reason) => reasons.has(reason)),
231
+ frontier
232
+ }
233
+
234
+ return {
235
+ graph,
236
+ nodes,
237
+ nodesByKey,
238
+ start: nodeIndexes[0]!,
239
+ limits,
240
+ stats: {
241
+ states: nodes.length,
242
+ plannedTransitions,
243
+ retainedEdges: edges.length,
244
+ maxDepth: nodes.reduce((maximum, node) => Math.max(maximum, node.depth), 0)
245
+ },
246
+ completeness
247
+ }
248
+ })
249
+ }
250
+
251
+ export class ReachabilityError<
252
+ M extends AnyMachine = AnyMachine,
253
+ Key extends ExplorationKey = ExplorationKey
254
+ > extends Data.TaggedError("MachineTestReachabilityError")<{
255
+ readonly name: string
256
+ readonly expectation: "reachable" | "unreachable"
257
+ readonly reason: ReachabilityFailure
258
+ readonly message: string
259
+ readonly completeness: ExplorationCompleteness<M, Key>
260
+ readonly witness?: ExplorationNode<M, Key>
261
+ }> {}
262
+
263
+ const validateAssertionName = (name: string): void => {
264
+ if (name.trim().length === 0) {
265
+ throw new Error("MachineTest reachability assertions expected name to be a non-empty string")
266
+ }
267
+ }
268
+
269
+ export const findShortest = <M extends AnyMachine, Key extends ExplorationKey>(
270
+ exploration: Exploration<M, Key>,
271
+ predicate: ExplorationPredicate<M, Key>
272
+ ): ExplorationNode<M, Key> | undefined => exploration.nodes.find(predicate)
273
+
274
+ export const assertReachable = <M extends AnyMachine, Key extends ExplorationKey>(
275
+ exploration: Exploration<M, Key>,
276
+ name: string,
277
+ predicate: ExplorationPredicate<M, Key>
278
+ ): Effect.Effect<ExplorationNode<M, Key>, ReachabilityError<M, Key>> => {
279
+ validateAssertionName(name)
280
+ return Effect.suspend(() => {
281
+ const witness = findShortest(exploration, predicate)
282
+ if (witness !== undefined) return Effect.succeed(witness)
283
+ const inconclusive = exploration.completeness._tag === "Truncated"
284
+ return Effect.fail(
285
+ new ReachabilityError({
286
+ name,
287
+ expectation: "reachable",
288
+ reason: inconclusive ? "Inconclusive" : "NotFound",
289
+ message: inconclusive
290
+ ? `Reachability of ${name} is inconclusive because exploration was truncated`
291
+ : `Expected ${name} to be reachable`,
292
+ completeness: exploration.completeness
293
+ })
294
+ )
295
+ })
296
+ }
297
+
298
+ export const assertUnreachable = <M extends AnyMachine, Key extends ExplorationKey>(
299
+ exploration: Exploration<M, Key>,
300
+ name: string,
301
+ predicate: ExplorationPredicate<M, Key>
302
+ ): Effect.Effect<void, ReachabilityError<M, Key>> => {
303
+ validateAssertionName(name)
304
+ return Effect.suspend(() => {
305
+ const witness = findShortest(exploration, predicate)
306
+ if (witness !== undefined) {
307
+ return Effect.fail(
308
+ new ReachabilityError({
309
+ name,
310
+ expectation: "unreachable",
311
+ reason: "UnexpectedMatch",
312
+ message: `Expected ${name} to be unreachable but found a witness at depth ${witness.depth}`,
313
+ completeness: exploration.completeness,
314
+ witness
315
+ })
316
+ )
317
+ }
318
+ if (exploration.completeness._tag === "Truncated") {
319
+ return Effect.fail(
320
+ new ReachabilityError({
321
+ name,
322
+ expectation: "unreachable",
323
+ reason: "Inconclusive",
324
+ message: `Unreachability of ${name} is inconclusive because exploration was truncated`,
325
+ completeness: exploration.completeness
326
+ })
327
+ )
328
+ }
329
+ return Effect.void
330
+ })
331
+ }