@jarenjs/flow 0.56.0 → 0.66.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.
package/README.md CHANGED
@@ -261,6 +261,22 @@ gate — `compileFsm`/`compileDag`. The mapping table, and where the pen's
261
261
  refusals end and the compiler's begin, are
262
262
  [FLOW-PEN.md](../linq/docs/FLOW-PEN.md).
263
263
 
264
+ ## Exports
265
+
266
+ Every subpath a consumer can import, derived from the manifest by
267
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
268
+
269
+ <!--fact:exports.flow-->
270
+ | Import | Kind | Declarations |
271
+ |---|---|---|
272
+ | `@jarenjs/flow` | JavaScript | declared |
273
+ | `@jarenjs/flow/schemas/jaren-dag.draft-07.schema.json` | schema | — |
274
+ | `@jarenjs/flow/schemas/jaren-dag.schema.json` | schema | — |
275
+ | `@jarenjs/flow/schemas/jaren-fsm.draft-07.schema.json` | schema | — |
276
+ | `@jarenjs/flow/schemas/jaren-fsm.schema.json` | schema | — |
277
+ | `@jarenjs/flow/package.json` | metadata | — |
278
+ <!--/fact-->
279
+
264
280
  ## Development
265
281
 
266
282
  Unit tests live in `test/flow/` at the repository root
