@ronaldroe/micro-flow 1.3.9 → 2.0.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.
Files changed (51) hide show
  1. package/README.md +51 -10
  2. package/dist/src/classes/base.js +2 -2
  3. package/dist/src/classes/base.js.map +3 -3
  4. package/dist/src/classes/callable_registry.js +1 -1
  5. package/dist/src/classes/callable_registry.js.map +2 -2
  6. package/dist/src/classes/events/event.js +1 -1
  7. package/dist/src/classes/events/event.js.map +3 -3
  8. package/dist/src/classes/index.js +1 -1
  9. package/dist/src/classes/index.js.map +3 -3
  10. package/dist/src/classes/state.js +1 -1
  11. package/dist/src/classes/state.js.map +3 -3
  12. package/dist/src/classes/steps/case.js +1 -1
  13. package/dist/src/classes/steps/case.js.map +3 -3
  14. package/dist/src/classes/steps/conditional_step.js +1 -1
  15. package/dist/src/classes/steps/conditional_step.js.map +3 -3
  16. package/dist/src/classes/steps/delay_step.js +1 -1
  17. package/dist/src/classes/steps/delay_step.js.map +2 -2
  18. package/dist/src/classes/steps/flow_control_step.js +1 -1
  19. package/dist/src/classes/steps/flow_control_step.js.map +2 -2
  20. package/dist/src/classes/steps/logic_step.js +1 -1
  21. package/dist/src/classes/steps/logic_step.js.map +3 -3
  22. package/dist/src/classes/steps/loop_step.js +1 -1
  23. package/dist/src/classes/steps/loop_step.js.map +3 -3
  24. package/dist/src/classes/steps/step.js +1 -1
  25. package/dist/src/classes/steps/step.js.map +3 -3
  26. package/dist/src/classes/steps/switch_step.js +1 -1
  27. package/dist/src/classes/steps/switch_step.js.map +3 -3
  28. package/dist/src/classes/workflow.js +1 -1
  29. package/dist/src/classes/workflow.js.map +3 -3
  30. package/dist/src/enums/delay_types.js.map +1 -1
  31. package/dist/src/enums/logic_step_types.js.map +3 -3
  32. package/dist/src/enums/sub_step_types.js +1 -1
  33. package/dist/src/enums/sub_step_types.js.map +2 -2
  34. package/package.json +1 -1
  35. package/src/classes/base.js +42 -11
  36. package/src/classes/callable_registry.js +82 -0
  37. package/src/classes/events/event.js +3 -3
  38. package/src/classes/index.js +2 -1
  39. package/src/classes/state.js +278 -8
  40. package/src/classes/steps/case.js +34 -4
  41. package/src/classes/steps/conditional_step.js +69 -3
  42. package/src/classes/steps/delay_step.js +18 -0
  43. package/src/classes/steps/flow_control_step.js +18 -2
  44. package/src/classes/steps/logic_step.js +26 -8
  45. package/src/classes/steps/loop_step.js +88 -3
  46. package/src/classes/steps/step.js +228 -16
  47. package/src/classes/steps/switch_step.js +66 -8
  48. package/src/classes/workflow.js +237 -40
  49. package/src/enums/delay_types.js +1 -1
  50. package/src/enums/logic_step_types.js +2 -2
  51. package/src/enums/sub_step_types.js +10 -10
@@ -1,5 +1,8 @@
1
1
  import crypto from 'crypto';
2
2
  import Base from './base.js';
3
+ import CallableRegistry from './callable_registry.js';
4
+ import Step from './steps/step.js';
5
+ import State from './state.js';
3
6
  import { base_types } from '../enums/index.js';
4
7
 
5
8
  /**
@@ -12,30 +15,48 @@ export default class Workflow extends Base {
12
15
  * Creates a new Workflow instance.
13
16
  * @param {Object} options - Configuration options.
14
17
  * @param {string} [options.name] - Name of the workflow.
18
+ * @param {CallableRegistry|null} [options.callable_registry=null] - Registry for callable objects.
15
19
  * @param {boolean} [options.exit_on_error=false] - Whether to exit on error.
16
20
  * @param {Array<Step>} [options.steps=[]] - Array of steps to add to the workflow.
17
21
  * @param {boolean} [options.throw_on_empty=false] - Whether to throw error if workflow is empty.
22
+ * @param {boolean} [options.use_state_singleton=false] - Deprecated. When true, this workflow (and every
23
+ * `Step` it owns) reads/writes `getState`/`setState`/`deleteState` calls through the process-wide `State`
24
+ * singleton instead of this workflow's own state.
18
25
  */
