@gesslar/actioneer 0.2.3 → 0.2.6

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
@@ -30,8 +30,16 @@ class MyAction {
30
30
 
31
31
  const builder = new ActionBuilder(new MyAction())
32
32
  const runner = new ActionRunner(builder)
33
- const result = await runner.pipe([{}], 4) // run up to 4 contexts concurrently
34
- console.log(result)
33
+ const results = await runner.pipe([{}], 4) // run up to 4 contexts concurrently
34
+
35
+ // pipe() returns settled results: {status: "fulfilled", value: ...} or {status: "rejected", reason: ...}
36
+ results.forEach(result => {
37
+ if (result.status === "fulfilled") {
38
+ console.log("Count:", result.value)
39
+ } else {
40
+ console.error("Error:", result.reason)
41
+ }
42
+ })
35
43
  ```
36
44
 
37
45
  ## Types (TypeScript / VS Code)
@@ -154,9 +162,40 @@ class ParallelProcessor {
154
162
 
155
163
  1. The **splitter** function receives the context and returns an array of contexts (one per parallel task)
156
164
  2. Each split context is processed in parallel through the **operation** function
157
- 3. The **rejoiner** function receives the original context and the array of processed results
165
+ 3. The **rejoiner** function receives the original context and the array of settled results from `Promise.allSettled()`
158
166
  4. The rejoiner combines the results and returns the updated context
159
167
 
168
+ **Important: SPLIT uses `Promise.allSettled()`**
169
+
170
+ The SPLIT mode uses `Promise.allSettled()` internally to execute parallel operations. This means your **rejoiner** function will receive an array of settlement objects, not the raw context values. Each element in the array will be either:
171
+
172
+ - `{ status: "fulfilled", value: <result> }` for successful operations
173
+ - `{ status: "rejected", reason: <error> }` for failed operations
174
+
175
+ Your rejoiner must handle settled results accordingly. You can process them however you need - check each `status` manually, or use helper utilities like those in `@gesslar/toolkit`:
176
+
177
+ ```js
178
+ import { Util } from "@gesslar/toolkit"
179
+
180
+ #rejoin = (originalCtx, settledResults) => {
181
+ // settledResults is an array of settlement objects
182
+ // Each has either { status: "fulfilled", value: ... }
183
+ // or { status: "rejected", reason: ... }
184
+
185
+ // Example: extract only successful results
186
+ originalCtx.results = Util.fulfilledValues(settledResults)
187
+
188
+ // Example: check for any failures
189
+ if (Util.anyRejected(settledResults)) {
190
+ originalCtx.errors = Util.rejectedReasons(
191
+ Util.settledAndRejected(settledResults)
192
+ )
193
+ }
194
+
195
+ return originalCtx
196
+ }
197
+ ```
198
+
160
199
  **Nested Pipelines with SPLIT:**
161
200
 
162
201
  You can use nested ActionBuilders with SPLIT mode for complex parallel workflows:
@@ -196,6 +235,68 @@ class NestedParallel {
196
235
  | **UNTIL** | `.do(name, ACTIVITY.UNTIL, predicate, operation)` | After iteration | Loop until condition is true |
197
236
  | **SPLIT** | `.do(name, ACTIVITY.SPLIT, splitter, rejoiner, operation)` | N/A | Parallel execution with split/rejoin |
198
237
 
238
+ ## Running Actions: `run()` vs `pipe()`
239
+
240
+ ActionRunner provides two methods for executing your action pipelines:
241
+
242
+ ### `run(context)` - Single Context Execution
243
+
244
+ Executes the pipeline once with a single context. Returns the final context value directly, or throws if an error occurs.
245
+
246
+ ```js
247
+ const builder = new ActionBuilder(new MyAction())
248
+ const runner = new ActionRunner(builder)
249
+
250
+ try {
251
+ const result = await runner.run({input: "data"})
252
+ console.log(result) // Final context value
253
+ } catch (error) {
254
+ console.error("Pipeline failed:", error)
255
+ }
256
+ ```
257
+
258
+ **Use `run()` when:**
259
+
260
+ - Processing a single context
261
+ - You want errors to throw immediately
262
+ - You prefer traditional try/catch error handling
263
+
264
+ ### `pipe(contexts, maxConcurrent)` - Concurrent Batch Execution
265
+
266
+ Executes the pipeline concurrently across multiple contexts with a configurable concurrency limit. Returns an array of **settled results** - never throws on individual pipeline failures.
267
+
268
+ ```js
269
+ const builder = new ActionBuilder(new MyAction())
270
+ const runner = new ActionRunner(builder)
271
+
272
+ const contexts = [{id: 1}, {id: 2}, {id: 3}]
273
+ const results = await runner.pipe(contexts, 4) // Max 4 concurrent
274
+
275
+ results.forEach((result, i) => {
276
+ if (result.status === "fulfilled") {
277
+ console.log(`Context ${i} succeeded:`, result.value)
278
+ } else {
279
+ console.error(`Context ${i} failed:`, result.reason)
280
+ }
281
+ })
282
+ ```
283
+
284
+ **Use `pipe()` when:**
285
+
286
+ - Processing multiple contexts in parallel
287
+ - You want to control concurrency (default: 10)
288
+ - You need all results (both successes and failures)
289
+ - Error handling should be at the call site
290
+
291
+ **Important: `pipe()` returns settled results**
292
+
293
+ The `pipe()` method uses `Promise.allSettled()` internally and returns an array of settlement objects:
294
+
295
+ - `{status: "fulfilled", value: <result>}` for successful executions
296
+ - `{status: "rejected", reason: <error>}` for failed executions
297
+
298
+ This design ensures error handling responsibility stays at the call site - you decide how to handle failures rather than the framework deciding for you.
299
+
199
300
  ## ActionHooks
200
301
 
201
302
  Actioneer supports lifecycle hooks that can execute before and after each activity in your pipeline. Hooks are loaded from a module and can be configured either by file path or by providing a pre-instantiated hooks object.
@@ -335,13 +436,22 @@ Examples of minimal configs and one-liners to run them are in the project discus
335
436
 
336
437
  ## Testing
337
438
 
338
- Run the small smoke tests with Node's built-in test runner:
439
+ Run the comprehensive test suite with Node's built-in test runner:
339
440
 
340
441
  ```bash
