@typeonce/effect-machine 0.2.0 → 0.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.
@@ -10,7 +10,169 @@ import { hasProperty } from "effect/Predicate";
10
10
  import * as Schema from "effect/Schema";
11
11
  import { MachineSchemaDecodeError, MachineSchemaEncodeError } from "./machineErrors.js";
12
12
  export const TargetTypeId = "~effect/Machine/Target";
13
+ export const TargetSnapshotTypeId = Symbol("effect/Machine/TargetSnapshot");
14
+ export const StateInputTypeId = Symbol("effect/Machine/StateInput");
15
+ export const StateConstructionTypeId = Symbol("effect/Machine/StateConstruction");
16
+ export const HistoryTargetTypeId = Symbol("effect/Machine/HistoryTarget");
17
+ const validateHistoryRecordControl = (machine, record) => {
18
+ const ancestry = new Set(getPathToRoot(machine, record.parent));
19
+ const visit = (path) => {
20
+ const node = getNode(machine, path);
21
+ if (node.type === "compound") {
22
+ const children = node.children.filter((child) => record.active.has(child));
23
+ if (children.length !== 1) {
24
+ throw new Error(`Machine history expected compound state "${path}" to retain one active child`);
25
+ }
26
+ if (record.mode === "deep")
27
+ visit(children[0]);
28
+ return;
29
+ }
30
+ if (node.type === "parallel") {
31
+ for (const child of node.children) {
32
+ if (!record.active.has(child)) {
33
+ throw new Error(`Machine history expected parallel state "${path}" to retain region "${child}"`);
34
+ }
35
+ if (record.mode === "deep")
36
+ visit(child);
37
+ }
38
+ }
39
+ };
40
+ visit(record.parent);
41
+ for (const path of record.active) {
42
+ if (!ancestry.has(path) && !isPathInSubtree(path, record.parent)) {
43
+ throw new Error(`Machine history contains state "${path}" outside parent "${record.parent}"`);
44
+ }
45
+ if (record.mode === "shallow" && isDescendantOf(path, record.parent) &&
46
+ getNode(machine, path).parent !== record.parent) {
47
+ throw new Error(`Machine shallow history contains deep descendant "${path}"`);
48
+ }
49
+ }
50
+ };
51
+ const historyFromSnapshot = (machine, snapshot) => {
52
+ const history = new Map();
53
+ for (const [path, entry] of Object.entries(snapshot.history ?? {})) {
54
+ const historyNode = getNode(machine, path);
55
+ if (historyNode.type !== "history" || historyNode.parent === undefined || historyNode.history !== entry.mode) {
56
+ throw new Error(`Machine snapshot contains invalid history record "${path}"`);
57
+ }
58
+ const active = new Set();
59
+ const values = new Map();
60
+ for (const activePath of entry.active) {
61
+ if (active.has(activePath) || !Object.prototype.hasOwnProperty.call(entry.values, activePath)) {
62
+ throw new Error(`Machine snapshot contains invalid remembered state "${activePath}"`);
63
+ }
64
+ const node = getNode(machine, activePath);
65
+ if (node.type === "history" ||
66
+ !(isPathInSubtree(activePath, historyNode.parent) ||
67
+ getPathToRoot(machine, historyNode.parent).includes(activePath)) ||
68
+ !Schema.is(getStateNodeSchema(node))(entry.values[activePath])) {
69
+ throw new Error(`Machine snapshot contains invalid remembered value for "${activePath}"`);
70
+ }
71
+ active.add(activePath);
72
+ values.set(activePath, entry.values[activePath]);
73
+ }
74
+ if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) {
75
+ throw new Error(`Machine snapshot contains incomplete history record "${path}"`);
76
+ }
77
+ history.set(path, {
78
+ mode: entry.mode,
79
+ parent: historyNode.parent,
80
+ active,
81
+ values
82
+ });
83
+ validateHistoryRecordControl(machine, history.get(path));
84
+ }
85
+ return history;
86
+ };
87
+ const historyFromSnapshotEffect = Effect.fnUntraced(function* (machine, snapshot) {
88
+ const history = new Map();
89
+ for (const [path, entry] of Object.entries(snapshot.history ?? {})) {
90
+ const historyNode = machine.stateNodes.byPath.get(path);
91
+ if (historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined ||
92
+ historyNode.history !== entry.mode) {
93
+ return yield* Effect.fail(new MachineSchemaDecodeError({
94
+ machineId: machine.id,
95
+ boundary: "history",
96
+ state: path,
97
+ cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${path}"`))
98
+ }));
99
+ }
100
+ const active = new Set();
101
+ const values = new Map();
102
+ for (const activePath of entry.active) {
103
+ const node = machine.stateNodes.byPath.get(activePath);
104
+ if (active.has(activePath) || node === undefined || node.type === "history" ||
105
+ !Object.prototype.hasOwnProperty.call(entry.values, activePath) ||
106
+ !(isPathInSubtree(activePath, historyNode.parent) ||
107
+ getPathToRoot(machine, historyNode.parent).includes(activePath))) {
108
+ return yield* Effect.fail(new MachineSchemaDecodeError({
109
+ machineId: machine.id,
110
+ boundary: "history",
111
+ state: activePath,
112
+ cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${activePath}"`))
113
+ }));
114
+ }
115
+ active.add(activePath);
116
+ values.set(activePath, yield* decodeBoundary(machine, getStateNodeSchema(node), entry.values[activePath], {
117
+ boundary: "history",
118
+ state: activePath
119
+ }));
120
+ }
121
+ if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) {
122
+ return yield* Effect.fail(new MachineSchemaDecodeError({
123
+ machineId: machine.id,
124
+ boundary: "history",
125
+ state: path,
126
+ cause: Cause.die(new Error(`Machine snapshot contains incomplete history record "${path}"`))
127
+ }));
128
+ }
129
+ history.set(path, {
130
+ mode: entry.mode,
131
+ parent: historyNode.parent,
132
+ active,
133
+ values
134
+ });
135
+ try {
136
+ validateHistoryRecordControl(machine, history.get(path));
137
+ }
138
+ catch (cause) {
139
+ return yield* Effect.fail(new MachineSchemaDecodeError({
140
+ machineId: machine.id,
141
+ boundary: "history",
142
+ state: path,
143
+ cause: Cause.die(cause)
144
+ }));
145
+ }
146
+ }
147
+ return history;
148
+ });
149
+ const historyToSnapshot = (history) => {
150
+ const entries = {};
151
+ for (const [path, record] of history) {
152
+ entries[path] = {
153
+ mode: record.mode,
154
+ active: Array.from(record.active),
155
+ values: Object.fromEntries(record.values)
156
+ };
157
+ }
158
+ return entries;
159
+ };
160
+ export const makeHistoryTarget = (path, parent) => ({
161
+ [HistoryTargetTypeId]: HistoryTargetTypeId,
162
+ path,
163
+ parent
164
+ });
165
+ export const isHistoryTarget = (u) => hasProperty(u, HistoryTargetTypeId);
13
166
  export const getStateNodeDefinition = (path, definition) => {
167
+ if (!Schema.isSchema(definition) && definition.type === "history") {
168
+ return {
169
+ schema: undefined,
170
+ output: undefined,
171
+ type: "history",
172
+ initial: undefined,
173
+ states: undefined
174
+ };
175
+ }
14
176
  if (Schema.isSchema(definition)) {
15
177
  return {
16
178
  schema: definition,
@@ -78,11 +240,20 @@ export const compileStateNodes = (states) => {
78
240
  parent,
79
241
  children: [],
80
242
  initial: definition.initial === undefined ? undefined : `${path}.${definition.initial}`,
243
+ history: definition.type === "history"
244
+ ? (tree[key].history === "deep" ? "deep" : "shallow")
245
+ : undefined,
81
246
  order
82
247
  };
83
248
  byPath.set(path, node);
84
- paths.push(path);
85
249
  order += 1;
250
+ if (definition.type === "history") {
251
+ if (parent === undefined) {
252
+ throw new Error(`Machine history state "${path}" must belong to a parent state`);
253
+ }
254
+ continue;
255
+ }
256
+ paths.push(path);
86
257
  if (definition.states !== undefined) {
87
258
  const children = compile(definition.states, path);
88
259
  if (node.type === "compound" && (node.initial === undefined || !children.includes(node.initial))) {
@@ -101,11 +272,27 @@ export const compileStateNodes = (states) => {
101
272
  };
102
273
  export const makeTarget = (path, value, options) => ({
103
274
  [TargetTypeId]: TargetTypeId,
275
+ [TargetSnapshotTypeId]: options?.snapshot,
104
276
  path,
105
277
  value,
106
278
  values: options?.values
107
279
  });
108
280
  export const isTarget = (u) => hasProperty(u, TargetTypeId);
281
+ export const makeStateInput = (input) => ({
282
+ [StateInputTypeId]: StateInputTypeId,
283
+ input
284
+ });
285
+ const isStateInput = (u) => hasProperty(u, StateInputTypeId);
286
+ export const markStateConstruction = (value) => {
287
+ if ((typeof value === "object" && value !== null) || typeof value === "function") {
288
+ Object.defineProperty(value, StateConstructionTypeId, {
289
+ value: StateConstructionTypeId,
290
+ enumerable: false
291
+ });
292
+ }
293
+ return value;
294
+ };
295
+ export const isStateConstruction = (u) => hasProperty(u, StateConstructionTypeId);
109
296
  export const isSnapshot = (u) => hasProperty(u, "path") && hasProperty(u, "value");
110
297
  export const getSnapshotByPath = (snapshot, path, parents) => {
111
298
  if (snapshot.path === path) {
@@ -170,7 +357,14 @@ export const decodeEmit = (machine, event) => {
170
357
  const eventName = getEventName(event);
171
358
  return decodeBoundary(machine, getProtocolSchemas(machine).emit, event, eventName === undefined ? { boundary: "emit" } : { boundary: "emit", event: eventName });
172
359
  };
173
- export const decodeStateValue = (machine, node, value) => decodeBoundary(machine, node.schema, value, { boundary: "state", state: node.path });
360
+ export const decodeStateValue = (machine, node, value) => isStateInput(value)
361
+ ? getStateNodeSchema(node).makeEffect(value.input).pipe(Effect.mapError((cause) => new MachineSchemaDecodeError({
362
+ machineId: machine.id,
363
+ boundary: "state",
364
+ state: node.path,
365
+ cause
366
+ })))
367
+ : decodeBoundary(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path });
174
368
  export const decodeOutputValue = (machine, node, value) => node.output === undefined
175
369
  ? Effect.succeed(value)
176
370
  : decodeBoundary(machine, node.output, value, { boundary: "output", state: node.path });
@@ -181,6 +375,12 @@ export const getNode = (machine, path) => {
181
375
  }
182
376
  return node;
183
377
  };
378
+ export const getStateNodeSchema = (node) => {
379
+ if (node.schema === undefined || node.type === "history") {
380
+ throw new Error(`Machine history state "${node.path}" has no active value schema`);
381
+ }
382
+ return node.schema;
383
+ };
184
384
  export const hasOwn = (u, key) => Object.prototype.hasOwnProperty.call(u, key);
185
385
  export const isDescendantOf = (path, ancestor) => path.startsWith(`${ancestor}.`);
186
386
  export const isPathInSubtree = (path, ancestor) => path === ancestor || isDescendantOf(path, ancestor);
@@ -224,7 +424,7 @@ export const getRootPath = (machine, configuration) => {
224
424
  };
225
425
  export const getActiveValue = (configuration, path) => {
226
426
  if (!configuration.values.has(path)) {
227
- throw new Error(`Machine expected active state "${path}" to have a value`);
427
+ throw new Error(`Machine expected active state "${path}" to have a value (available: ${Array.from(configuration.values.keys()).join(", ")})`);
228
428
  }
229
429
  return configuration.values.get(path);
230
430
  };
@@ -288,6 +488,19 @@ export const snapshotFromConfiguration = (machine, configuration) => {
288
488
  ;
289
489
  snapshot.completed = completed;
290
490
  }
491
+ if (configuration.history.size > 0) {
492
+ Object.assign(snapshot, { history: historyToSnapshot(configuration.history) });
493
+ }
494
+ return snapshot;
495
+ };
496
+ /** Creates a targetable subtree snapshot while carrying machine-level history
497
+ * metadata on that subtree root. This is used when a nested history target
498
+ * must preserve active ancestors and unaffected parallel regions. */
499
+ export const snapshotFromConfigurationAtPath = (machine, configuration, path) => {
500
+ const snapshot = snapshotFromPath(machine, configuration, path);
501
+ if (configuration.history.size > 0) {
502
+ Object.assign(snapshot, { history: historyToSnapshot(configuration.history) });
503
+ }
291
504
  return snapshot;
292
505
  };
293
506
  export const configurationFromSnapshot = (machine, snapshot) => {
@@ -296,7 +509,7 @@ export const configurationFromSnapshot = (machine, snapshot) => {
296
509
  const snapshotOutputs = snapshot.completed;
297
510
  const visit = (current) => {
298
511
  const node = getNode(machine, String(current.path));
299
- if (!Schema.is(node.schema)(current.value)) {
512
+ if (!Schema.is(getStateNodeSchema(node))(current.value)) {
300
513
  throw new Error(`Machine expected snapshot for "${node.path}" to match its schema`);
301
514
  }
302
515
  active.add(node.path);
@@ -339,7 +552,7 @@ export const configurationFromSnapshot = (machine, snapshot) => {
339
552
  }
340
553
  }
341
554
  }
342
- return { active, values, outputs };
555
+ return { active, values, outputs, history: historyFromSnapshot(machine, snapshot) };
343
556
  };
344
557
  export const normalizeConfiguration = (machine, state) => configurationFromSnapshot(machine, state);
345
558
  export const configurationFromSnapshotEffect = Effect.fnUntraced(function* (machine, snapshot) {
@@ -389,7 +602,12 @@ export const configurationFromSnapshotEffect = Effect.fnUntraced(function* (mach
389
602
  }
390
603
  }
391
604
  }
392
- return { active, values, outputs };
605
+ return {
606
+ active,
607
+ values,
608
+ outputs,
609
+ history: yield* historyFromSnapshotEffect(machine, snapshot)
610
+ };
393
611
  });
394
612
  export const normalizeConfigurationEffect = (machine, state) => configurationFromSnapshotEffect(machine, state).pipe(Effect.catchCause((cause) => {
395
613
  const error = Cause.findErrorOption(cause);
@@ -419,6 +637,85 @@ export const validateInitialConfiguration = (machine, configuration) => {
419
637
  }
420
638
  }
421
639
  };
640
+ /** Capture every history register whose owning parent exits in this microstep.
641
+ * The control record is deliberately independent from effects/actions: it is
642
+ * part of the logical snapshot and is therefore preserved by pure planning. */
643
+ export const captureHistory = (machine, current, next, exitPaths) => {
644
+ if (exitPaths.length === 0) {
645
+ return next;
646
+ }
647
+ const exited = new Set(exitPaths);
648
+ const history = new Map(next.history);
649
+ for (const node of machine.stateNodes.byPath.values()) {
650
+ if (node.type !== "history" || node.parent === undefined || !exited.has(node.parent)) {
651
+ continue;
652
+ }
653
+ const mode = node.history === "deep" ? "deep" : "shallow";
654
+ const active = new Set();
655
+ for (const ancestor of getPathToRoot(machine, node.parent)) {
656
+ if (current.active.has(ancestor)) {
657
+ active.add(ancestor);
658
+ }
659
+ }
660
+ for (const path of current.active) {
661
+ if (path === node.parent ||
662
+ (mode === "deep" && isDescendantOf(path, node.parent)) ||
663
+ (mode === "shallow" && getNode(machine, path).parent === node.parent)) {
664
+ active.add(path);
665
+ }
666
+ }
667
+ const values = new Map();
668
+ for (const path of active) {
669
+ values.set(path, getActiveValue(current, path));
670
+ }
671
+ history.set(node.path, {
672
+ mode,
673
+ parent: node.parent,
674
+ active,
675
+ values
676
+ });
677
+ }
678
+ return {
679
+ active: next.active,
680
+ values: next.values,
681
+ outputs: next.outputs,
682
+ history
683
+ };
684
+ };
685
+ export const getHistoryRecord = (configuration, path) => configuration.history.get(path);
686
+ /** Builds the remembered portion of a configuration. Shallow records are
687
+ * intentionally incomplete below their direct child; the planner completes
688
+ * them by invoking only the required typed initializers. */
689
+ export const configurationFromHistoryRecord = (machine, current, record) => {
690
+ const active = new Set(record.active);
691
+ const values = new Map(record.values);
692
+ const outputs = new Map();
693
+ const ancestors = getPathToRoot(machine, record.parent);
694
+ const ancestorSet = new Set(ancestors);
695
+ // A history transition can occur while an ancestor parallel state remains
696
+ // active. Its unaffected regions retain their current configuration.
697
+ for (const ancestor of ancestors) {
698
+ const node = getNode(machine, ancestor);
699
+ if (node.type !== "parallel") {
700
+ continue;
701
+ }
702
+ for (const child of node.children) {
703
+ if (ancestorSet.has(child) || record.active.has(child) || !current.active.has(child)) {
704
+ continue;
705
+ }
706
+ for (const path of current.active) {
707
+ if (isPathInSubtree(path, child)) {
708
+ active.add(path);
709
+ if (current.values.has(path))
710
+ values.set(path, current.values.get(path));
711
+ if (current.outputs.has(path))
712
+ outputs.set(path, current.outputs.get(path));
713
+ }
714
+ }
715
+ }
716
+ }
717
+ return { active, values, outputs, history: current.history };
718
+ };
422
719
  export const configurationFromTargetPathEffect = Effect.fnUntraced(function* (machine, current, path, value, providedValues) {
423
720
  const node = getNode(machine, path);
424
721
  const active = new Set();
@@ -466,14 +763,77 @@ export const configurationFromTargetPathEffect = Effect.fnUntraced(function* (ma
466
763
  if (node.type === "compound" || node.type === "parallel") {
467
764
  throw new Error(`Machine target "${node.path}" must include an active child state`);
468
765
  }
469
- return { active, values, outputs };
766
+ return {
767
+ active,
768
+ values,
769
+ outputs,
770
+ history: current.history
771
+ };
772
+ });
773
+ export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function* (machine, current, snapshot, providedValues) {
774
+ const subtree = yield* configurationFromSnapshotEffect(machine, snapshot);
775
+ const active = new Set(subtree.active);
776
+ const values = new Map(subtree.values);
777
+ const outputs = new Map(subtree.outputs);
778
+ const paths = getPathToRoot(machine, String(snapshot.path));
779
+ const pathSet = new Set(paths);
780
+ for (const ancestor of paths.slice(0, -1)) {
781
+ const node = getNode(machine, ancestor);
782
+ active.add(ancestor);
783
+ if (providedValues !== undefined && hasOwn(providedValues, ancestor)) {
784
+ values.set(ancestor, yield* decodeStateValue(machine, node, providedValues[ancestor]));
785
+ }
786
+ else if (current.values.has(ancestor)) {
787
+ values.set(ancestor, current.values.get(ancestor));
788
+ }
789
+ else {
790
+ throw new Error(`Machine target "${snapshot.path}" requires a value for ancestor state "${ancestor}"`);
791
+ }
792
+ }
793
+ for (const ancestor of paths.slice(0, -1)) {
794
+ const ancestorNode = getNode(machine, ancestor);
795
+ if (ancestorNode.type === "parallel") {
796
+ for (const child of ancestorNode.children) {
797
+ if (pathSet.has(child) || !current.active.has(child)) {
798
+ continue;
799
+ }
800
+ for (const activePath of current.active) {
801
+ if (isPathInSubtree(activePath, child)) {
802
+ active.add(activePath);
803
+ if (current.values.has(activePath)) {
804
+ values.set(activePath, current.values.get(activePath));
805
+ }
806
+ if (current.outputs.has(activePath)) {
807
+ outputs.set(activePath, current.outputs.get(activePath));
808
+ }
809
+ }
810
+ }
811
+ }
812
+ }
813
+ }
814
+ return {
815
+ active,
816
+ values,
817
+ outputs,
818
+ history: new Map([...current.history, ...subtree.history])
819
+ };
470
820
  });
471
821
  export const normalizeTargetConfigurationEffect = (machine, current, target) => {
472
822
  if (isTarget(target)) {
823
+ const snapshot = target[TargetSnapshotTypeId];
824
+ if (snapshot !== undefined) {
825
+ if (String(snapshot.path) !== String(target.path)) {
826
+ throw new Error(`Machine expected target snapshot path to be "${target.path}"`);
827
+ }
828
+ return configurationFromTargetSnapshotEffect(machine, current, snapshot, target.values);
829
+ }
473
830
  return configurationFromTargetPathEffect(machine, current, target.path, target.value, target.values);
474
831
  }
475
832
  if (isSnapshot(target)) {
476
- return normalizeConfigurationEffect(machine, target);
833
+ return normalizeConfigurationEffect(machine, target).pipe(Effect.map((configuration) => ({
834
+ ...configuration,
835
+ history: new Map([...current.history, ...configuration.history])
836
+ })));
477
837
  }
478
838
  throw new Error("Machine expected transition target to be a snapshot or target builder result");
479
839
  };
@@ -593,7 +953,8 @@ export const completeConfigurationEffect = Effect.fnUntraced(function* (machine,
593
953
  const completed = {
594
954
  active: configuration.active,
595
955
  values: configuration.values,
596
- outputs
956
+ outputs,
957
+ history: configuration.history
597
958
  };
598
959
  for (const path of Array.from(completed.active).sort((left, right) => {
599
960
  const depth = pathDepth(machine, right) - pathDepth(machine, left);
@@ -612,6 +973,11 @@ const EncodedSnapshotSchema = Schema.Struct({
612
973
  completed: Schema.optional(Schema.Array(Schema.Struct({
613
974
  path: Schema.String,
614
975
  output: Schema.optional(Schema.Unknown)
976
+ }))),
977
+ history: Schema.optional(Schema.Record(Schema.String, Schema.Struct({
978
+ mode: Schema.Literals(["shallow", "deep"]),
979
+ active: Schema.Array(Schema.String),
980
+ values: Schema.Record(Schema.String, Schema.Unknown)
615
981
  })))
616
982
  });
617
983
  const encodeBoundary = (machine, schema, value, options) => Schema.encodeUnknownEffect(schema)(value).pipe(Effect.mapError((cause) => new MachineSchemaEncodeError({
@@ -669,7 +1035,7 @@ const failDecodeCause = (machine, cause) => {
669
1035
  export const encodeSnapshot = (machine, snapshot) => Effect.gen(function* () {
670
1036
  const configuration = yield* normalizeConfigurationEffect(machine, snapshot).pipe(Effect.mapError((error) => new MachineSchemaEncodeError({
671
1037
  machineId: machine.id,
672
- boundary: error.boundary === "state" ? "state" : "configuration",
1038
+ boundary: error.boundary === "state" || error.boundary === "history" ? error.boundary : "configuration",
673
1039
  ...(error.state === undefined ? {} : { state: error.state }),
674
1040
  cause: error.cause
675
1041
  })));
@@ -688,7 +1054,7 @@ export const encodeSnapshot = (machine, snapshot) => Effect.gen(function* () {
688
1054
  const node = getNode(machine, path);
689
1055
  active.push({
690
1056
  path,
691
- value: yield* encodeBoundary(machine, node.schema, getActiveValue(configuration, path), {
1057
+ value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), {
692
1058
  boundary: "state",
693
1059
  state: path
694
1060
  })
@@ -708,10 +1074,62 @@ export const encodeSnapshot = (machine, snapshot) => Effect.gen(function* () {
708
1074
  ...(encodedOutput === undefined ? {} : { output: encodedOutput })
709
1075
  });
710
1076
  }
1077
+ const history = {};
1078
+ for (const [historyPath, record] of Array.from(configuration.history).sort(([left], [right]) => left.localeCompare(right))) {
1079
+ const historyNode = machine.stateNodes.byPath.get(historyPath);
1080
+ if (historyNode === undefined || historyNode.type !== "history" || historyNode.parent !== record.parent ||
1081
+ historyNode.history !== record.mode) {
1082
+ return yield* Effect.fail(new MachineSchemaEncodeError({
1083
+ machineId: machine.id,
1084
+ boundary: "history",
1085
+ state: historyPath,
1086
+ cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${historyPath}"`))
1087
+ }));
1088
+ }
1089
+ try {
1090
+ validateHistoryRecordControl(machine, record);
1091
+ }
1092
+ catch (cause) {
1093
+ return yield* Effect.fail(new MachineSchemaEncodeError({
1094
+ machineId: machine.id,
1095
+ boundary: "history",
1096
+ state: historyPath,
1097
+ cause: Cause.die(cause)
1098
+ }));
1099
+ }
1100
+ const encodedValues = {};
1101
+ for (const path of record.active) {
1102
+ const stateNode = machine.stateNodes.byPath.get(path);
1103
+ if (stateNode === undefined || stateNode.type === "history" || !record.values.has(path) ||
1104
+ !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path))) {
1105
+ return yield* Effect.fail(new MachineSchemaEncodeError({
1106
+ machineId: machine.id,
1107
+ boundary: "history",
1108
+ state: path,
1109
+ cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${path}"`))
1110
+ }));
1111
+ }
1112
+ encodedValues[path] = yield* encodeBoundary(machine, getStateNodeSchema(stateNode), record.values.get(path), { boundary: "history", state: path });
1113
+ }
1114
+ if (record.values.size !== record.active.size) {
1115
+ return yield* Effect.fail(new MachineSchemaEncodeError({
1116
+ machineId: machine.id,
1117
+ boundary: "history",
1118
+ state: historyPath,
1119
+ cause: Cause.die(new Error(`Machine history record "${historyPath}" contains values outside its paths`))
1120
+ }));
1121
+ }
1122
+ history[historyPath] = {
1123
+ mode: record.mode,
1124
+ active: Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right)),
1125
+ values: encodedValues
1126
+ };
1127
+ }
711
1128
  return {
712
1129
  _tag: "MachineSnapshot",
713
1130
  active,
714
- ...(completed.length === 0 ? {} : { completed })
1131
+ ...(completed.length === 0 ? {} : { completed }),
1132
+ ...(Object.keys(history).length === 0 ? {} : { history })
715
1133
  };
716
1134
  }).pipe(Effect.catchCause((cause) => failEncodeCause(machine, cause)));
717
1135
  export const decodeSnapshot = (machine, encoded) => Effect.gen(function* () {
@@ -728,15 +1146,91 @@ export const decodeSnapshot = (machine, encoded) => Effect.gen(function* () {
728
1146
  }
729
1147
  const node = getNode(machine, entry.path);
730
1148
  active.add(entry.path);
731
- values.set(entry.path, yield* decodeEncodedBoundary(machine, node.schema, entry.value, {
1149
+ values.set(entry.path, yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, {
732
1150
  boundary: "state",
733
1151
  state: entry.path
734
1152
  }));
735
1153
  }
1154
+ const history = new Map();
1155
+ for (const [historyPath, encodedRecord] of Object.entries(decoded.history ?? {})) {
1156
+ const historyNode = machine.stateNodes.byPath.get(historyPath);
1157
+ if (historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined ||
1158
+ historyNode.history !== encodedRecord.mode) {
1159
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1160
+ machineId: machine.id,
1161
+ boundary: "history",
1162
+ state: historyPath,
1163
+ cause: Cause.die(new Error(`Machine encoded snapshot contains invalid history record "${historyPath}"`))
1164
+ }));
1165
+ }
1166
+ const rememberedActive = new Set();
1167
+ const rememberedValues = new Map();
1168
+ for (const path of encodedRecord.active) {
1169
+ if (rememberedActive.has(path)) {
1170
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1171
+ machineId: machine.id,
1172
+ boundary: "history",
1173
+ state: path,
1174
+ cause: Cause.die(new Error(`Machine encoded history contains duplicate state "${path}"`))
1175
+ }));
1176
+ }
1177
+ const stateNode = machine.stateNodes.byPath.get(path);
1178
+ if (stateNode === undefined || stateNode.type === "history" ||
1179
+ !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) ||
1180
+ !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path))) {
1181
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1182
+ machineId: machine.id,
1183
+ boundary: "history",
1184
+ state: path,
1185
+ cause: Cause.die(new Error(`Machine encoded snapshot contains invalid remembered state "${path}"`))
1186
+ }));
1187
+ }
1188
+ rememberedActive.add(path);
1189
+ rememberedValues.set(path, yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], {
1190
+ boundary: "history",
1191
+ state: path
1192
+ }));
1193
+ }
1194
+ if (Object.keys(encodedRecord.values).length !== rememberedActive.size) {
1195
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1196
+ machineId: machine.id,
1197
+ boundary: "history",
1198
+ state: historyPath,
1199
+ cause: Cause.die(new Error(`Machine encoded history "${historyPath}" contains values outside its paths`))
1200
+ }));
1201
+ }
1202
+ if (!rememberedActive.has(historyNode.parent)) {
1203
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1204
+ machineId: machine.id,
1205
+ boundary: "history",
1206
+ state: historyPath,
1207
+ cause: Cause.die(new Error(`Machine encoded history "${historyPath}" does not contain its parent state`))
1208
+ }));
1209
+ }
1210
+ const record = {
1211
+ mode: encodedRecord.mode,
1212
+ parent: historyNode.parent,
1213
+ active: rememberedActive,
1214
+ values: rememberedValues
1215
+ };
1216
+ try {
1217
+ validateHistoryRecordControl(machine, record);
1218
+ }
1219
+ catch (cause) {
1220
+ return yield* Effect.fail(new MachineSchemaDecodeError({
1221
+ machineId: machine.id,
1222
+ boundary: "history",
1223
+ state: historyPath,
1224
+ cause: Cause.die(cause)
1225
+ }));
1226
+ }
1227
+ history.set(historyPath, record);
1228
+ }
736
1229
  const configuration = {
737
1230
  active,
738
1231
  values,
739
- outputs: new Map()
1232
+ outputs: new Map(),
1233
+ history
740
1234
  };
741
1235
  const snapshot = validateEncodedConfiguration(machine, configuration);
742
1236
  const completions = [];