19
26
  constructor({
20
27
  name,
28
+ callable_registry = null,
21
29
  exit_on_error = false,
30
+ result_per_step = false,
31
+ result_per_step_function = null,
22
32
  steps = [],
23
- throw_on_empty = false
33
+ throw_on_empty = false,
34
+ use_state_singleton = false,
24
35
  }) {
25
- super({ name, base_type: base_types.WORKFLOW });
26
-
27
- this.initializeWorkflowState();
28
-
29
- this.addSteps(steps);
36
+ super({ name, base_type: base_types.WORKFLOW, use_state_singleton });
30
37
 
38
+ this.callable_registry = callable_registry ?? new CallableRegistry();
39
+ this.current_session_id = null;
31
40
  this.exit_on_error = exit_on_error;
32
- this.throw_on_empty = throw_on_empty;
41
+ this.result_per_step = result_per_step;
33
42
  this.sessions = {};
34
- this.current_session_id = null;
43
+ this.throw_on_empty = throw_on_empty;
44
+ this.result_per_step_function = result_per_step_function;
45
+
46
+ // _steps/steps_by_id must exist before initializeWorkflowState(): it reads this._steps
47
+ // (to set current_step) and logs, which serializes `this` - both need this._steps to
48
+ // already be an array, even when no steps are passed (addSteps([]) never calls addStep,
49
+ // so it wouldn't otherwise get initialized).
50
+ this._steps = [];
51
+ this.steps_by_id = {};
52
+ this.addSteps(steps);
53
+ this.initializeWorkflowState();
35
54
  }
36
55
 
37
56
  /**
38
57
  * Executes the workflow by running all steps in sequence.
58
+ * If the workflow is currently `paused`, resumes from the step after the one
59
+ * that was running when it paused, rather than starting over from the beginning.
39
60
  * @async
40
61
  * @returns {Promise<Workflow>} The workflow instance with execution results.
41
62
  * @throws {Error} Throws if workflow is empty and throw_on_empty is true.
@@ -51,13 +72,17 @@ export default class Workflow extends Base {
51
72
  }
52
73
 
53
74
  this.markAsComplete();
54
- this.prepareResult('Workflow is empty', null);
75
+ await this.prepareResult('Workflow is empty', null);
55
76
  return this;
56
77
  }
57
-
78
+
79
+ const is_resuming = this.status === this.getState('statuses.workflow').PAUSED;
80
+ const paused_at_index = this._steps.findIndex(step => step.id === this.current_step);
81
+ const start_index = is_resuming ? paused_at_index + 1 : 0;
82
+
58
83
  this.markAsRunning();
59
84
 
60
- for (let i = 0; i < this._steps.length; i++) {
85
+ for (let i = start_index; i < this._steps.length; i++) {
61
86
  if (this.should_break) {
62
87
  this.log(this.getState('event_names.workflow').WORKFLOW_BREAK_EXECUTED, `Workflow "${this.name}" execution broken at step ${this._steps[i].name} - ${this._steps[i].id}.`);
63
88
  break;
@@ -76,10 +101,10 @@ export default class Workflow extends Base {
76
101
 
77
102
  try {
78
103
  const step_result = await this.step();
79
- this.prepareResult('Success', step_result);
104
+ await this.prepareResult('Success', step_result);
80
105
  } catch (error) {
81
106
  this.markAsFailed();
82
- this.prepareResult(`Workflow execution failed at step ${this.steps_by_id[this.current_step].name} - ${this.current_step}`, { error });
107
+ await this.prepareResult(`Workflow execution failed at step ${this.steps_by_id[this.current_step].name} - ${this.current_step}`, { error });
83
108
 
84
109
  if (this.exit_on_error) {
85
110
  return this;
@@ -94,7 +119,7 @@ export default class Workflow extends Base {
94
119
  }
95
120
 
96
121
  this.markAsComplete();
97
- return this;
122
+ return this.prepareForSerialization();
98
123
  }
99
124
 
100
125
  /**
@@ -121,7 +146,6 @@ export default class Workflow extends Base {
121
146
  async step() {
122
147
  const step = this.steps_by_id[this.current_step];
123
148
 
124
- step.parentWorkflowId = this.id;
125
149
  const result = await step.execute();
126
150
 
127
151
  if (step.status === this.getState('statuses.step.FAILED')) {
@@ -137,8 +161,10 @@ export default class Workflow extends Base {
137
161
  * @throws {Error} Throws if step is not a valid Step instance.
138
162
  */
