@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,498 @@
1
+ /**
2
+ * Internal encoded snapshot serialization.
3
+ *
4
+ * @since 0.4.0
5
+ */
6
+
7
+ import * as Cause from "effect/Cause"
8
+ import * as Effect from "effect/Effect"
9
+ import * as Option from "effect/Option"
10
+ import * as Schema from "effect/Schema"
11
+ import type { Machine } from "../../Machine.js"
12
+ import {
13
+ type ActiveConfiguration,
14
+ compareDocumentOrder,
15
+ configurationFromSnapshot,
16
+ getActiveChildPath,
17
+ getActiveValue,
18
+ getPathToRoot,
19
+ type HistoryRecord,
20
+ isActiveFinalNode,
21
+ isPathInSubtree,
22
+ normalizeConfigurationEffect,
23
+ snapshotFromConfiguration,
24
+ validateHistoryRecordControl
25
+ } from "./configuration.js"
26
+ import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./errors.js"
27
+ import { decodeBoundary } from "./protocol.js"
28
+ import { getNode, getStateNodeSchema } from "./topology.js"
29
+
30
+ const EncodedSnapshotSchema = Schema.Struct({
31
+ _tag: Schema.Literal("MachineSnapshot"),
32
+ active: Schema.Array(Schema.Struct({
33
+ path: Schema.String,
34
+ value: Schema.Unknown
35
+ })),
36
+ completed: Schema.optional(Schema.Array(Schema.Struct({
37
+ path: Schema.String,
38
+ output: Schema.optional(Schema.Unknown)
39
+ }))),
40
+ history: Schema.optional(Schema.Record(
41
+ Schema.String,
42
+ Schema.Struct({
43
+ mode: Schema.Literals(["shallow", "deep"]),
44
+ active: Schema.Array(Schema.String),
45
+ values: Schema.Record(Schema.String, Schema.Unknown)
46
+ })
47
+ ))
48
+ })
49
+
50
+ const encodeBoundary = (
51
+ machine: Machine.Any,
52
+ schema: Schema.Top,
53
+ value: unknown,
54
+ options: {
55
+ readonly boundary: "state" | "output" | "history"
56
+ readonly state: string
57
+ }
58
+ ): Effect.Effect<unknown, MachineSchemaEncodeError, unknown> =>
59
+ Schema.encodeUnknownEffect(schema)(value).pipe(
60
+ Effect.mapError((cause) =>
61
+ new MachineSchemaEncodeError({
62
+ machineId: machine.id,
63
+ boundary: options.boundary,
64
+ state: options.state,
65
+ cause
66
+ })
67
+ )
68
+ )
69
+
70
+ const decodeEncodedBoundary = (
71
+ machine: Machine.Any,
72
+ schema: Schema.Top,
73
+ value: unknown,
74
+ options: {
75
+ readonly boundary: "state" | "output" | "history"
76
+ readonly state: string
77
+ }
78
+ ): Effect.Effect<unknown, MachineSchemaDecodeError, unknown> =>
79
+ Schema.decodeUnknownEffect(schema)(value).pipe(
80
+ Effect.mapError((cause) =>
81
+ new MachineSchemaDecodeError({
82
+ machineId: machine.id,
83
+ boundary: options.boundary,
84
+ state: options.state,
85
+ cause
86
+ })
87
+ )
88
+ )
89
+
90
+ const getCompletionSchema = (
91
+ machine: Machine.Any,
92
+ configuration: ActiveConfiguration,
93
+ path: string
94
+ ): Schema.Top => {
95
+ const node = getNode(machine, path)
96
+ if (node.type === "compound") {
97
+ const child = getActiveChildPath(machine, configuration, path)
98
+ if (child === undefined) {
99
+ throw new Error(`Machine expected completed state "${path}" to have an active child`)
100
+ }
101
+ return getCompletionSchema(machine, configuration, child)
102
+ }
103
+ return node.output ?? Schema.Void
104
+ }
105
+
106
+ /** Defensively validates and normalizes an in-memory logical snapshot. Unlike
107
+ * the transport decoder this consumes decoded schema values. */
108
+ export const normalizeSnapshotEffect = <const States extends Machine.StateSchemas>(
109
+ machine: Machine.Any,
110
+ snapshot: Machine.Snapshot<States>
111
+ ): Effect.Effect<Machine.Snapshot<States>, MachineSchemaDecodeError> =>
112
+ Effect.gen(function*() {
113
+ const configuration = yield* normalizeConfigurationEffect(machine, snapshot)
114
+ const outputs = new Map<string, unknown>()
115
+ const completionPaths = new Set<string>()
116
+ const completions = snapshot.completed ?? []
117
+ if (!Array.isArray(completions)) {
118
+ throw new Error("Machine snapshot completion metadata must be an array")
119
+ }
120
+ for (const completion of completions) {
121
+ if (
122
+ typeof completion !== "object" || completion === null ||
123
+ typeof (completion as { readonly path?: unknown }).path !== "string"
124
+ ) {
125
+ throw new Error("Machine snapshot contains malformed completion metadata")
126
+ }
127
+ const path = completion.path
128
+ if (completionPaths.has(path)) {
129
+ throw new Error(`Machine snapshot contains duplicate completion "${path}"`)
130
+ }
131
+ if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) {
132
+ throw new Error(`Machine snapshot contains invalid completion "${path}"`)
133
+ }
134
+ completionPaths.add(path)
135
+ outputs.set(
136
+ path,
137
+ yield* decodeBoundary(machine, getCompletionSchema(machine, configuration, path), completion.output, {
138
+ boundary: "output",
139
+ state: path
140
+ })
141
+ )
142
+ }
143
+ return snapshotFromConfiguration<States>(machine, { ...configuration, outputs })
144
+ }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause)))
145
+
146
+ const validateEncodedConfiguration = (
147
+ machine: Machine.Any,
148
+ configuration: ActiveConfiguration
149
+ ): Machine.Snapshot<any> => {
150
+ const snapshot = snapshotFromConfiguration(machine, configuration)
151
+ const normalized = configurationFromSnapshot(machine, snapshot)
152
+ if (
153
+ normalized.active.size !== configuration.active.size ||
154
+ Array.from(configuration.active).some((path) => !normalized.active.has(path))
155
+ ) {
156
+ throw new Error("Machine encoded snapshot contains states outside its active configuration")
157
+ }
158
+ return snapshot
159
+ }
160
+
161
+ const failEncodeCause = (
162
+ machine: Machine.Any,
163
+ cause: Cause.Cause<unknown>
164
+ ): Effect.Effect<never, MachineSchemaEncodeError> => {
165
+ const error = Cause.findErrorOption(cause)
166
+ return Option.isSome(error) && error.value instanceof MachineSchemaEncodeError
167
+ ? Effect.fail(error.value)
168
+ : Effect.fail(
169
+ new MachineSchemaEncodeError({
170
+ machineId: machine.id,
171
+ boundary: "configuration",
172
+ cause
173
+ })
174
+ )
175
+ }
176
+
177
+ const failDecodeCause = (
178
+ machine: Machine.Any,
179
+ cause: Cause.Cause<unknown>
180
+ ): Effect.Effect<never, MachineSchemaDecodeError> => {
181
+ const error = Cause.findErrorOption(cause)
182
+ return Option.isSome(error) && error.value instanceof MachineSchemaDecodeError
183
+ ? Effect.fail(error.value)
184
+ : Effect.fail(
185
+ new MachineSchemaDecodeError({
186
+ machineId: machine.id,
187
+ boundary: "configuration",
188
+ cause
189
+ })
190
+ )
191
+ }
192
+
193
+ export const encodeSnapshot = (
194
+ machine: Machine.Any,
195
+ snapshot: Machine.Snapshot<any>
196
+ ): Effect.Effect<Machine.EncodedSnapshot, MachineSchemaEncodeError, unknown> =>
197
+ Effect.gen(function*() {
198
+ const configuration = yield* normalizeConfigurationEffect(machine, snapshot).pipe(
199
+ Effect.mapError((error) =>
200
+ new MachineSchemaEncodeError({
201
+ machineId: machine.id,
202
+ boundary: error.boundary === "state" || error.boundary === "history" ? error.boundary : "configuration",
203
+ ...(error.state === undefined ? {} : { state: error.state }),
204
+ cause: error.cause
205
+ })
206
+ )
207
+ )
208
+ const completionPaths = new Set<string>()
209
+ for (const completion of snapshot.completed ?? []) {
210
+ if (completionPaths.has(completion.path)) {
211
+ throw new Error(`Machine snapshot contains duplicate completion "${completion.path}"`)
212
+ }
213
+ if (!configuration.active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) {
214
+ throw new Error(`Machine snapshot contains invalid completion "${completion.path}"`)
215
+ }
216
+ completionPaths.add(completion.path)
217
+ }
218
+ const active: Array<Machine.EncodedSnapshotState> = []
219
+ for (
220
+ const path of Array.from(configuration.active).sort((left, right) => compareDocumentOrder(machine, left, right))
221
+ ) {
222
+ const node = getNode(machine, path)
223
+ active.push({
224
+ path,
225
+ value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), {
226
+ boundary: "state",
227
+ state: path
228
+ })
229
+ })
230
+ }
231
+
232
+ const completed: Array<Machine.EncodedSnapshotCompletion> = []
233
+ for (
234
+ const [path, output] of Array.from(configuration.outputs).sort(([left], [right]) =>
235
+ compareDocumentOrder(machine, left, right)
236
+ )
237
+ ) {
238
+ if (!configuration.active.has(path) || !isActiveFinalNode(machine, configuration, path)) {
239
+ throw new Error(`Machine encoded snapshot contains invalid completion "${path}"`)
240
+ }
241
+ const encodedOutput = yield* encodeBoundary(
242
+ machine,
243
+ getCompletionSchema(machine, configuration, path),
244
+ output,
245
+ {
246
+ boundary: "output",
247
+ state: path
248
+ }
249
+ )
250
+ completed.push({
251
+ path,
252
+ ...(encodedOutput === undefined ? {} : { output: encodedOutput })
253
+ })
254
+ }
255
+
256
+ const history: Record<string, Machine.EncodedSnapshotHistoryEntry> = {}
257
+ for (
258
+ const [historyPath, record] of Array.from(configuration.history).sort(([left], [right]) =>
259
+ left.localeCompare(right)
260
+ )
261
+ ) {
262
+ const historyNode = machine.stateNodes.byPath.get(historyPath)
263
+ if (
264
+ historyNode === undefined || historyNode.type !== "history" || historyNode.parent !== record.parent ||
265
+ historyNode.history !== record.mode
266
+ ) {
267
+ return yield* Effect.fail(
268
+ new MachineSchemaEncodeError({
269
+ machineId: machine.id,
270
+ boundary: "history",
271
+ state: historyPath,
272
+ cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${historyPath}"`))
273
+ })
274
+ )
275
+ }
276
+ try {
277
+ validateHistoryRecordControl(machine, record)
278
+ } catch (cause) {
279
+ return yield* Effect.fail(
280
+ new MachineSchemaEncodeError({
281
+ machineId: machine.id,
282
+ boundary: "history",
283
+ state: historyPath,
284
+ cause: Cause.die(cause)
285
+ })
286
+ )
287
+ }
288
+ const encodedValues: Record<string, unknown> = {}
289
+ for (const path of record.active) {
290
+ const stateNode = machine.stateNodes.byPath.get(path)
291
+ if (
292
+ stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" ||
293
+ !record.values.has(path) ||
294
+ !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path))
295
+ ) {
296
+ return yield* Effect.fail(
297
+ new MachineSchemaEncodeError({
298
+ machineId: machine.id,
299
+ boundary: "history",
300
+ state: path,
301
+ cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${path}"`))
302
+ })
303
+ )
304
+ }
305
+ encodedValues[path] = yield* encodeBoundary(
306
+ machine,
307
+ getStateNodeSchema(stateNode),
308
+ record.values.get(path),
309
+ { boundary: "history", state: path }
310
+ )
311
+ }
312
+ if (record.values.size !== record.active.size) {
313
+ return yield* Effect.fail(
314
+ new MachineSchemaEncodeError({
315
+ machineId: machine.id,
316
+ boundary: "history",
317
+ state: historyPath,
318
+ cause: Cause.die(new Error(`Machine history record "${historyPath}" contains values outside its paths`))
319
+ })
320
+ )
321
+ }
322
+ history[historyPath] = {
323
+ mode: record.mode,
324
+ active: Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right)),
325
+ values: encodedValues
326
+ }
327
+ }
328
+
329
+ return {
330
+ _tag: "MachineSnapshot" as const,
331
+ active,
332
+ ...(completed.length === 0 ? {} : { completed }),
333
+ ...(Object.keys(history).length === 0 ? {} : { history })
334
+ }
335
+ }).pipe(Effect.catchCause((cause) => failEncodeCause(machine, cause)))
336
+
337
+ export const decodeSnapshot = (
338
+ machine: Machine.Any,
339
+ encoded: unknown
340
+ ): Effect.Effect<Machine.Snapshot<any>, MachineSchemaDecodeError, unknown> =>
341
+ Effect.gen(function*() {
342
+ const decoded = yield* Schema.decodeUnknownEffect(EncodedSnapshotSchema)(encoded).pipe(
343
+ Effect.mapError((cause) =>
344
+ new MachineSchemaDecodeError({
345
+ machineId: machine.id,
346
+ boundary: "configuration",
347
+ cause
348
+ })
349
+ )
350
+ )
351
+ const active = new Set<string>()
352
+ const values = new Map<string, unknown>()
353
+ for (const entry of decoded.active) {
354
+ if (active.has(entry.path)) {
355
+ throw new Error(`Machine encoded snapshot contains duplicate state "${entry.path}"`)
356
+ }
357
+ const node = getNode(machine, entry.path)
358
+ active.add(entry.path)
359
+ values.set(
360
+ entry.path,
361
+ yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, {
362
+ boundary: "state",
363
+ state: entry.path
364
+ })
365
+ )
366
+ }
367
+
368
+ const history = new Map<string, HistoryRecord>()
369
+ for (const [historyPath, encodedRecord] of Object.entries(decoded.history ?? {})) {
370
+ const historyNode = machine.stateNodes.byPath.get(historyPath)
371
+ if (
372
+ historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined ||
373
+ historyNode.history !== encodedRecord.mode
374
+ ) {
375
+ return yield* Effect.fail(
376
+ new MachineSchemaDecodeError({
377
+ machineId: machine.id,
378
+ boundary: "history",
379
+ state: historyPath,
380
+ cause: Cause.die(new Error(`Machine encoded snapshot contains invalid history record "${historyPath}"`))
381
+ })
382
+ )
383
+ }
384
+ const rememberedActive = new Set<string>()
385
+ const rememberedValues = new Map<string, unknown>()
386
+ for (const path of encodedRecord.active) {
387
+ if (rememberedActive.has(path)) {
388
+ return yield* Effect.fail(
389
+ new MachineSchemaDecodeError({
390
+ machineId: machine.id,
391
+ boundary: "history",
392
+ state: path,
393
+ cause: Cause.die(new Error(`Machine encoded history contains duplicate state "${path}"`))
394
+ })
395
+ )
396
+ }
397
+ const stateNode = machine.stateNodes.byPath.get(path)
398
+ if (
399
+ stateNode === undefined || stateNode.type === "history" || stateNode.type === "choice" ||
400
+ !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) ||
401
+ !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path))
402
+ ) {
403
+ return yield* Effect.fail(
404
+ new MachineSchemaDecodeError({
405
+ machineId: machine.id,
406
+ boundary: "history",
407
+ state: path,
408
+ cause: Cause.die(new Error(`Machine encoded snapshot contains invalid remembered state "${path}"`))
409
+ })
410
+ )
411
+ }
412
+ rememberedActive.add(path)
413
+ rememberedValues.set(
414
+ path,
415
+ yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], {
416
+ boundary: "history",
417
+ state: path
418
+ })
419
+ )
420
+ }
421
+ if (Object.keys(encodedRecord.values).length !== rememberedActive.size) {
422
+ return yield* Effect.fail(
423
+ new MachineSchemaDecodeError({
424
+ machineId: machine.id,
425
+ boundary: "history",
426
+ state: historyPath,
427
+ cause: Cause.die(new Error(`Machine encoded history "${historyPath}" contains values outside its paths`))
428
+ })
429
+ )
430
+ }
431
+ if (!rememberedActive.has(historyNode.parent)) {
432
+ return yield* Effect.fail(
433
+ new MachineSchemaDecodeError({
434
+ machineId: machine.id,
435
+ boundary: "history",
436
+ state: historyPath,
437
+ cause: Cause.die(new Error(`Machine encoded history "${historyPath}" does not contain its parent state`))
438
+ })
439
+ )
440
+ }
441
+ const record: HistoryRecord = {
442
+ mode: encodedRecord.mode,
443
+ parent: historyNode.parent,
444
+ active: rememberedActive,
445
+ values: rememberedValues
446
+ }
447
+ try {
448
+ validateHistoryRecordControl(machine, record)
449
+ } catch (cause) {
450
+ return yield* Effect.fail(
451
+ new MachineSchemaDecodeError({
452
+ machineId: machine.id,
453
+ boundary: "history",
454
+ state: historyPath,
455
+ cause: Cause.die(cause)
456
+ })
457
+ )
458
+ }
459
+ history.set(historyPath, record)
460
+ }
461
+
462
+ const configuration: ActiveConfiguration = {
463
+ active,
464
+ values,
465
+ outputs: new Map(),
466
+ history
467
+ }
468
+ const snapshot = validateEncodedConfiguration(machine, configuration)
469
+ const completions: Array<Machine.SnapshotCompletion> = []
470
+ const completionPaths = new Set<string>()
471
+ for (const completion of decoded.completed ?? []) {
472
+ if (completionPaths.has(completion.path)) {
473
+ throw new Error(`Machine encoded snapshot contains duplicate completion "${completion.path}"`)
474
+ }
475
+ if (!active.has(completion.path) || !isActiveFinalNode(machine, configuration, completion.path)) {
476
+ throw new Error(`Machine encoded snapshot contains invalid completion "${completion.path}"`)
477
+ }
478
+ completionPaths.add(completion.path)
479
+ completions.push({
480
+ path: completion.path,
481
+ output: yield* decodeEncodedBoundary(
482
+ machine,
483
+ getCompletionSchema(machine, configuration, completion.path),
484
+ completion.output,
485
+ {
486
+ boundary: "output",
487
+ state: completion.path
488
+ }
489
+ )
490
+ })
491
+ }
492
+ if (completions.length > 0) {
493
+ ;(snapshot as Machine.AtomicSnapshot<string, unknown> & {
494
+ completed: ReadonlyArray<Machine.SnapshotCompletion>
495
+ }).completed = completions
496
+ }
497
+ return snapshot
498
+ }).pipe(Effect.catchCause((cause) => failDecodeCause(machine, cause)))