@gesslar/actioneer 0.2.2 → 0.2.4

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
@@ -335,13 +335,22 @@ Examples of minimal configs and one-liners to run them are in the project discus
335
335
 
336
336
  ## Testing
337
337
 
338
- Run the small smoke tests with Node's built-in test runner:
338
+ Run the comprehensive test suite with Node's built-in test runner:
339
339
 
340
340
  ```bash
341
341
  npm test
342
342
  ```
343
343
 
344
- The test suite is intentionally small; it verifies public exports and a few core behaviors. Add more unit tests under `tests/` if you need deeper coverage.
344
+ The test suite includes 150+ tests covering all core classes and behaviors:
345
+
346
+ - **Activity** - Activity definitions, ACTIVITY flags (WHILE, UNTIL, SPLIT), and execution
347
+ - **ActionBuilder** - Fluent builder API, activity registration, and hooks configuration
348
+ - **ActionWrapper** - Activity iteration and integration with ActionBuilder
349
+ - **ActionRunner** - Pipeline execution, loop semantics, nested builders, and error handling
350
+ - **ActionHooks** - Hook lifecycle, loading from files, and timeout handling
351
+ - **Piper** - Concurrent processing, worker pools, and lifecycle hooks
352
+
353
+ Tests are organized in `tests/unit/` with one file per class. All tests use Node's native test runner and assertion library.
345
354
 
346
355
  ## Publishing
347
356
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/actioneer",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Ready? Set?? ACTION!! pew! pew! pew!",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ import {Data, Sass, Valid} from "@gesslar/toolkit"
2
2
 
3
3
  import ActionWrapper from "./ActionWrapper.js"
4
4
  import ActionHooks from "./ActionHooks.js"
5
+ import {ACTIVITY} from "./Activity.js"
5
6
 
6
7
  /** @typedef {import("./ActionRunner.js").default} ActionRunner */
7
8
  /** @typedef {typeof import("./Activity.js").ACTIVITY} ActivityFlags */
@@ -90,12 +91,17 @@ export default class ActionBuilder {
90
91
  }
91
92
  }
92
93
 
94
+ get tag() {
95
+ return this.#tag
96
+ }
97
+
93
98
  /**
94
99
  * Register an activity that the runner can execute.
95
100
  *
96
101
  * Overloads:
97
102
  * - do(name, op)
98
103
  * - do(name, kind, pred, opOrWrapper)
104
+ * - do(name, kind, splitter, rejoiner, opOrWrapper)
99
105
  *
100
106
  * @overload
101
107
  * @param {string|symbol} name Activity name
@@ -112,6 +118,16 @@ export default class ActionBuilder {
112
118
  * @returns {ActionBuilder}
113
119
  */
114
120
 
