@ronaldroe/micro-flow 1.3.9 → 3.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 (54) 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/instance_state.js +2 -0
  11. package/dist/src/classes/instance_state.js.map +7 -0
  12. package/dist/src/classes/state.js +1 -1
  13. package/dist/src/classes/state.js.map +3 -3
  14. package/dist/src/classes/steps/case.js +1 -1
  15. package/dist/src/classes/steps/case.js.map +3 -3
  16. package/dist/src/classes/steps/conditional_step.js +1 -1
  17. package/dist/src/classes/steps/conditional_step.js.map +3 -3
  18. package/dist/src/classes/steps/delay_step.js +1 -1
  19. package/dist/src/classes/steps/delay_step.js.map +3 -3
  20. package/dist/src/classes/steps/flow_control_step.js +1 -1
  21. package/dist/src/classes/steps/flow_control_step.js.map +3 -3
  22. package/dist/src/classes/steps/logic_step.js +1 -1
  23. package/dist/src/classes/steps/logic_step.js.map +3 -3
  24. package/dist/src/classes/steps/loop_step.js +1 -1
  25. package/dist/src/classes/steps/loop_step.js.map +3 -3
  26. package/dist/src/classes/steps/step.js +1 -1
  27. package/dist/src/classes/steps/step.js.map +3 -3
  28. package/dist/src/classes/steps/switch_step.js +1 -1
  29. package/dist/src/classes/steps/switch_step.js.map +3 -3
  30. package/dist/src/classes/workflow.js +1 -1
  31. package/dist/src/classes/workflow.js.map +3 -3
  32. package/dist/src/enums/delay_types.js.map +1 -1
  33. package/dist/src/enums/logic_step_types.js.map +3 -3
  34. package/dist/src/enums/sub_step_types.js +1 -1
  35. package/dist/src/enums/sub_step_types.js.map +2 -2
  36. package/package.json +1 -1
  37. package/src/classes/base.js +42 -10
  38. package/src/classes/callable_registry.js +82 -0
  39. package/src/classes/events/event.js +3 -3
  40. package/src/classes/index.js +2 -0
  41. package/src/classes/instance_state.js +277 -0
  42. package/src/classes/state.js +20 -49
  43. package/src/classes/steps/case.js +34 -4
  44. package/src/classes/steps/conditional_step.js +72 -5
  45. package/src/classes/steps/delay_step.js +23 -8
  46. package/src/classes/steps/flow_control_step.js +21 -4
  47. package/src/classes/steps/logic_step.js +63 -45
  48. package/src/classes/steps/loop_step.js +88 -3
  49. package/src/classes/steps/step.js +238 -20
  50. package/src/classes/steps/switch_step.js +68 -9
  51. package/src/classes/workflow.js +280 -62
  52. package/src/enums/delay_types.js +1 -1
  53. package/src/enums/logic_step_types.js +2 -2
  54. package/src/enums/sub_step_types.js +10 -10
@@ -2,6 +2,11 @@ import Base from '../base.js';
2
2
  import Workflow from '../workflow.js';
3
3
  import { base_types, step_types } from '../../enums/index.js';
4
4
 
5
+ // Populated by each Step subclass file registering itself (see the bottom of
6
+ // step.js and each subclass file) - keeps this file from importing every
7
+ // subclass directly, which would create an import cycle through the hierarchy.
8
+ const step_class_registry = {};
9
+
5
10
  /**
6
11
  * Step class representing an executable unit within a workflow.
7
12
  * @class Step
@@ -10,6 +15,11 @@ import { base_types, step_types } from '../../enums/index.js';
10
15
  export default class Step extends Base {
11
16
  static step_name = 'step';
12
17
  #callable_object = null;
18
+ static callable_types = {
19
+ FUNCTION: 'function',
20
+ STEP: 'step',
21
+ WORKFLOW: 'workflow',
22
+ }
13
23
 
14
24
  /**
15
25
  * Creates a new Step instance.
@@ -24,6 +34,7 @@ export default class Step extends Base {
24
34
  constructor({
25
35
  name,
26
36
  callable = async () => {},
37
+ callable_registry_key = null,
27
38
  max_retries = 0,
28
39
  max_timeout_ms = 30000,
29
40
  step_type = step_types.ACTION,
@@ -33,6 +44,9 @@ export default class Step extends Base {
33
44
 
34
45
  this.callable = callable;
35
46
 
47
+ // Optional key to reference the callable to be rehydrated after serialization.
48
+ this.callable_registry_key = callable_registry_key;
49
+
36
50
  // Store off the original callable object, because if it's a Step or Workflow,
37
51
  // this.callable is set to the execute method of that object, but we may need to access its properties later.
38
52
  this.#callable_object = callable;
@@ -55,9 +69,13 @@ export default class Step extends Base {
55
69
  * @returns {Promise<Step>} The step instance with execution results.
56
70
  */