341
442
  npm test
342
443
  ```
343
444
 
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.
445
+ The test suite includes 150+ tests covering all core classes and behaviors:
446
+
447
+ - **Activity** - Activity definitions, ACTIVITY flags (WHILE, UNTIL, SPLIT), and execution
448
+ - **ActionBuilder** - Fluent builder API, activity registration, and hooks configuration
449
+ - **ActionWrapper** - Activity iteration and integration with ActionBuilder
450
+ - **ActionRunner** - Pipeline execution, loop semantics, nested builders, and error handling
451
+ - **ActionHooks** - Hook lifecycle, loading from files, and timeout handling
452
+ - **Piper** - Concurrent processing, worker pools, and lifecycle hooks
453
+
454
+ Tests are organized in `tests/unit/` with one file per class. All tests use Node's native test runner and assertion library.
345
455
 
346
456
  ## Publishing
347
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/actioneer",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "Ready? Set?? ACTION!! pew! pew! pew!",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -56,6 +56,6 @@
56
56
  "typescript": "^5.9.3"
57
57
  },
58
58
  "dependencies": {
59
- "@gesslar/toolkit": "^0.7.0"
59
+ "@gesslar/toolkit": "^1.9.1"
60
60
  }
61
61
  }
@@ -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 */
@@ -100,6 +101,7 @@ export default class ActionBuilder {
100
101
  * Overloads:
101
102
  * - do(name, op)
102
103
  * - do(name, kind, pred, opOrWrapper)
104
+ * - do(name, kind, splitter, rejoiner, opOrWrapper)
103
105
  *
104
106
  * @overload
105
107
  * @param {string|symbol} name Activity name
@@ -116,6 +118,16 @@ export default class ActionBuilder {
116
118
  * @returns {ActionBuilder}
117
119
  */
118
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
+
119
131
  /**
120
132
  * Handles runtime dispatch across the documented overloads.
121
133
  *
@@ -128,7 +140,8 @@ export default class ActionBuilder {
128
140
 
129
141
  // signatures
130
142
  // name, [function] => once
131
- // 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
132
145
  // name, [number,function,ActionBuilder] => some kind of branch
133
146
 
134
147
  const action = this.#action
@@ -149,6 +162,19 @@ export default class ActionBuilder {
149
162
  Valid.type(op, "Function|ActionBuilder")
150
163
 
151
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})
152
178
  } else {
153
179
  throw Sass.new("Invalid number of arguments passed to 'do'")
154
180
  }
@@ -182,9 +208,14 @@ export default class ActionBuilder {
182
208
  *
183
209
  * @param {import("./ActionHooks.js").default} hooks An already-instantiated hooks instance.
184
210
  * @returns {ActionBuilder} The builder instance for chaining.
185
- * @throws {Sass} If hooks have already been configured.
211
+ * @throws {Sass} If hooks have already been configured with a different instance.
186
212
  */
187
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
+
188
219
  Valid.assert(this.#hooksFile === null, "Hooks have already been configured.")
189
220
  Valid.assert(this.#hooksKind === null, "Hooks have already been configured.")
190
221
  Valid.assert(this.#hooks === null, "Hooks have already been configured.")
@@ -236,8 +267,14 @@ export default class ActionBuilder {
236
267
  const newHooks = ActionHooks.new
237
268
 
238
269
  const hooks = this.#hooks
239
- 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
240
276
  return await newHooks({hooks}, this.#debug)
277
+ }
241
278
 
242
279
  const hooksFile = this.#hooksFile
243
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 {Data, Sass, Valid} from "@gesslar/toolkit"
1
+ import {Data, Sass, Util, Valid} from "@gesslar/toolkit"
2
2
 
3
3
  import ActionBuilder from "./ActionBuilder.js"
4
4
  import {ACTIVITY} from "./Activity.js"
@@ -112,20 +112,46 @@ export default class ActionRunner extends Piper {
112
112
  if(await this.#hasPredicate(activity,predicate,context))
113
113
  break
114
114
  }
115
- } else if(kindSplit && activity.opKind === "ActionBuilder") {
116
- // SPLIT activity: parallel execution with splitter/rejoiner pattern
117
- const splitter = activity.splitter
118
- const rejoiner = activity.rejoiner
115
+ } else if(kindSplit) {
116
+ // SPLIT activity: parallel execution with splitter/rejoiner
117
+ // pattern
118
+ const {splitter, rejoiner} = activity
119
119
 
120
120
  if(!splitter || !rejoiner)
121
121
  throw Sass.new(
122
- `SPLIT activity "${String(activity.name)}" requires both splitter and rejoiner functions.`
122
+ `SPLIT activity "${String(activity.name)}" requires both ` +
123
+ `splitter and rejoiner functions.`
123
124
  )
124
125
 
125
126
  const original = context
126
- const splitContext = splitter.call(activity.action,context)
127
- const newContext = await this.#execute(activity,splitContext,true)
128
- const rejoined = rejoiner.call(activity.action, original,newContext)
127
+ const splitContexts = await splitter.call(activity.action, context)
128
+
129
+ let settled
130
+
131
+ if(activity.opKind === "ActionBuilder") {
132
+ // Use parallel execution for ActionBuilder with concurrency control
133
+ // pipe() now returns settled results
134
+ if(activity.hooks)
135
+ activity.op.withHooks(activity.hooks)
136
+
137
+ const runner = new this.constructor(activity.op, {
138
+ debug: this.#debug, name: activity.name
139
+ })
140
+
141
+ // pipe() returns settled results with concurrency control
142
+ settled = await runner.pipe(splitContexts)
143
+ } else {
144
+ // For plain functions, process each split context
145
+ settled = await Util.settleAll(
146
+ splitContexts.map(ctx => this.#execute(activity, ctx))
147
+ )
148
+ }
149
+
150
+ const rejoined = await rejoiner.call(
151
+ activity.action,
152
+ original,
153
+ settled
154
+ )
129
155
 
130
156
  context = rejoined
131
157
  } else {
@@ -147,7 +173,7 @@ export default class ActionRunner extends Piper {
147
173
  *
148
174
  * When parallel=true, uses Piper.pipe() for concurrent execution with worker pool pattern.
149
175
  * This is triggered by SPLIT activities where context is divided for parallel processing.
150
- * Results from parallel execution are filtered to only include successful outcomes ({ok: true}).
176
+ * Results from parallel execution are returned directly as an array from Piper.pipe().
151
177
  *
152
178
  * @param {import("./Activity.js").default} activity Pipeline activity descriptor.
153
179
  * @param {unknown} context Current pipeline context.
@@ -162,17 +188,15 @@ export default class ActionRunner extends Piper {
162
188
  const opKind = activity.opKind
163
189
 
164
190
  if(opKind === "ActionBuilder") {
165
- if(activity.hooks && !activity.op.hasActionHooks)
166
- activity.op.withActionHooks(activity.hooks)
191
+ if(activity.hooks)
192
+ activity.op.withHooks(activity.hooks)
167
193
 
168
194
  const runner = new this.constructor(activity.op, {
169
195
  debug: this.#debug, name: activity.name
170
196
  })
171
197
 
172
198
  if(parallel) {
173
- const piped = await runner.pipe(context)
174
-
175
- return piped.filter(p => p.ok).map(p => p.value)
199
+ return await runner.pipe(context)
176
200
  } else {
177
201
  return await runner.run(context)
178
202
  }
@@ -182,16 +206,14 @@ export default class ActionRunner extends Piper {
182
206
 
183
207
  if(Data.isType(result, "ActionBuilder")) {
184
208
  if(activity.hooks)
185
- result.withActionHooks(activity.hooks)
209
+ result.withHooks(activity.hooks)
186
210
 
187
211
  const runner = new this.constructor(result, {
188
212
  debug: this.#debug, name: result.name
189
213
  })
190
214
 
191
215
  if(parallel) {
192
- const piped = await runner.pipe(context)
193
-
194
- return piped.filter(p => p.ok).map(p => p.value)
216
+ return await runner.pipe(context)
195
217
  } else {
196
218
  return await runner.run(context)
197
219
  }
package/src/lib/Piper.js CHANGED
@@ -79,7 +79,7 @@ export default class Piper {
79
79
  *
80
80
  * @param {Array<unknown>|unknown} items - Items to process
81
81
  * @param {number} maxConcurrent - Maximum concurrent items to process
82
- * @returns {Promise<Array<unknown>>} - Collected results from steps
82
+ * @returns {Promise<Array<{status: string, value?: unknown, reason?: unknown}>>} - Settled results from processing
83
83
  */
84
84
  async pipe(items, maxConcurrent = 10) {
85
85
  items = Array.isArray(items)
@@ -87,20 +87,23 @@ export default class Piper {
87
87
  : [items]
88
88
 
89
89
  let itemIndex = 0
90
- const allResults = []
90
+ const allResults = new Array(items.length)
91
91
 
92
92
  const processWorker = async() => {
93
93
  while(true) {
94
94
  const currentIndex = itemIndex++
95
+
95
96
  if(currentIndex >= items.length)
96
97
  break
97
98
 
98
99
  const item = items[currentIndex]
100
+
99
101
  try {
100
102
  const result = await this.#processItem(item)
101
- allResults.push(result)
103
+
104
+ allResults[currentIndex] = {status: "fulfilled", value: result}
102
105
  } catch(error) {
103
- throw Sass.new("Processing pipeline item.", error)
106
+ allResults[currentIndex] = {status: "rejected", reason: error}
104
107
  }
105
108
  }
106
109
  }
@@ -108,6 +111,7 @@ export default class Piper {
108
111
  const setupResult = await Util.settleAll(
109
112
  [...this.#lifeCycle.get("setup")].map(e => e())
110
113
  )
114
+
111
115
  this.#processResult("Setting up the pipeline.", setupResult)
112
116
 
113
117
  try {
@@ -118,14 +122,14 @@ export default class Piper {
118
122
  for(let i = 0; i < workerCount; i++)
119
123
  workers.push(processWorker())
120
124
 
121
- // Wait for all workers to complete
122
- const processResult = await Util.settleAll(workers)
123
- this.#processResult("Processing pipeline.", processResult)
125
+ // Wait for all workers to complete - don't throw on worker failures
126
+ await Promise.all(workers)
124
127
  } finally {
125
128
  // Run cleanup hooks
126
129
  const teardownResult = await Util.settleAll(
127
130
  [...this.#lifeCycle.get("teardown")].map(e => e())
128
131
  )
132
+
129
133
  this.#processResult("Tearing down the pipeline.", teardownResult)
130
134
  }
131
135
 
@@ -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
  /**