@@ -31,6 +31,13 @@ export type CompiledDag = {
31
31
  * - The output node's id.
32
32
  */
33
33
  output: string;
34
+ /**
35
+ * - Every
36
+ * declared task identity this workflow depends on, keyed by node id and
37
+ * SORTED (§7.8); a nested workflow's map composes under its node's
38
+ * path. Empty when no node declares a version.
39
+ */
40
+ taskVersions: Readonly<Record<string, string>>;
34
41
  /**
35
42
  * -
36
43
  * Execute the graph for one input (`undefined` reads as `null`).
@@ -41,30 +48,6 @@ export type CompiledDag = {
41
48
  runId?: string;
42
49
  }) => Promise<any>;
43
50
  };
44
- /**
45
- * A settlement record handed to `onNode` (§7.4). `restored` fires at
46
- * the start of a RESUMED run for every node whose checkpointed value
47
- * was seeded instead of evaluated (§7.6).
48
- * @typedef {{ id: string, status: 'ok'|'error'|'aborted'|'restored', ms: number }} DagNodeRecord
49
- */
50
- /**
51
- * The opt-in checkpoint store (§7.6): `load` answers a prior run's
52
- * recorded values (or null), `save` records one declared node's
53
- * value, `complete` records the run's result. Any member may return a
54
- * promise; a throwing store fails the run (JF2009), never silently.
55
- * @typedef {Object} DagCheckpointStore
56
- * @property {(runId: string) => any} load
57
- * @property {(runId: string, nodeId: string, value: any) => any} save
58
- * @property {(runId: string, result: any) => any} complete
59
- */
60
- /**
61
- * A compiled jaren-dag graph.
62
- * @typedef {Object} CompiledDag
63
- * @property {readonly string[]} nodes - Declared node ids, document order.
64
- * @property {string} output - The output node's id.
65
- * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
66
- * Execute the graph for one input (`undefined` reads as `null`).
67
- */
68
51
  /**
69
52
  * Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
70
53
  * task registry. Everything is decided here: structural validation,
@@ -72,7 +55,9 @@ export type CompiledDag = {
72
55
  * registry resolution — `run` only executes closures.
73
56
  *
74
57
  * @param {any} doc - the jaren-dag document
75
- * @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any>,
58
+ * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
59
+ * | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
60
+ * taskVersions?: Record<string, string> }>,
76
61
  * checkpoint?: DagCheckpointStore }} [options]
77
62
  * @returns {CompiledDag}
78
63
  * @throws {FlowCompileError} when the document violates the format (JF0xxx)
@@ -81,9 +66,16 @@ export type CompiledDag = {
81
66
  * checkpoint store missing one of load/save/complete)
82
67
  */
83
68
  export declare function compileDag(doc: any, options?: {
84
- tasks?: Record<string, (props: {
69
+ tasks?: Record<string, ((props: {
85
70
  with: any;
86
71
  input: any;
87
- }, signal: AbortSignal) => any>;
72
+ }, signal: AbortSignal) => any) | {
73
+ run: (props: {
74
+ with: any;
75
+ input: any;
76
+ }, signal: AbortSignal) => any;
77
+ version?: string;
78
+ taskVersions?: Record<string, string>;
79
+ }>;
88
80
  checkpoint?: DagCheckpointStore;
89
81
  }): CompiledDag;
@@ -33,6 +33,7 @@ export declare const FLOW_CODES: Readonly<{
33
33
  JF0016: "the graph has a cycle";
34
34
  JF0017: "the document does not declare exactly one output node";
35
35
  JF0018: "a task node names a handler the registry does not provide";
36
+ JF0019: "a task node and its registered handler disagree about the handler version";
36
37
  JF2001: "a state id the machine does not declare";
37
38
  JF2002: "step was called with a non-string event";
38
39
  JF2003: "a guard threw while evaluating";
@@ -5,9 +5,10 @@
5
5
  * captures a session as JSON, `resumeFsmSession` rebuilds one (the
6
6
  * existing JF2001 refusal covers a snapshot naming an undeclared
7
7
  * state), and `createDurableFsmSession` persists through a
8
- * SYNCHRONOUS `{ load, save }` store on every state CHANGE — a store
9
- * that throws fails the send, never loses a transition silently. An
10
- * asynchronous store composes its own wrapper; the session contract
8
+ * SYNCHRONOUS `{ load, save }` store on every fired transition,
9
+ * including self-transitions a store that throws leaves the session
10
+ * at its previous state, so the event can be retried without losing a
11
+ * transition. An asynchronous store composes its own wrapper; the session contract
11
12
  * stays synchronous.
12
13
  */
13
14
  /**
@@ -44,8 +45,10 @@ export declare function resumeFsmSession(fsm: any, snapshot: {
44
45
  /**
45
46
  * A session that persists its state through a synchronous store:
46
47
  * `load()` answers the stored state (or null/undefined for a fresh
47
- * start), `save(state)` records each CHANGED state before the step
48
- * result is returned.
48
+ * start), `save(state)` records every fired transition's state,
49
+ * including self-transitions, before the step result is returned and
50
+ * the session advances. A failed save leaves
51
+ * the session unchanged.
49
52
  * @param {any} fsm - a machine from `compileFsm`
50
53
  * @param {{ load: () => string | null | undefined,
51
54
  * save: (state: string) => void }} store
@@ -57,5 +60,5 @@ export declare function createDurableFsmSession(fsm: any, store: {
57
60
  readonly state: string;
58
61
  readonly done: boolean;
59
62
  can: (event: any, opts: any) => boolean;
60
- send(event: any, opts: any): import("./fsm.js").FsmStepResult;
63
+ send(event: any, opts: any): any;
61
64
  }>;
@@ -210,6 +210,7 @@ tables there and here MUST stay in sync.
210
210
  | JF0016 | the graph has a cycle (member ids in the message, `docPath` at the first edge inside it) |
211
211
  | JF0017 | not exactly one `output` node |
212
212
  | JF0018 | a `task` node names no registered handler |
213
+ | JF0019 | a `task` node and its registered handler disagree about the handler version |
213
214
 
214
215
  ### §5.2 Runtime: thrown vs recorded
215
216
 
@@ -305,7 +306,8 @@ A node's `$` is decided by its inbound edges:
305
306
  inbound edge MUST name one, ports MUST be unique, and `$` is the
306
307
  object of port-named values, members in **edge document order**. A
307
308
  single ported edge therefore yields `{ port: value }` — the way to
308
- force the object shape.
309
+ force the object shape. Each port is an own object member, including
310
+ names that also occur on `Object.prototype`.
309
311
  - Two or more unported inbound edges are a compile error (JF0015).
310
312
 
311
313
  A delivery whose `select` yields the empty sequence delivers `null`; a
@@ -429,6 +431,51 @@ await dag.run(input, { runId: 'run-42' });
429
431
  reason). Values recorded for node ids the current document does not
430
432
  declare (or no longer declares `checkpoint`) are ignored.
431
433
 
434
+ ### §7.8 Declared task versions
435
+
436
+ A checkpointed node's value is REPLAYED on a later run instead of being
437
+ recomputed. That is sound only while the handler that produced it is the
438
+ same handler. So a `task` node that declares `checkpoint: true` must also
439
+ declare `version`: a non-empty string naming the identity of the
440
+ implementation it depends on.
441
+
442
+ ```jsonc
443
+ { "kind": "task", "run": "summarise", "version": "2026-09-05", "checkpoint": true }
444
+ ```
445
+
446
+ **The identity is declared, never derived.** Hashing the handler's source
447
+ would call a reformat a new task and a changed dependency the same one;
448
+ neither is what a caller means. The host states it, and the host is the
449
+ only party that knows when its implementation actually changed.
450
+
451
+ **The registry must agree.** A registered handler is either a bare
452
+ function — the shorthand, which carries no version — or
453
+ `{ run, version }`. A node that declares a version against a bare handler,
454
+ or against a different version, is `JF0019` at COMPILE time, before any
455
+ node runs. The shorthand therefore serves exactly the workflows that
456
+ checkpoint nothing, and a checkpointed node cannot use it.
457
+
458
+ `version` is allowed on a node that does not checkpoint, so a workflow can
459
+ carry one identity vocabulary throughout.
460
+
461
+ **The canonical map.** A compiled workflow answers `taskVersions`: every
462
+ declared identity it depends on, keyed by node id and SORTED, so two
463
+ compiles of the same document produce the same map — byte for byte —
464
+ whatever order the declarations were written in. A handler that is itself
465
+ a compiled workflow may expose its own `taskVersions`; those compose under
466
+ the node's path (`outer`, `outer/inner`), so a composed run has one
467
+ identity rather than two. Every task id is retained as an own member,
468
+ including names inherited by ordinary JavaScript objects.
469
+
470
+ ```js
471
+ compileDag(doc, { tasks: { summarise: { version: '2026-09-05', run: handler } } })
472
+ .taskVersions; // → { "draft": "2026-09-05", "draft/tidy": "1" }
473
+ ```
474
+
475
+ That map is what a durable queue fingerprints alongside the workflow
476
+ revision and the input, so a run cannot resume onto checkpoints written by
477
+ an implementation nobody is running any more.
478
+
432
479
  ### §7.7 FSM persistence
433
480
 
434
481
  Needs nothing new: `step` is pure and a session's whole durable state
@@ -436,9 +483,11 @@ IS its current state string. `@jarenjs/flow` ships three thin helpers
436
483
  — `snapshotFsm(session)` → `{ state }`, `resumeFsmSession(fsm,
437
484
  snapshot)` (an undeclared state refuses with the session's own
438
485
  JF2001), and `createDurableFsmSession(fsm, { load, save })`, which
439
- persists through a SYNCHRONOUS store on every state CHANGE, before
440
- the step result returns; a throwing `save` fails the send rather than
441
- lose a transition. A worked example over a `@jarenjs/db` collection:
486
+ persists through a SYNCHRONOUS store on every fired transition,
487
+ including self-transitions (`changed: true`), before
488
+ the step result returns and the session advances; a throwing `save`
489
+ leaves the session unchanged so the event can be retried. A worked
490
+ example over a `@jarenjs/db` collection:
442
491
 
443
492
  ```js
444
493
  const machine = compileFsm(doc);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/flow",
3
3
  "private": false,
4
- "version": "0.56.0",
4
+ "version": "0.66.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "prepack": "npm run build:types"
50
50
  },
51
51
  "dependencies": {
52
- "@jarenjs/core": "^0.56.0",
53
- "@jarenjs/json": "^0.56.0"
52
+ "@jarenjs/core": "^0.66.1",
53
+ "@jarenjs/json": "^0.66.1"
54
54
  }
55
55
  }
@@ -146,6 +146,11 @@
146
146
  "type": "string",
147
147
  "minLength": 1
148
148
  },
149
+ "version": {
150
+ "description": "The declared identity of the handler implementation this node depends on (FLOW-FORMAT section 7.8). REQUIRED when checkpoint is true: a recorded value is replayed only while the handler that produced it is the same one, and that identity is declared, never derived from source. The registry must supply the same token.",
151
+ "type": "string",
152
+ "minLength": 1
153
+ },
149
154
  "with": {
150
155
  "description": "Handler props: a query resolved against the node's input scope (null when absent or empty).",
151
156
  "allOf": [
@@ -146,6 +146,11 @@
146
146
  "type": "string",
147
147
  "minLength": 1
148
148
  },
149
+ "version": {
150
+ "description": "The declared identity of the handler implementation this node depends on (FLOW-FORMAT section 7.8). REQUIRED when checkpoint is true: a recorded value is replayed only while the handler that produced it is the same one, and that identity is declared, never derived from source. The registry must supply the same token.",
151
+ "type": "string",
152
+ "minLength": 1
153
+ },
149
154
  "with": {
150
155
  "description": "Handler props: a query resolved against the node's input scope (null when absent or empty).",
151
156
  "allOf": [
package/src/dag.js CHANGED
@@ -18,7 +18,7 @@ import { compileJsonQuery } from '@jarenjs/json/query';
18
18
  import { compileJsltStylesheet } from '@jarenjs/json/jslt';
19
19
  import { canonicalizeJson } from '@jarenjs/json/canonical';
20
20
  import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
21
- import { isJsonObject } from '@jarenjs/core/object';
21
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
22
22
  import { asError, FlowCompileError, FlowRuntimeError } from './errors.js';
23
23
 
24
24
  const KINDS = ['input', 'output', 'const', 'query', 'jslt', 'task'];
@@ -67,10 +67,49 @@ function compileEmbedded(compile, embedded, docPath) {
67
67
  * @typedef {Object} CompiledDag
68
68
  * @property {readonly string[]} nodes - Declared node ids, document order.
69
69
  * @property {string} output - The output node's id.
70
+ * @property {Readonly<Record<string, string>>} taskVersions - Every
71
+ * declared task identity this workflow depends on, keyed by node id and
72
+ * SORTED (§7.8); a nested workflow's map composes under its node's
73
+ * path. Empty when no node declares a version.
70
74
  * @property {(input?: any, opts?: { signal?: AbortSignal, onNode?: (record: DagNodeRecord) => void, runId?: string }) => Promise<any>} run -
71
75
  * Execute the graph for one input (`undefined` reads as `null`).
72
76
  */
73
77
 
78
+ /**
79
+ * One registry entry, in either accepted spelling.
80
+ *
81
+ * `{ run, version }` is the full one. A bare function is the shorthand,
82
+ * and it carries no version — which is why a checkpointed node, whose
83
+ * declared version has nothing to be compared against, cannot use it.
84
+ * An entry may also expose a `taskVersions` map of its own: a handler
85
+ * that is itself a compiled workflow contributes its versions under this
86
+ * node's path, so a composed run has one identity, not two.
87
+ * @param {any} entry
88
+ * @param {string} name
89
+ * @returns {{ run: Function, version: string | null, taskVersions: Record<string, string> | null }}
90
+ */
91
+ function normalizeTaskEntry(entry, name) {
92
+ if (typeof entry === 'function') return { run: entry, version: null, taskVersions: null };
93
+ if (!isJsonObject(entry) || typeof entry.run !== 'function') {
94
+ throw new TypeError(
95
+ `compileDag: the registered handler '${name}' is not a function, nor { run, version }`);
96
+ }
97
+ if (entry.version !== undefined
98
+ && (typeof entry.version !== 'string' || entry.version === '')) {
99
+ throw new TypeError(
100
+ `compileDag: the registered handler '${name}' has a version that is not a non-empty string`);
101
+ }
102
+ if (entry.taskVersions !== undefined && !isJsonObject(entry.taskVersions)) {
103
+ throw new TypeError(
104
+ `compileDag: the registered handler '${name}' has a taskVersions that is not an object`);
105
+ }
106
+ return {
107
+ run: entry.run,
108
+ version: entry.version ?? null,
109
+ taskVersions: entry.taskVersions ?? null,
110
+ };
111
+ }
112
+
74
113
  /**
75
114
  * Compile a jaren-dag document (docs/FLOW-FORMAT.md §6–§7) against a
76
115
  * task registry. Everything is decided here: structural validation,
@@ -78,7 +117,9 @@ function compileEmbedded(compile, embedded, docPath) {
78
117
  * registry resolution — `run` only executes closures.
79
118
  *
80
119
  * @param {any} doc - the jaren-dag document
81
- * @param {{ tasks?: Record<string, (props: { with: any, input: any }, signal: AbortSignal) => any>,
120
+ * @param {{ tasks?: Record<string, ((props: { with: any, input: any }, signal: AbortSignal) => any)
121
+ * | { run: (props: { with: any, input: any }, signal: AbortSignal) => any, version?: string,
122
+ * taskVersions?: Record<string, string> }>,
82
123
  * checkpoint?: DagCheckpointStore }} [options]
83
124
  * @returns {CompiledDag}
84
125
  * @throws {FlowCompileError} when the document violates the format (JF0xxx)
@@ -160,16 +201,45 @@ export function compileDag(doc, options) {
160
201
  throw new FlowCompileError('JF0011',
161
202
  `task node '${id}' must carry a non-empty string "run"`, `${base}/run`);
162
203
  }
204
+ if (decl.version !== undefined
205
+ && (typeof decl.version !== 'string' || decl.version === '')) {
206
+ throw new FlowCompileError('JF0011',
207
+ `task node '${id}' has a "version" member that is not a non-empty string`,
208
+ `${base}/version`);
209
+ }
210
+ // a checkpointed node's output is REPLAYED on a later run, which
211
+ // is only sound while the implementation that produced it is the
212
+ // same implementation. That identity is declared, never derived:
213
+ // hashing a closure's source would call a reformat a new task and
214
+ // a changed dependency the same one
215
+ if (node.checkpoint && decl.version === undefined) {
216
+ throw new FlowCompileError('JF0011',
217
+ `task node '${id}' declares checkpoint, so it must also declare a "version" — `
218
+ + 'a checkpointed result is replayed only while the handler that produced it is '
219
+ + 'the same one, and that identity has to be stated', `${base}/version`);
220
+ }
163
221
  if (!Object.hasOwn(tasks, decl.run)) {
164
222
  throw new FlowCompileError('JF0018',
165
223
  `task node '${id}' names the handler '${decl.run}', which the registry does not provide`,
166
224
  `${base}/run`);
167
225
  }
168
- if (typeof tasks[decl.run] !== 'function') {
169
- throw new TypeError(
170
- `compileDag: the registered handler '${decl.run}' is not a function`);
226
+ const entry = normalizeTaskEntry(tasks[decl.run], decl.run);
227
+ if (decl.version !== undefined) {
228
+ if (entry.version === null) {
229
+ throw new FlowCompileError('JF0019',
230
+ `task node '${id}' declares version '${decl.version}', but the registry provides `
231
+ + `'${decl.run}' as a bare handler with no version — register it as `
232
+ + '{ run, version } so the two can be compared', `${base}/version`);
233
+ }
234
+ if (entry.version !== decl.version) {
235
+ throw new FlowCompileError('JF0019',
236
+ `task node '${id}' declares version '${decl.version}', but the registry provides `
237
+ + `'${decl.run}' at version '${entry.version}'`, `${base}/version`);
238
+ }
171
239
  }
172
- node.handler = tasks[decl.run];
240
+ node.handler = entry.run;
241
+ node.version = decl.version ?? null;
242
+ node.nestedVersions = entry.taskVersions;
173
243
  node.with = decl.with === undefined
174
244
  ? null
175
245
  : compileEmbedded(compileJsonQuery, decl.with, `${base}/with`);
@@ -449,7 +519,7 @@ export function compileDag(doc, options) {
449
519
  if (node.ports) {
450
520
  scope = {};
451
521
  for (let i = 0; i < node.inbound.length; i++) {
452
- scope[node.inbound[i].port] = delivered[i];
522
+ setObjectMember(scope, node.inbound[i].port, delivered[i]);
453
523
  }
454
524
  }
455
525
  else if (node.inbound.length === 1) {
@@ -552,9 +622,28 @@ export function compileDag(doc, options) {
552
622
  return result;
553
623
  }
554
624
 
625
+ // The canonical version map: every declared task identity this
626
+ // workflow depends on, keyed by node path and SORTED, so two compiles
627
+ // of the same document answer the same map whatever order the
628
+ // declarations were written in. A handler that is itself a workflow
629
+ // contributes its own map under this node's path.
630
+ /** @type {Record<string, string>} */
631
+ const versions = {};
632
+ for (const node of nodes.values()) {
633
+ if (node.kind !== 'task' || node.version === null) continue;
634
+ setObjectMember(versions, node.id, node.version);
635
+ if (node.nestedVersions === null) continue;
636
+ for (const [path, version] of Object.entries(node.nestedVersions)) {
637
+ setObjectMember(versions, `${node.id}/${path}`, version);
638
+ }
639
+ }
640
+ const taskVersions = Object.freeze(Object.fromEntries(
641
+ Object.keys(versions).sort().map((key) => [key, versions[key]])));
642
+
555
643
  return Object.freeze({
556
644
  nodes: Object.freeze(order.slice()),
557
645
  output: outputId,
646
+ taskVersions,
558
647
  run,
559
648
  });
560
649
  }
package/src/errors.js CHANGED
@@ -36,6 +36,7 @@ export const FLOW_CODES = Object.freeze({
36
36
  JF0016: 'the graph has a cycle',
37
37
  JF0017: 'the document does not declare exactly one output node',
38
38
  JF0018: 'a task node names a handler the registry does not provide',
39
+ JF0019: 'a task node and its registered handler disagree about the handler version',
39
40
  JF2001: 'a state id the machine does not declare',
40
41
  JF2002: 'step was called with a non-string event',
41
42
  JF2003: 'a guard threw while evaluating',
package/src/persist.js CHANGED
@@ -6,9 +6,10 @@
6
6
  * captures a session as JSON, `resumeFsmSession` rebuilds one (the
7
7
  * existing JF2001 refusal covers a snapshot naming an undeclared
8
8
  * state), and `createDurableFsmSession` persists through a
9
- * SYNCHRONOUS `{ load, save }` store on every state CHANGE — a store
10
- * that throws fails the send, never loses a transition silently. An
11
- * asynchronous store composes its own wrapper; the session contract
9
+ * SYNCHRONOUS `{ load, save }` store on every fired transition,
10
+ * including self-transitions a store that throws leaves the session
11
+ * at its previous state, so the event can be retried without losing a
12
+ * transition. An asynchronous store composes its own wrapper; the session contract
12
13
  * stays synchronous.
13
14
  */
14
15
 
@@ -40,8 +41,10 @@ export function resumeFsmSession(fsm, snapshot) {
40
41
  /**
41
42
  * A session that persists its state through a synchronous store:
42
43
  * `load()` answers the stored state (or null/undefined for a fresh
43
- * start), `save(state)` records each CHANGED state before the step
44
- * result is returned.
44
+ * start), `save(state)` records every fired transition's state,
45
+ * including self-transitions, before the step result is returned and
46
+ * the session advances. A failed save leaves
47
+ * the session unchanged.
45
48
  * @param {any} fsm - a machine from `compileFsm`
46
49
  * @param {{ load: () => string | null | undefined,
47
50
  * save: (state: string) => void }} store
@@ -51,14 +54,15 @@ export function createDurableFsmSession(fsm, store) {
51
54
  throw new TypeError('createDurableFsmSession: the store must provide load and save');
52
55
  }
53
56
  const stored = store.load();
54
- const session = createFsmSession(fsm, stored ?? undefined);
57
+ let session = createFsmSession(fsm, stored ?? undefined);
55
58
  return Object.freeze({
56
59
  get state() { return session.state; },
57
60
  get done() { return session.done; },
58
61
  can: (event, opts) => session.can(event, opts),
59
62
  send(event, opts) {
60
- const result = session.send(event, opts);
63
+ const result = fsm.step(session.state, event, opts);
61
64
  if (result.changed) store.save(result.state);
65
+ session = createFsmSession(fsm, result.state);
62
66
  return result;
63
67
  },
64
68
  });