121
+ /**
122
+ * @overload
123
+ * @param {string|symbol} name Activity name
124
+ * @param {number} kind ACTIVITY.SPLIT flag.
125
+ * @param {(context: unknown) => unknown} splitter Splitter function for SPLIT mode.
126
+ * @param {(originalContext: unknown, splitResults: unknown) => unknown} rejoiner Rejoiner function for SPLIT mode.
127
+ * @param {ActionFunction|import("./ActionWrapper.js").default} op Operation or nested wrapper to execute.
128
+ * @returns {ActionBuilder}
129
+ */
130
+
115
131
  /**
116
132
  * Handles runtime dispatch across the documented overloads.
117
133
  *
@@ -124,7 +140,8 @@ export default class ActionBuilder {
124
140
 
125
141
  // signatures
126
142
  // name, [function] => once
127
- // name, [number,function,function] => some kind of control operation
143
+ // name, [number,function,function] => some kind of control operation (WHILE/UNTIL)
144
+ // name, [number,function,function,function] => SPLIT operation with splitter/rejoiner
128
145
  // name, [number,function,ActionBuilder] => some kind of branch
129
146
 
130
147
  const action = this.#action
@@ -145,6 +162,19 @@ export default class ActionBuilder {
145
162
  Valid.type(op, "Function|ActionBuilder")
146
163
 
147
164
  Object.assign(activityDefinition, {kind, pred, op})
165
+ } else if(args.length === 4) {
166
+ const [kind, splitter, rejoiner, op] = args
167
+
168
+ Valid.type(kind, "Number")
169
+ Valid.type(splitter, "Function")
170
+ Valid.type(rejoiner, "Function")
171
+ Valid.type(op, "Function|ActionBuilder")
172
+
173
+ // Validate that kind is SPLIT
174
+ if((kind & ACTIVITY.SPLIT) !== ACTIVITY.SPLIT)
175
+ throw Sass.new("4-argument form of 'do' is only valid for ACTIVITY.SPLIT")
176
+
177
+ Object.assign(activityDefinition, {kind, splitter, rejoiner, op})
148
178
  } else {
149
179
  throw Sass.new("Invalid number of arguments passed to 'do'")
150
180
  }
@@ -178,9 +208,14 @@ export default class ActionBuilder {
178
208
  *
179
209
  * @param {import("./ActionHooks.js").default} hooks An already-instantiated hooks instance.
180
210
  * @returns {ActionBuilder} The builder instance for chaining.
181
- * @throws {Sass} If hooks have already been configured.
211
+ * @throws {Sass} If hooks have already been configured with a different instance.
182
212
  */
