@gesslar/actioneer 0.2.4 → 0.2.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/actioneer",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
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
  }
@@ -247,7 +247,7 @@ export default class ActionBuilder {
247
247
  async build() {
248
248
  const action = this.#action
249
249
 
250
- if(!action.tag) {
250
+ if(action && !action.tag) {
251
251
  action.tag = this.#tag
252
252
 
253
253
  action.setup.call(action, this)
@@ -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"
@@ -113,30 +113,45 @@ export default class ActionRunner extends Piper {
113
113
  break
114
114
  }
115
115
  } else if(kindSplit) {
116
- // SPLIT activity: parallel execution with splitter/rejoiner pattern
117
- const splitter = activity.splitter
118
- const rejoiner = activity.rejoiner
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 splitContexts = splitter.call(activity.action,context)
127
+ const splitContexts = await splitter.call(activity.action, context)
128
+
129
+ let settled
127
130
 
128
- let newContext
129
131
  if(activity.opKind === "ActionBuilder") {
130
- // Use parallel execution for ActionBuilder
131
- newContext = await this.#execute(activity,splitContexts,true)
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)
132
143
  } else {
133
144
  // For plain functions, process each split context
134
- newContext = await Promise.all(
135
- splitContexts.map(ctx => this.#execute(activity,ctx))
145
+ settled = await Util.settleAll(
146
+ splitContexts.map(ctx => this.#execute(activity, ctx))
136
147
  )
137
148
  }
138
149
 
139
- const rejoined = rejoiner.call(activity.action, original,newContext)
150
+ const rejoined = await rejoiner.call(
151
+ activity.action,
152
+ original,
153
+ settled
154
+ )
140
155
 
141
156
  context = rejoined
142
157
  } else {
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