57
71
  async execute() {
58
- if (!this.timeout ) {
72
+ if (!this.timeout) {
59
73
  this.timeout = new Promise((_, reject) =>
60
- setTimeout(reject, this.max_timeout_ms, new Error(`Step "${this.name}" timed out after ${this.max_timeout_ms}ms`))
74
+ setTimeout(
75
+ reject,
76
+ this.max_timeout_ms,
77
+ new Error(`Step "${this.name}" timed out after ${this.max_timeout_ms}ms`)
78
+ )
61
79
  );
62
80
  }
63
81
 
@@ -81,12 +99,11 @@ export default class Step extends Base {
81
99
  throw error;
82
100
  }
83
101
  }
84
-
85
102
  }
86
103
 
87
- const { FAILED, COMPLETE } = this.getState('statuses')[this.base_type];
104
+ const { FAILED, COMPLETE } = Workflow.statuses[this.base_type];
88
105
 
89
- if (! [FAILED, COMPLETE].includes(this.status)) {
106
+ if (![FAILED, COMPLETE].includes(this.status)) {
90
107
  this.markAsComplete();
91
108
  }
92
109
 
@@ -94,7 +111,7 @@ export default class Step extends Base {
94
111
  return this.#callable_object;
95
112
  }
96
113
 
97
- return this;
114
+ return this.prepareForSerialization();
98
115
  }
99
116
 
100
117
  /**
@@ -104,32 +121,185 @@ export default class Step extends Base {
104
121
  * @throws {Error} Throws if callable type is invalid.
105
122
  */
106
123
  getCallableType(callable) {
124
+ return Step.getCallableType(callable);
125
+ }
126
+
127
+ /**
128
+ * Determines the type of the callable (function, step, or workflow).
129
+ * @param {Function|Step|Workflow} callable - The callable to check.
130
+ * @returns {string} The type: 'function', 'step', or 'workflow'.
131
+ * @throws {Error} Throws if callable type is invalid.
132
+ */
133
+ static getCallableType(callable) {
107
134
  if (callable && callable.base_type === base_types.WORKFLOW) {
108
- return 'workflow';
135
+ return Step.callable_types.WORKFLOW;
109
136
  } else if (callable && callable.base_type === base_types.STEP) {
110
- return 'step';
137
+ return Step.callable_types.STEP;
111
138
  } else if (typeof callable === 'function') {
112
- return 'function';
113
- }
139
+ return Step.callable_types.FUNCTION;
140
+ }
114
141
 
115
142
  throw new Error('Invalid callable type. Must be one of function, Step, or Workflow.');
116
143
  }
117
144
 