139
163
  addStep(step) {
164
+ // This check only ensures that the getCallableType method exists,
165
+ // which is a characteristic of Step instances
140
166
  if (typeof step.getCallableType !== 'function') {
141
- throw new Error('Invalid step type. Must be an instance of Step.');
167
+ throw new Error('Invalid input. Must be an instance of Step.');
142
168
  }
143
169
 
144
170
  if (!Array.isArray(this._steps)) {
@@ -151,7 +177,10 @@ export default class Workflow extends Base {
151
177
 
152
178
  this.steps_by_id[step.id] = step;
153
179
 
154
- step.parentWorkflowId = this.id;
180
+ step.parent_workflow_id = this.id;
181
+ step.parent_workflow = this.prepareForSerialization();
182
+ step.use_state_singleton = this.use_state_singleton;
183
+ step.state = this.state;
155
184
  this._steps.push(step);
156
185
  }
157
186
 
@@ -166,7 +195,10 @@ export default class Workflow extends Base {
166
195
  }
167
196
 
168
197
  this.steps_by_id[step.id] = step;
169
- step.parentWorkflowId = this.id;
198
+ step.parent_workflow_id = this.id;
199
+ step.parent_workflow = this.prepareForSerialization();
200
+ step.use_state_singleton = this.use_state_singleton;
201
+ step.state = this.state;
170
202
  this._steps.splice(index, 0, step);
171
203
  }
172
204
 
@@ -175,6 +207,10 @@ export default class Workflow extends Base {
175
207
  * @param {Step[]} steps - Array of steps to add.
176
208
  */
177
209
  addSteps(steps) {
210
+ if (!Array.isArray(steps)) {
211
+ throw new Error('Invalid input. Must be an array of Step instances.');
212
+ }
213
+
178
214
  steps.forEach(step => this.addStep(step));
179
215
  }
180
216
 
@@ -183,6 +219,7 @@ export default class Workflow extends Base {
183
219
  */
184
220
  clearSteps() {
185
221
  this._steps = [];
222
+ this.steps_by_id = {};
186
223
  }
187
224
 
188
225
  /**
@@ -207,6 +244,10 @@ export default class Workflow extends Base {
207
244
  * @param {string} stepId - The ID of the step to delete.
208
245
  */
209
246
  deleteStep(stepId) {
247
+ if (!Array.isArray(this._steps)) {
248
+ this._steps = [];
249
+ }
250
+
210
251
  this._steps = this._steps.filter(step => step.id !== stepId);
211
252
  }
212
253
 
@@ -215,28 +256,48 @@ export default class Workflow extends Base {
215
256
  * @param {number} index - The index of the step to delete.
216
257
  */
217
258
  deleteStepByIndex(index) {
259
+ if (!Array.isArray(this._steps)) {
260
+ this._steps = [];
261
+ }
262
+
218
263
  this._steps.splice(index, 1);
219
264
  }
220
265
 
266
+ /**
267
+ * Resolves a nested property path within this workflow's own state - the low-level counterpart
268
+ * to `getState()`. Falls back to the deprecated `State` singleton's resolver when
269
+ * `use_state_singleton` is `true`.
270
+ * @param {string} path - Path to the state property.
271
+ * @param {boolean} [emit=true] - Only meaningful when `use_state_singleton` is `true`; whether
272
+ * to emit the singleton's `GET_FROM_PROPERTY_PATH` state event.
273
+ * @returns {*} The value at the specified path, or undefined if not found.
274
+ */
275
+ getStateFromPropertyPath(path, emit = true) {
276
+ if (this.use_state_singleton) {
277
+ console.warn('The state singleton has been deprecated. Use the .prepareForSerialization() method on the workflow instance instead.');
278
+ return State.getFromPropertyPath(path, emit);
279
+ }
280
+
281
+ return this.state.getStateFromPropertyPath(path);
282
+ }
283
+
221
284
  /**
222
285
  * Initializes the workflow state with default values.
223
286
  */
224
287
  initializeWorkflowState() {
225
- this.results = [];
226
- this.exit_on_error = false;
227
- this.current_step = null;
228
- this.should_break = false;
229
- this.should_continue = false;
230
- this.should_pause = false;
231
- this.should_skip = false;
232
- this.status = this.getState('statuses.workflow').CREATED;
233
- this._steps = [];
234
- this.throw_on_empty = this.throw_on_empty;
288
+ this.current_step = ! this.isEmpty() ? this._steps[0].id : null;
289
+ this.results = this.results ?? [];
290
+ this.sessions = this.sessions ?? {};
291
+ this.should_break = this.should_break ?? false;
292
+ this.should_continue = this.should_continue ?? false;
293
+ this.should_pause = this.should_pause ?? false;
294
+ this.should_skip = this.should_skip ?? false;
295
+ this.status = this.status ?? this.getState('statuses.workflow').CREATED;
235
296
  this.timing = {
236
297
  ...this.timing,
237
- create_time: new Date(),
238
- pause_time: null,
239
- resume_time: null,
298
+ create_time: this.timing?.create_time ?? new Date(),
299
+ pause_time: this.timing?.pause_time ?? null,
300
+ resume_time: this.timing?.resume_time ?? null,
240
301
  }
241
302
 
242
303
  const workflows = this.getState('workflows');
@@ -254,7 +315,7 @@ export default class Workflow extends Base {
254
315
  * @returns {boolean} True if the workflow is empty.
255
316
  */
256
317
  isEmpty() {
257
- return !this._steps || !this._steps.length
318
+ return !Array.isArray(this._steps) || !this._steps.length;
258
319
  }
259
320
 
260
321
  /**
@@ -329,6 +390,16 @@ export default class Workflow extends Base {
329
390
  );
330
391
  }
331
392
 
393
+ /**
394
+ * Parses a property path string into an array of keys, supporting both dot notation and
395
+ * bracket notation (e.g. `"users[0].name"`). Pure utility - not affected by `use_state_singleton`.
396
+ * @param {string} path - The path to parse.
397
+ * @returns {string[]} Array of property keys.
398
+ */
399
+ parseStatePath(path) {
400
+ return this.use_state_singleton ? State.parsePath(path) : this.state.parseStatePath(path);
401
+ }
402
+
332
403
  /**
333
404
  * Pauses the workflow execution.
334
405
  */
@@ -350,13 +421,41 @@ export default class Workflow extends Base {
350
421
  return this._steps.pop();
351
422
  }
352
423
 
424
+ /**
425
+ * Inserts safely serializable properties of the workflow into a new object for serialization.
426
+ * @returns {Object} An object containing the workflow's properties ready for serialization.
427
+ */
428
+ prepareForSerialization() {
429
+ const serialized_workflow = {
430
+ id: this.id,
431
+ current_session_id: this.current_session_id,
432
+ current_step: this.current_step,
433
+ exit_on_error: this.exit_on_error,
434
+ name: this.name,
435
+ sessions: this.sessions,
436
+ status: this.status,
437
+ steps: this._steps.map(step => step.prepareForSerialization()),
438
+ throw_on_empty: this.throw_on_empty,
439
+ timing: this.timing,
440
+ results: this.results,
441
+ use_state_singleton: this.use_state_singleton,
442
+ };
443
+
444
+ return serialized_workflow;
445
+ }
446
+
353
447
  /**
354
448
  * Prepares a result object and adds it to the results array.
355
449
  * @param {string} message - Result message.
356
450
  * @param {*} data - Result data.
357
451
  */
358
- prepareResult(message, data) {
359
- this.results.push({ message, data });
452
+ async prepareResult(message, data) {
453
+ if (this.result_per_step && typeof this.result_per_step_function === 'function') {
454
+ await this.result_per_step_function(this.prepareForSerialization());
455
+ }
456
+
457
+ const result = { message, data };
458
+ this.results.push(result);
360
459
  }
361
460
 
362
461
  /**
@@ -375,6 +474,33 @@ export default class Workflow extends Base {
375
474
  steps.forEach(step => this.addStep(step));
376
475
  }
377
476
 
477
+ /**
478
+ * Serializes the workflow into a JSON string.
479
+ * @returns {string} The JSON string representation of the workflow.
480
+ */
481
+ serialize() {
482
+ return JSON.stringify(this.prepareForSerialization());
483
+ }
484
+
485
+ /**
486
+ * Sets a nested property value within this workflow's own state, creating intermediate
487
+ * objects/arrays as needed - the low-level counterpart to `setState()`. Falls back to the
488
+ * deprecated `State` singleton's setter when `use_state_singleton` is `true`.
489
+ * @param {string} path - Path to the state property.
490
+ * @param {*} value - The value to set at the specified path.
491
+ * @param {boolean} [emit=true] - Only meaningful when `use_state_singleton` is `true`; whether
492
+ * to emit the singleton's `SET_TO_PROPERTY_PATH` state event.
493
+ */
494
+ setStateToPropertyPath(path, value, emit = true) {
495
+ if (this.use_state_singleton) {
496
+ console.warn('The state singleton has been deprecated. Use the .prepareForSerialization() method on the workflow instance instead.');
497
+ State.setToPropertyPath(path, value, emit);
498
+ return;
499
+ }
500
+
501
+ this.state.setStateToPropertyPath(path, value);
502
+ }
503
+
378
504
  /**
379
505
  * Removes and returns the first step from the workflow.
380
506
  * @returns {Step} The first step.
@@ -399,10 +525,20 @@ export default class Workflow extends Base {
399
525
 
400
526
  this.steps_by_id[step.id] = step;
401
527
 
402
- step.parentWorkflowId = this.id;
528
+ step.parent_workflow_id = this.id;
529
+ step.use_state_singleton = this.use_state_singleton;
530
+ step.state = this.state;
403
531
  this._steps.unshift(step);
404
532
  }
405
533
 
534
+ /**
535
+ * Custom JSON serializer
536
+ * @returns {Object} The JSON representation of the workflow.
537
+ */
538
+ toJSON() {
539
+ return this.prepareForSerialization();
540
+ }
541
+
406
542
  /**
407
543
  * Gets the array of steps in the workflow.
408
544
  * @returns {Step[]} Array of steps.
@@ -416,12 +552,73 @@ export default class Workflow extends Base {
416
552
  * @param {Step[]} steps - Array of steps to add.
417
553
  */
418
554
  set steps(steps) {
419
- steps.forEach((step, index) => {
420
- if (typeof step.getCallableType !== 'function') {
421
- throw new Error(`Invalid step type. Step at index ${index} is not an instance of Step.`);
422
- }
555
+ this.addSteps(steps);
556
+ }
557
+
558
+ /**
559
+ * Deserializes a JSON string into a Workflow instance and hydrates it.
560
+ * @param {string} serialized_workflow - The JSON string representation of the workflow.
561
+ * @param {CallableRegistry|null} [callable_registry] - Registry used to resolve function callables in the workflow's steps.
562
+ * @returns {Workflow} The hydrated Workflow instance.
563
+ * @throws {Error} Throws if the serialized workflow is not a string.
564
+ */
565
+ static hydrateSerialized(serialized_workflow, callable_registry = null) {
566
+ // TODO: Validate structure of serialized workflow
567
+ if (typeof serialized_workflow !== 'string') {
568
+ throw new Error('Invalid serialized workflow. Must be a string.');
569
+ }
570
+
571
+ const parsed = JSON.parse(serialized_workflow);
572
+
573
+ return Workflow.hydrate(parsed, callable_registry);
574
+ }
575
+
576
+ /**
577
+ * Hydrates a parsed workflow object into a Workflow instance.
578
+ * @param {Object} parsed_workflow - The parsed workflow object.
579
+ * @param {CallableRegistry|null} [callable_registry] - Registry used to resolve function callables in the workflow's steps.
580
+ * @returns {Workflow} The hydrated Workflow instance.
581
+ * @throws {Error} Throws if the parsed workflow is not a valid object.
582
+ */
583
+ static hydrate(parsed_workflow, callable_registry = null) {
584
+ // TODO: Validate structure of serialized workflow
585
+ // TODO: Use event system to handle errors?
586
+ if (typeof parsed_workflow !== 'object' || parsed_workflow === null) {
587
+ throw new Error('Invalid parsed workflow. Must be a valid object.');
588
+ }
589
+
590
+ const hydrated_workflow = new Workflow({
591
+ name: parsed_workflow.name,
592
+ callable_registry,
593
+ exit_on_error: parsed_workflow.exit_on_error,
594
+ steps: parsed_workflow.steps.map(step => Step.hydrateAny(step, callable_registry)),
595
+ throw_on_empty: parsed_workflow.throw_on_empty,
596
+ use_state_singleton: parsed_workflow.use_state_singleton ?? false,
423
597
  });
424
598
 
425
- this.addSteps(steps);
599
+ // The constructor above (via Base) always generates a fresh id, and addStep() has
600
+ // already stamped that fresh id onto each step's parent_workflow_id and registered
601
+ // the workflow under it in its own state's `workflows` registry. Restoring the real id
602
+ // below would otherwise leave both of those referencing a discarded id, so fix them up here too.
603
+ const stale_id = hydrated_workflow.id;
604
+ hydrated_workflow.id = parsed_workflow.id;
605
+
606
+ const workflows = hydrated_workflow.getState('workflows');
607
+ delete workflows[stale_id];
608
+ workflows[hydrated_workflow.id] = hydrated_workflow;
609
+ hydrated_workflow.setState('workflows', workflows);
610
+
611
+ hydrated_workflow.steps.forEach(step => {
612
+ step.parent_workflow_id = hydrated_workflow.id;
613
+ });
614
+
615
+ hydrated_workflow.current_session_id = parsed_workflow.current_session_id;
616
+ hydrated_workflow.current_step = parsed_workflow.current_step ?? hydrated_workflow.current_step;
617
+ hydrated_workflow.sessions = parsed_workflow.sessions ?? {};
618
+ hydrated_workflow.status = parsed_workflow.status;
619
+ hydrated_workflow.timing = parsed_workflow.timing;
620
+ hydrated_workflow.results = parsed_workflow.results;
621
+
622
+ return hydrated_workflow;
426
623
  }
427
624
  }
@@ -6,7 +6,7 @@
6
6
  * @example
7
7
  * import delay_types from 'micro-flow';
8
8
  *
9
- * const delayStep = new DelayStep({
9
+ * const delay_step = new DelayStep({
10
10
  * name: 'wait-5-seconds',
11
11
  * delay_type: delay_types.RELATIVE,
12
12
  * delay_duration: 5000
@@ -5,7 +5,7 @@
5
5
  * @enum {string}
6
6
  * @readonly
7
7
  */
8
- const LogicStepTypes = {
8
+ const logic_step_types = {
9
9
  CONDITIONAL: 'conditional',
10
10
  LOOP: 'loop',
11
11
  FLOW_CONTROL: 'flow_control',
@@ -13,4 +13,4 @@ const LogicStepTypes = {
13
13
  SKIP: 'skip'
14
14
  };
15
15
 
16
- export default LogicStepTypes;
16
+ export default logic_step_types;
@@ -3,18 +3,18 @@
3
3
  * @type {Object.<string, string>}
4
4
  * @readonly
5
5
  * @example
6
- * console.log(sub_step_types.Step); // "step"
7
- * console.log(sub_step_types.ConditionalStep); // "conditional"
6
+ * console.log(sub_step_types.step); // "step"
7
+ * console.log(sub_step_types.conditional_step); // "conditional"
8
8
  */
9
9
  const sub_step_types = {
10
- Step: 'step',
11
- LogicStep: 'logic',
12
- ConditionalStep: 'conditional',
13
- FlowControlStep: 'flow_control',
14
- LoopStep: 'loop',
15
- SwitchStep: 'switch',
16
- Case: 'case',
17
- DelayStep: 'delay',
10
+ step: 'step',
11
+ logic_step: 'logic',
12
+ conditional_step: 'conditional',
13
+ flow_control_step: 'flow_control',
14
+ loop_step: 'loop',
15
+ switch_step: 'switch',
16
+ case: 'case',
17
+ delay_step: 'delay',
18
18
  };
19
19
 
20
20
  export default sub_step_types;