183
213
  withHooks(hooks) {
214
+ // If the same hooks instance is already set, this is idempotent - just return
215
+ if(this.#hooks === hooks) {
216
+ return this
217
+ }
218
+
184
219
  Valid.assert(this.#hooksFile === null, "Hooks have already been configured.")
185
220
  Valid.assert(this.#hooksKind === null, "Hooks have already been configured.")
186
221
  Valid.assert(this.#hooks === null, "Hooks have already been configured.")
@@ -232,8 +267,14 @@ export default class ActionBuilder {
232
267
  const newHooks = ActionHooks.new
233
268
 
234
269
  const hooks = this.#hooks
235
- if(hooks)
270
+ if(hooks) {
271
+ // If hooks is already an ActionHooks instance, use it directly
272
+ if(hooks instanceof ActionHooks)
273
+ return hooks
274
+
275
+ // Otherwise, wrap it in a new ActionHooks instance
236
276
  return await newHooks({hooks}, this.#debug)
277
+ }
237
278
 
238
279
  const hooksFile = this.#hooksFile
239
280
  const hooksKind = this.#hooksKind
@@ -114,7 +114,7 @@ export default class ActionHooks {
114
114
  static async new(config, debug) {
115
115
  debug("Creating new HookManager instance with args: %o", 2, config)
116
116
 
117
- const instance = new ActionHooks(config, debug)
117
+ const instance = new ActionHooks({...config, debug})
118
118
  if(!instance.#hooks) {
119
119
  const hooksFile = new FileObject(instance.#hooksFile)
120
120
 
@@ -151,7 +151,7 @@ export default class ActionHooks {
151
151
  }
152
152
  }
153
153
 
154
- return this
154
+ return instance
155
155
  }
156
156
 
157
157
  /**
@@ -170,8 +170,8 @@ export default class ActionHooks {
170
170
  if(!hooks)
171
171
  return
172
172
 
173
- const stringActivityName = Data.isType("Symbol")
174
- ? activityName.description()
173
+ const stringActivityName = Data.isType(activityName, "Symbol")
174
+ ? activityName.description
175
175
  : activityName
176
176
 
177
177
  const hookName = this.#getActivityHookName(kind, stringActivityName)
@@ -1,4 +1,4 @@
1
- import {Sass, Valid} from "@gesslar/toolkit"
1
+ import {Data, Sass, Valid} from "@gesslar/toolkit"
2
2
 
3
3
  import ActionBuilder from "./ActionBuilder.js"
4
4
  import {ACTIVITY} from "./Activity.js"
@@ -20,7 +20,10 @@ import Piper from "./Piper.js"
20
20
  * context object under `result.value` that can be replaced or enriched.
21
21
  */
22
22
  export default class ActionRunner extends Piper {
23
+ /** @type {import("./ActionBuilder.js").default|null} */
23
24
  #actionBuilder = null
25
+ /** @type {import("./ActionWrapper.js").default|null} */
26
+ #actionWrapper = null
24
27
 
25
28
  /**
26
29
  * Logger invoked for diagnostics.
@@ -48,82 +51,167 @@ export default class ActionRunner extends Piper {
48
51
 
49
52
  this.#actionBuilder = actionBuilder
50
53
 
51
- this.addStep(this.run)
54
+ this.addStep(this.run, {
55
+ name: `ActionRunner for ${actionBuilder.tag.description}`
56
+ })
52
57
  }
53
58
 
54
59
  /**
55
60
  * Executes the configured action pipeline.
61
+ * Builds the ActionWrapper on first run and caches it for subsequent calls.
62
+ * Supports WHILE, UNTIL, and SPLIT activity kinds.
56
63
  *
57
64
  * @param {unknown} context - Seed value passed to the first activity.
58
- * @returns {Promise<unknown>} Final value produced by the pipeline, or null when a parallel stage reports failures.
59
- * @throws {Sass} When no activities are registered or required parallel builders are missing.
65
+ * @returns {Promise<unknown>} Final value produced by the pipeline.
66
+ * @throws {Sass} When no activities are registered, conflicting activity kinds are used, or execution fails.
60
67
  */
61
68
  async run(context) {
62
- const actionWrapper = await this.#actionBuilder.build()
69
+ if(!this.#actionWrapper)
70
+ this.#actionWrapper = await this.#actionBuilder.build()
71
+
72
+ const actionWrapper = this.#actionWrapper
63
73
  const activities = actionWrapper.activities
64
74
 
65
75
  for(const activity of activities) {
66
- const kind = activity.kind
67
-
68
- // If we have no kind, then it's just a once.
69
- // Get it over and done with!
70
- if(!kind) {
71
- context = await this.#executeActivity(activity, context)
72
- } else {
73
- const {WHILE,UNTIL} = ACTIVITY
74
- const pred = activity.pred
75
- const kindWhile = kind & WHILE
76
- const kindUntil = kind & UNTIL
77
-
78
- if(kindWhile && kindUntil)
79
- throw Sass.new(
80
- "For Kathy Griffin's sake! You can't do something while AND " +
81
- "until. Pick one!"
82
- )
83
-
84
- if(kindWhile || kindUntil) {
85
- for(;;) {
86
-
87
- if(kindWhile)
88
- if(!await this.#predicateCheck(activity,pred,context))
89
- break
76
+ try {
77
+ // await timeout(500)
90
78
 
91
- context = await this.#executeActivity(activity,context)
79
+ const kind = activity.kind
92
80
 
93
- if(kindUntil)
94
- if(await this.#predicateCheck(activity,pred,context))
95
- break
96
- }
81
+ // If we have no kind, then it's just a once.
82
+ // Get it over and done with!
83
+ if(!kind) {
84
+ context = await this.#execute(activity, context)
97
85
  } else {
98
- context = await this.#executeActivity(activity, context)
86
+ // Validate that only one activity kind bit is set
87
+ // (kind & (kind - 1)) !== 0 means multiple bits are set
88
+ const multipleBitsSet = (kind & (kind - 1)) !== 0
89
+ if(multipleBitsSet)
90
+ throw Sass.new(
91
+ "For Kathy Griffin's sake! You can't combine activity kinds. " +
92
+ "Pick one: WHILE, UNTIL, or SPLIT!"
93
+ )
94
+
95
+ const {WHILE,UNTIL,SPLIT} = ACTIVITY
96
+ const kindWhile = kind & WHILE
97
+ const kindUntil = kind & UNTIL
98
+ const kindSplit = kind & SPLIT
99
+
100
+ if(kindWhile || kindUntil) {
101
+ const predicate = activity.pred
102
+
103
+ for(;;) {
104
+
105
+ if(kindWhile)
106
+ if(!await this.#hasPredicate(activity,predicate,context))
107
+ break
108
+
109
+ context = await this.#execute(activity,context)
110
+
111
+ if(kindUntil)
112
+ if(await this.#hasPredicate(activity,predicate,context))
113
+ break
114
+ }
115
+ } else if(kindSplit) {
116
+ // SPLIT activity: parallel execution with splitter/rejoiner pattern
117
+ const splitter = activity.splitter
118
+ const rejoiner = activity.rejoiner
119
+
120
+ if(!splitter || !rejoiner)
121
+ throw Sass.new(
122
+ `SPLIT activity "${String(activity.name)}" requires both splitter and rejoiner functions.`
123
+ )
124
+
125
+ const original = context
126
+ const splitContexts = splitter.call(activity.action,context)
127
+
128
+ let newContext
129
+ if(activity.opKind === "ActionBuilder") {
130
+ // Use parallel execution for ActionBuilder
131
+ newContext = await this.#execute(activity,splitContexts,true)
132
+ } else {
133
+ // For plain functions, process each split context
134
+ newContext = await Promise.all(
135
+ splitContexts.map(ctx => this.#execute(activity,ctx))
136
+ )
137
+ }
138
+
139
+ const rejoined = rejoiner.call(activity.action, original,newContext)
140
+
141
+ context = rejoined
142
+ } else {
143
+ context = await this.#execute(activity, context)
144
+ }
99
145
  }
146
+ } catch(error) {
147
+ throw Sass.new("ActionRunner running activity", error)
100
148
  }
101
-
102
149
  }
103
150
 
104
151
  return context
105
152
  }
106
153
 
107
154
  /**
108
- * Execute a single activity, recursing into nested action wrappers when needed.
155
+ * Execute a single activity, recursing into nested ActionBuilders when needed.
156
+ * Handles both function-based activities and ActionBuilder-based nested pipelines.
157
+ * Automatically propagates hooks to nested builders and handles dynamic ActionBuilder returns.
158
+ *
159
+ * When parallel=true, uses Piper.pipe() for concurrent execution with worker pool pattern.
160
+ * This is triggered by SPLIT activities where context is divided for parallel processing.
161
+ * Results from parallel execution are returned directly as an array from Piper.pipe().
109
162
  *
110
163
  * @param {import("./Activity.js").default} activity Pipeline activity descriptor.
111
164
  * @param {unknown} context Current pipeline context.
165
+ * @param {boolean} [parallel] Whether to use parallel execution (via pipe() instead of run()). Default: false.
112
166
  * @returns {Promise<unknown>} Resolved activity result.
167
+ * @throws {Sass} If the operation kind is invalid, or if SPLIT activity lacks splitter/rejoiner.
113
168
  * @private
114
169
  */
115
- async #executeActivity(activity, context) {
170
+ async #execute(activity, context, parallel=false) {
116
171
  // What kind of op are we looking at? Is it a function?
117
172
  // Or a class instance of type ActionBuilder?
118
173
  const opKind = activity.opKind
174
+
119
175
  if(opKind === "ActionBuilder") {
120
- const runner = new this.constructor(activity.op, {debug: this.#debug})
176
+ if(activity.hooks)
177
+ activity.op.withHooks(activity.hooks)
121
178
 
122
- return await runner.run(context, true)
179
+ const runner = new this.constructor(activity.op, {
180
+ debug: this.#debug, name: activity.name
181
+ })
182
+
183
+ if(parallel) {
184
+ return await runner.pipe(context)
185
+ } else {
186
+ return await runner.run(context)
187
+ }
123
188
  } else if(opKind === "Function") {
124
- return await activity.run(context)
189
+ try {
190
+ const result = await activity.run(context)
191
+
192
+ if(Data.isType(result, "ActionBuilder")) {
193
+ if(activity.hooks)
194
+ result.withHooks(activity.hooks)
195
+
196
+ const runner = new this.constructor(result, {
197
+ debug: this.#debug, name: result.name
198
+ })
199
+
200
+ if(parallel) {
201
+ return await runner.pipe(context)
202
+ } else {
203
+ return await runner.run(context)
204
+ }
205
+ } else {
206
+ return result
207
+ }
208
+ } catch(error) {
209
+ throw Sass.new("Executing activity", error)
210
+ }
125
211
  }
126
212
 
213
+ console.log(activity.opKind + " " + JSON.stringify(activity))
214
+
127
215
  throw Sass.new("We buy Functions and ActionBuilders. Only. Not whatever that was.")
128
216
  }
129
217
 
@@ -136,7 +224,7 @@ export default class ActionRunner extends Piper {
136
224
  * @returns {Promise<boolean>} True when the predicate allows another iteration.
137
225
  * @private
138
226
  */
139
- async #predicateCheck(activity,predicate,context) {
227
+ async #hasPredicate(activity,predicate,context) {
140
228
  Valid.type(predicate, "Function")
141
229
 
142
230
  return !!(await predicate.call(activity.action, context))
@@ -8,32 +8,58 @@ import {Data} from "@gesslar/toolkit"
8
8
  *
9
9
  * @readonly
10
10
  * @enum {number}
11
+ * @property {number} WHILE - Execute activity while predicate returns true (2)
12
+ * @property {number} UNTIL - Execute activity until predicate returns true (4)
13
+ * @property {number} SPLIT - Execute activity with split/rejoin pattern for parallel execution (8)
11
14
  */
12
15
  export const ACTIVITY = Object.freeze({
13
16
  WHILE: 1<<1,
14
17
  UNTIL: 1<<2,
18
+ SPLIT: 1<<3,
15
19
  })
16
20
 
17
21
  export default class Activity {
22
+ /** @type {unknown} */
18
23
  #action = null
24
+ /** @type {unknown} */
25
+ #context = null
26
+ /** @type {ActionHooks|null} */
27
+ #hooks = null
28
+ /** @type {number|null} */
29
+ #kind = null
30
+ /** @type {string|symbol} */
19
31
  #name = null
32
+ /** @type {((context: unknown) => unknown|Promise<unknown>)|import("./ActionBuilder.js").default} */
20
33
  #op = null
21
- #kind = null
34
+ /** @type {((context: unknown) => boolean|Promise<boolean>)|null} */
22
35
  #pred = null
23
- #hooks = null
36
+ /** @type {((originalContext: unknown, splitResults: unknown) => unknown)|null} */
37
+ #rejoiner = null
38
+ /** @type {((context: unknown) => unknown)|null} */
39
+ #splitter = null
24
40
 
25
41
  /**
26
42
  * Construct an Activity definition wrapper.
27
43
  *
28
- * @param {{action: unknown, name: string, op: (context: unknown) => unknown|Promise<unknown>|unknown, kind?: number, pred?: (context: unknown) => boolean|Promise<boolean>, hooks?: ActionHooks}} init - Initial properties describing the activity operation, loop semantics, and predicate
44
+ * @param {object} init - Initial properties describing the activity operation, loop semantics, and predicate
45
+ * @param {unknown} init.action - Parent action instance
46
+ * @param {string|symbol} init.name - Activity identifier
47
+ * @param {(context: unknown) => unknown|Promise<unknown>|import("./ActionBuilder.js").default} init.op - Operation to execute
48
+ * @param {number} [init.kind] - Optional loop semantics flags
49
+ * @param {(context: unknown) => boolean|Promise<boolean>} [init.pred] - Optional predicate for WHILE/UNTIL
50
+ * @param {ActionHooks} [init.hooks] - Optional hooks instance
51
+ * @param {(context: unknown) => unknown} [init.splitter] - Optional splitter function for SPLIT activities
52
+ * @param {(originalContext: unknown, splitResults: unknown) => unknown} [init.rejoiner] - Optional rejoiner function for SPLIT activities
29
53
  */
30
- constructor({action,name,op,kind,pred,hooks}) {
54
+ constructor({action,name,op,kind,pred,hooks,splitter,rejoiner}) {
55
+ this.#action = action
56
+ this.#hooks = hooks
57
+ this.#kind = kind
31
58
  this.#name = name
32
59
  this.#op = op
33
- this.#kind = kind
34
- this.#action = action
35
60
  this.#pred = pred
36
- this.#hooks = hooks
61
+ this.#rejoiner = rejoiner
62
+ this.#splitter = splitter
37
63
  }
38
64
 
39
65
  /**
@@ -64,7 +90,16 @@ export default class Activity {
64
90
  }
65
91
 
66
92
  /**
67
- * The operator kind name (Function or ActionWrapper).
93
+ * The current context (if set).
94
+ *
95
+ * @returns {unknown} Current context value
96
+ */
97
+ get context() {
98
+ return this.#context
99
+ }
100
+
101
+ /**
102
+ * The operator kind name (Function or ActionBuilder).
68
103
  *
69
104
  * @returns {string} - Kind name extracted via Data.typeOf
70
105
  */
@@ -73,14 +108,32 @@ export default class Activity {
73
108
  }
74
109
 
75
110
  /**
76
- * The operator to execute (function or nested wrapper).
111
+ * The operator to execute (function or nested ActionBuilder).
77
112
  *
78
- * @returns {unknown} - Activity operation
113
+ * @returns {(context: unknown) => unknown|Promise<unknown>|import("./ActionBuilder.js").default} - Activity operation
79
114
  */
80
115
  get op() {
81
116
  return this.#op
82
117
  }
83
118
 
119
+ /**
120
+ * The splitter function for SPLIT activities.
121
+ *
122
+ * @returns {((context: unknown) => unknown)|null} Splitter function or null
123
+ */
124
+ get splitter() {
125
+ return this.#splitter
126
+ }
127
+
128
+ /**
129
+ * The rejoiner function for SPLIT activities.
130
+ *
131
+ * @returns {((originalContext: unknown, splitResults: unknown) => unknown)|null} Rejoiner function or null
132
+ */
133
+ get rejoiner() {
134
+ return this.#rejoiner
135
+ }
136
+
84
137
  /**
85
138
  * The action instance this activity belongs to.
86
139
  *
@@ -94,7 +147,7 @@ export default class Activity {
94
147
  * Execute the activity with before/after hooks.
95
148
  *
96
149
  * @param {unknown} context - Mutable context flowing through the pipeline
97
- * @returns {Promise<{activityResult: unknown}>} - Activity result wrapper with new context
150
+ * @returns {Promise<unknown>} - Activity result
98
151
  */
99
152
  async run(context) {
100
153
  // before hook
@@ -112,7 +165,7 @@ export default class Activity {
112
165
  /**
113
166
  * Attach hooks to this activity instance.
114
167
  *
115
- * @param {unknown} hooks - Hooks instance with optional before$/after$ methods
168
+ * @param {ActionHooks} hooks - Hooks instance with optional before$/after$ methods
116
169
  * @returns {this} - This activity for chaining
117
170
  */
118
171
  setActionHooks(hooks) {
@@ -121,4 +174,13 @@ export default class Activity {
121
174
 
122
175
  return this
123
176
  }
177
+
178
+ /**
179
+ * Get the hooks instance attached to this activity.
180
+ *
181
+ * @returns {ActionHooks|null} The hooks instance or null
182
+ */
183
+ get hooks() {
184
+ return this.#hooks
185
+ }
124
186
  }
@@ -89,7 +89,7 @@ export default class ActionBuilder {
89
89
  *
90
90
  * @param {import("./ActionHooks.js").default} hooks An already-instantiated hooks instance.
91
91
  * @returns {ActionBuilder} The builder instance for chaining.
92
- * @throws {Sass} If hooks have already been configured.
92
+ * @throws {Sass} If hooks have already been configured with a different instance.
93
93
  */
94
94
  withHooks(hooks: import('./ActionHooks.js').default): ActionBuilder
95
95
  /**