118
145
  /**
119
- * Sets a value in the parent workflow's state.
120
- * @param {string} workflowId - ID of the parent workflow.
121
- * @param {string} path - Path in the workflow state to set.
146
+ * Registers a Step subclass so hydration can rebuild instances of the correct type.
147
+ * Called as a side effect at the bottom of each step subclass file.
148
+ * @param {typeof Step} StepClass - The Step subclass to register, keyed by its static `step_name`.
149
+ */
150
+ static registerStepClass(StepClass) {
151
+ step_class_registry[StepClass.step_name] = StepClass;
152
+ }
153
+
154
+ /**
155
+ * Resolves a `step_name` (as stored in `class_name` on a serialized step) to its class.
156
+ * Falls back to the base `Step` class if the name is unknown.
157
+ * @param {string} step_name - The step_name to resolve.
158
+ * @returns {typeof Step} The resolved Step subclass.
159
+ */
160
+ static resolveStepClass(step_name) {
161
+ return step_class_registry[step_name] ?? Step;
162
+ }
163
+
164
+ /**
165
+ * Serializes a callable-like value (function, Step, or Workflow) into a plain, JSON-safe descriptor.
166
+ * @param {Function|Step|Workflow|null} callable - The callable to serialize.
167
+ * @returns {Object|null} A `{ type, value }` descriptor, or null if no callable was given.
168
+ */
169
+ static serializeCallableField(callable) {
170
+ if (callable === null || callable === undefined) {
171
+ return null;
172
+ }
173
+
174
+ const type = Step.getCallableType(callable);
175
+
176
+ if (type === Step.callable_types.FUNCTION) {
177
+ return { type, value: callable.name };
178
+ }
179
+
180
+ return { type, value: callable.prepareForSerialization() };
181
+ }
182
+
183
+ /**
184
+ * Hydrates a callable-like descriptor (as produced by `serializeCallableField`) back into a
185
+ * live function, Step, or Workflow. Idempotent - passing an already-hydrated value through
186
+ * returns it unchanged, since subclass `hydrate` overrides may resolve a field before
187
+ * delegating to a superclass `hydrate` that would otherwise try to resolve it again.
188
+ * @param {Object|Function|Step|Workflow|null} serialized - The descriptor (or already-hydrated value) to hydrate.
189
+ * @param {import('../callable_registry.js').default|null} [callable_registry] - Registry used to resolve function callables.
190
+ * @returns {Function|Step|Workflow|undefined} The hydrated callable, or undefined if nothing was given.
191
+ * @throws {Error} Throws if a function callable can't be found in the registry, or the descriptor type is unknown.
192
+ */
193
+ static hydrateCallableField(serialized, callable_registry = null) {
194
+ if (serialized === null || serialized === undefined) {
195
+ return undefined;
196
+ }
197
+
198
+ if (typeof serialized === 'function' || serialized instanceof Step || serialized?.base_type) {
199
+ return serialized;
200
+ }
201
+
202
+ const { type, value } = serialized;
203
+
204
+ if (type === Step.callable_types.FUNCTION) {
205
+ if (!callable_registry || !callable_registry.has(value)) {
206
+ throw new Error(`Callable registry key "${value}" not found in registry or registry not provided.`);
207
+ }
208
+
209
+ return callable_registry.get(value);
210
+ }
211
+
212
+ if (type === Step.callable_types.STEP) {
213
+ return Step.hydrateAny(value, callable_registry);
214
+ }
215
+
216
+ if (type === Step.callable_types.WORKFLOW) {
217
+ return Workflow.hydrate(value, callable_registry);
218
+ }
219
+
220
+ throw new Error(`Unknown callable type "${type}" encountered during hydration.`);
221
+ }
222
+
223
+ /**
224
+ * Hydrates a parsed step object into an instance of its correct Step subclass,
225
+ * resolved from its serialized `class_name`.
226
+ * @param {Object} parsed_step - The parsed step object.
227
+ * @param {import('../callable_registry.js').default|null} [callable_registry] - Registry used to resolve function callables.
228
+ * @returns {Step} The hydrated Step (or subclass) instance.
229
+ */
230
+ static hydrateAny(parsed_step, callable_registry = null) {
231
+ const StepClass = Step.resolveStepClass(parsed_step.class_name);
232
+
233
+ return StepClass.hydrate(parsed_step, callable_registry);
234
+ }
235
+
236
+ /**
237
+ * Inserts safely serializable properties of the step into a new object for serialization.
238
+ * @returns {Object} An object containing the step's properties ready for serialization.
239
+ */
240
+ prepareForSerialization() {
241
+ const serialized_step = {
242
+ id: this.id,
243
+ class_name: this.constructor.step_name,
244
+ name: this.name,
245
+ callable_type: this.callable_type,
246
+ step_type: this.step_type,
247
+ sub_step_type: this.sub_step_type,
248
+ max_retries: this.max_retries,
249
+ max_timeout_ms: this.max_timeout_ms,
250
+ retry_count: this.retry_count,
251
+ retry_results: this.retry_results,
252
+ errors: this.errors,
253
+ result: this.result,
254
+ timing: this.timing,
255
+ status: this.status,
256
+ parent_workflow_id: this.parent_workflow_id,
257
+ };
258
+
259
+ serialized_step.callable = this.callable_registry_key
260
+ ? { type: Step.callable_types.FUNCTION, value: this.callable_registry_key }
261
+ : Step.serializeCallableField(this.#callable_object);
262
+
263
+ return serialized_step;
264
+ }
265
+
266
+ /**
267
+ * Sets a value on the parent workflow instance itself. Normally this is the live object
268
+ * shared via this step's own state under the `workflow` key (see `initializeWorkflowState()`
269
+ * in `workflow.js`); when `use_state_singleton` is `true`, several unrelated workflows can
270
+ * share the same process-wide state, so it's looked up by id in the singleton's `workflows`
271
+ * registry instead.
272
+ * @param {string} workflow_id - ID of the parent workflow.
273
+ * @param {string} path - Property name to set on the workflow instance.
122
274
  * @param {*} value - Value to set at the specified path.
123
- * @throws {Error} Throws if parent workflow is not found.
275
+ * @throws {Error} Throws if the parent workflow is not found.
124
276
  */
125
- setParentWorkflowValue(workflowId, path, value) {
126
- const parentWorkflow = this.getState('workflows')[workflowId];
277
+ setParentWorkflowValue(workflow_id, path, value) {
278
+ const parent_workflow = this.use_state_singleton
279
+ ? this.getState('workflows')[workflow_id]
280
+ : this.getState('workflow');
127
281
 
128
- if (!parentWorkflow) {
129
- throw new Error(`Parent workflow with ID ${workflowId} not found.`);
282
+ if (!parent_workflow || parent_workflow.id !== workflow_id) {
283
+ throw new Error(`Parent workflow with ID ${workflow_id} not found.`);
130
284
  }
131
285
 
132
- parentWorkflow[path] = value;
286
+ parent_workflow[path] = value;
287
+ }
288
+
289
+ /**
290
+ * Serializes the step into a JSON string.
291
+ * @returns {string} The JSON string representation of the step.
292
+ */
293
+ serialize() {
294
+ return JSON.stringify(this.prepareForSerialization());
295
+ }
296
+
297
+ /**
298
+ * Custom JSON serializer
299
+ * @returns {Object} The JSON representation of the step.
300
+ */
301
+ toJSON() {
302
+ return this.prepareForSerialization();
133
303
  }
134
304
 
135
305
  /**
@@ -141,7 +311,7 @@ export default class Step extends Base {
141
311
 
142
312
  if (['step', 'workflow'].includes(this.callable_type)) {
143
313
  if (this.callable_type === 'step') {
144
- callable.parentWorkflowId = this.parentWorkflowId ?? null;
314
+ callable.parent_workflow_id = this.parent_workflow_id ?? null;
145
315
  }
146
316
 
147
317
  this._callable = callable.execute.bind(callable);
@@ -149,4 +319,52 @@ export default class Step extends Base {
149
319
  this._callable = callable.bind(this);
150
320
  }
151
321
  }
322
+
323
+ /**
324
+ * Deserializes a JSON string into a Step instance and hydrates it, dispatching to the correct subclass.
325
+ * @param {string} serialized_step - The JSON string representation of the step.
326
+ * @param {import('../callable_registry.js').default|null} [callable_registry] - Registry used to resolve function callables.
327
+ * @returns {Step} The hydrated Step (or subclass) instance.
328
+ * @throws {Error} Throws if the serialized step is not a string.
329
+ */
330
+ static hydrateSerialized(serialized_step, callable_registry = null) {
331
+ if (typeof serialized_step !== 'string') {
332
+ throw new Error('Invalid serialized step. Must be a string.');
333
+ }
334
+
335
+ return Step.hydrateAny(JSON.parse(serialized_step), callable_registry);
336
+ }
337
+
338
+ /**
339
+ * Hydrates a parsed step object into an instance of `this` class, resolving its callable
340
+ * (and restoring execution metadata) from the serialized data.
341
+ * Subclasses with extra callable-like fields (e.g. ConditionalStep's true_callable/false_callable)
342
+ * should resolve those fields with `Step.hydrateCallableField` and delegate to `super.hydrate()`.
343
+ * @param {Object} parsed_step - The parsed step object.
344
+ * @param {import('../callable_registry.js').default|null} [callable_registry] - Registry used to resolve function callables.
345
+ * @returns {Step} The hydrated Step instance.
346
+ * @throws {Error} Throws if a callable registry key is specified but not found in the registry.
347
+ */
348
+ static hydrate(parsed_step, callable_registry = null) {
349
+ const callable_descriptor = parsed_step.callable;
350
+ const callable = Step.hydrateCallableField(callable_descriptor, callable_registry);
351
+ const callable_registry_key = callable_descriptor?.type === Step.callable_types.FUNCTION
352
+ ? callable_descriptor.value
353
+ : null;
354
+
355
+ const instance = new this({ ...parsed_step, callable, callable_registry_key });
356
+
357
+ instance.id = parsed_step.id;
358
+ instance.retry_count = parsed_step.retry_count ?? 0;
359
+ instance.retry_results = parsed_step.retry_results ?? [];
360
+ instance.errors = parsed_step.errors ?? [];
361
+ instance.result = parsed_step.result ?? null;
362
+ instance.timing = parsed_step.timing;
363
+ instance.status = parsed_step.status;
364
+ instance.parent_workflow_id = parsed_step.parent_workflow_id ?? null;
365
+
366
+ return instance;
367
+ }
152
368
  }
369
+
370
+ Step.registerStepClass(Step);
@@ -1,4 +1,5 @@
1
1
  import Step from './step.js';
2
+ import { event_names } from '../instance_state.js';
2
3
 
3
4
  /**
4
5
  * SwitchStep class for implementing switch/case logic in workflows.
@@ -16,12 +17,14 @@ export default class SwitchStep extends Step {
16
17
  * @param {Array<Case|LogicStep>} [options.cases=[]] - Array of Case or LogicStep instances to evaluate. LogicStep instances MUST have conditional.subject set.
17
18
  * @param {Function|Step|Workflow} [options.default_callable=async () => {}] - Function, Step, or Workflow to execute if no cases match.
18
19
  * @param {*|Function} [options.subject=null] - Subject value to evaluate against each case. Can be a function that returns the value.
20
+ * @param {string|null} [options.default_callable_registry_key=null] - Optional key to reference default_callable to be rehydrated after serialization.
19
21
  */
20
22
  constructor({
21
23
  name,
22
24
  cases = [],
23
25
  default_callable = async () => {},
24
- subject = null
26
+ subject = null,
27
+ default_callable_registry_key = null,
25
28
  }) {
26
29
  super({
27
30
  name,
@@ -29,6 +32,7 @@ export default class SwitchStep extends Step {
29
32
  });
30
33
 
31
34
  this.cases = cases;
35
+ this.default_callable_registry_key = default_callable_registry_key;
32
36
  this._default_callable_type = this.getCallableType(default_callable);
33
37
  this._default_callable_raw = default_callable;
34
38
  this.default_callable = this._default_callable_type === 'function'
@@ -42,36 +46,91 @@ export default class SwitchStep extends Step {
42
46
  /**
43
47
  * Executes the switch logic by evaluating each case in order.
44
48
  * Returns the result of the first matching case, or the default callable if no match.
49
+ * Every `Case` (and, if it's a `Step`/`Workflow`, `default_callable`) is stamped with this
50
+ * step's own `parent_workflow_id`/`use_state_singleton`/`state` before it runs, since `cases`
51
+ * lives on this step rather than the parent workflow's `_steps`, so it never goes through
52
+ * `Workflow.addStep()` to pick those up on its own.
45
53
  * @returns {Promise<*>} The result of the matched case or default callable.
46
54
  */
47
55
  async switch() {
48
56
  // Resolve subject once - call it if it's a function
49
- const resolvedSubject = typeof this.subject === 'function' ? this.subject() : this.subject;
57
+ const resolved_subject = typeof this.subject === 'function' ? this.subject() : this.subject;
50
58
 
51
59
  for (const switch_case of this.cases) {
52
- switch_case.switch_subject = resolvedSubject;
60
+ switch_case.switch_subject = resolved_subject;
61
+ // Cases live in `this.cases`, not the workflow's `_steps` array, so they never go through
62
+ // Workflow.addStep() - stamp them here instead, mirroring what addStep() does.
63
+ switch_case.parent_workflow_id = this.parent_workflow_id;
64
+ switch_case.use_state_singleton = this.use_state_singleton;
65
+ switch_case.state = this.state;
53
66
 
54
67
  const is_matched = await switch_case.checkCondition();
55
68
 
56
69
  if (is_matched) {
57
70
  this.log(
58
- this.getState('events.step.event_names.SWITCH_CASE_MATCHED'),
71
+ event_names.step.SWITCH_CASE_MATCHED,
59
72
  `Case matched for step: ${this.name}, executing case callable`
60
73
  );
61
74
 
62
75
  // Return the case's result value directly, not the Case object.
63
76
  // This keeps result structure consistent: switchStep.result contains the
64
77
  // callable's return value, matching how Step.result works.
65
- const caseResult = await switch_case.execute();
66
- return caseResult.result;
78
+ const case_result = await switch_case.execute();
79
+ return case_result.result;
67
80
  }
68
81
  }
69
82
 
70
83
  // Unwrap Step/Workflow results for consistency with case results
71
- const defaultResult = await this.default_callable();
72
84
  if (this._default_callable_type !== 'function') {
73
- return defaultResult.result;
85
+ this._default_callable_raw.parent_workflow_id = this.parent_workflow_id;
86
+ this._default_callable_raw.use_state_singleton = this.use_state_singleton;
87
+ this._default_callable_raw.state = this.state;
74
88
  }
75
- return defaultResult;
89
+
90
+ const default_result = await this.default_callable();
91
+ if (this._default_callable_type !== 'function') {
92
+ return default_result.result;
93
+ }
94
+ return default_result;
95
+ }
96
+
97
+ /**
98
+ * Inserts safely serializable properties of the step into a new object for serialization.
99
+ * Note: a function-valued `subject` is not persisted, since there's no registry for it.
100
+ * @returns {Object} An object containing the step's properties ready for serialization.
101
+ */
102
+ prepareForSerialization() {
103
+ return {
104
+ ...super.prepareForSerialization(),
105
+ // The base `callable` is an internal wiring detail (the bound `switch` method) -
106
+ // SwitchStep's constructor doesn't take a callable, so it isn't real data to persist.
107
+ callable: null,
108
+ cases: this.cases.map(switch_case => switch_case.prepareForSerialization()),
109
+ default_callable: this.default_callable_registry_key
110
+ ? { type: Step.callable_types.FUNCTION, value: this.default_callable_registry_key }
111
+ : Step.serializeCallableField(this._default_callable_raw),
112
+ subject: typeof this.subject === 'function' ? null : this.subject,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Hydrates a parsed step object into a SwitchStep instance, resolving its cases and default callable.
118
+ * @param {Object} parsed_step - The parsed step object.
119
+ * @param {import('../callable_registry.js').default|null} [callable_registry] - Registry used to resolve function callables.
120
+ * @returns {SwitchStep} The hydrated SwitchStep instance.
121
+ */
122
+ static hydrate(parsed_step, callable_registry = null) {
123
+ const default_descriptor = parsed_step.default_callable;
124
+
125
+ return super.hydrate({
126
+ ...parsed_step,
127
+ cases: (parsed_step.cases ?? []).map(switch_case => Step.hydrateAny(switch_case, callable_registry)),
128
+ default_callable: Step.hydrateCallableField(default_descriptor, callable_registry),
129
+ default_callable_registry_key: default_descriptor?.type === Step.callable_types.FUNCTION
130
+ ? default_descriptor.value
131
+ : null,
132
+ }, callable_registry);
76
133
  }
77
134
  }
135
+
136
+ SwitchStep.registerStepClass(SwitchStep);