@gesslar/actioneer 2.0.2 → 2.2.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Actioneer
2
2
 
3
- Actioneer is a small, focused action orchestration library for Node.js and browser environments. It provides a fluent builder for composing activities and a concurrent runner with lifecycle hooks and simple loop semantics (while/until). The project is written as ES modules and targets Node 20+ and modern browsers.
3
+ Actioneer is a small, focused action orchestration library for Node.js and browser environments. It provides a fluent builder for composing activities and a concurrent runner with lifecycle hooks and control flow semantics (while/until/if/break/continue). The project is written as ES modules and targets Node 20+ and modern browsers.
4
4
 
5
5
  This repository extracts the action orchestration pieces from a larger codebase and exposes a compact API for building pipelines of work that can run concurrently with hook support and nested pipelines.
6
6
 
@@ -16,7 +16,7 @@ These classes work in browsers, Node.js, and browser-like environments such as T
16
16
  | ActionHooks | Lifecycle hook management (requires pre-instantiated hooks in browser) |
17
17
  | ActionRunner | Concurrent pipeline executor with configurable concurrency |
18
18
  | ActionWrapper | Activity container and iterator |
19
- | Activity | Activity definitions with WHILE, UNTIL, and SPLIT modes |
19
+ | Activity | Activity definitions with WHILE, UNTIL, IF, BREAK, CONTINUE, and SPLIT modes |
20
20
  | Piper | Base concurrent processing with worker pools |
21
21
 
22
22
  ### Node.js
@@ -133,7 +133,7 @@ If you'd like more complete typings or additional JSDoc, open an issue or send a
133
133
 
134
134
  ## Activity Modes
135
135
 
136
- Actioneer supports four distinct execution modes for activities, allowing you to control how operations are executed:
136
+ Actioneer supports six distinct execution modes for activities, allowing you to control how operations are executed:
137
137
 
138
138
  ### Execute Once (Default)
139
139
 
@@ -203,6 +203,133 @@ class ProcessorAction {
203
203
 
204
204
  The activity executes at least once, then continues while the predicate returns `false`. Once it returns `true`, execution moves to the next activity.
205
205
 
206
+ ### IF Mode
207
+
208
+ Conditionally executes an activity based on a predicate. Unlike WHILE/UNTIL, IF executes at most once:
209
+
210
+ ```js
211
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
212
+
213
+ class ConditionalAction {
214
+ #shouldProcess = (ctx) => ctx.value > 10
215
+
216
+ #processLargeValue = (ctx) => {
217
+ ctx.processed = ctx.value * 2
218
+ }
219
+
220
+ setup(builder) {
221
+ builder
222
+ .do("initialize", ctx => { ctx.value = 15 })
223
+ .do("maybeProcess", ACTIVITY.IF, this.#shouldProcess, this.#processLargeValue)
224
+ .do("finish", ctx => { return ctx })
225
+ }
226
+ }
227
+ ```
228
+
229
+ If the predicate returns `true`, the activity executes once. If `false`, the activity is skipped entirely and execution moves to the next activity.
230
+
231
+ ### BREAK Mode
232
+
233
+ Breaks out of a WHILE or UNTIL loop when a predicate returns `true`. BREAK must be used inside a nested ActionBuilder within a loop:
234
+
235
+ ```js
236
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
237
+
238
+ class BreakExample {
239
+ setup(builder) {
240
+ builder
241
+ .do("initialize", ctx => {
242
+ ctx.count = 0
243
+ ctx.items = []
244
+ })
245
+ .do("loop", ACTIVITY.WHILE, ctx => ctx.count < 100,
246
+ new ActionBuilder()
247
+ .do("increment", ctx => {
248
+ ctx.count++
249
+ ctx.items.push(ctx.count)
250
+ return ctx
251
+ })
252
+ .do("earlyExit", ACTIVITY.BREAK, ctx => ctx.count >= 5)
253
+ )
254
+ .do("finish", ctx => { return ctx.items }) // Returns [1, 2, 3, 4, 5]
255
+ }
256
+ }
257
+ ```
258
+
259
+ When the BREAK predicate returns `true`, the loop terminates immediately and execution continues with the next activity after the loop.
260
+
261
+ **Important:** BREAK only works inside a nested ActionBuilder that is the operation of a WHILE or UNTIL activity. Using BREAK outside of a loop context will throw an error.
262
+
263
+ ### CONTINUE Mode
264
+
265
+ Skips the remaining activities in the current loop iteration and continues to the next iteration. Like BREAK, CONTINUE must be used inside a nested ActionBuilder within a loop:
266
+
267
+ ```js
268
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
269
+
270
+ class ContinueExample {
271
+ setup(builder) {
272
+ builder
273
+ .do("initialize", ctx => {
274
+ ctx.count = 0
275
+ ctx.processed = []
276
+ })
277
+ .do("loop", ACTIVITY.WHILE, ctx => ctx.count < 5,
278
+ new ActionBuilder()
279
+ .do("increment", ctx => {
280
+ ctx.count++
281
+ return ctx
282
+ })
283
+ .do("skipEvens", ACTIVITY.CONTINUE, ctx => ctx.count % 2 === 0)
284
+ .do("process", ctx => {
285
+ ctx.processed.push(ctx.count)
286
+ return ctx
287
+ })
288
+ )
289
+ .do("finish", ctx => { return ctx.processed }) // Returns [1, 3, 5]
290
+ }
291
+ }
292
+ ```
293
+
294
+ When the CONTINUE predicate returns `true`, the remaining activities in that iteration are skipped, and the loop continues with its next iteration (re-evaluating the loop predicate for WHILE, or executing the operation then evaluating for UNTIL).
295
+
296
+ **Important:** Like BREAK, CONTINUE only works inside a nested ActionBuilder within a WHILE or UNTIL loop.
297
+
298
+ ### Combining Control Flow
299
+
300
+ You can combine IF, BREAK, and CONTINUE within the same loop for complex control flow:
301
+
302
+ ```js
303
+ class CombinedExample {
304
+ setup(builder) {
305
+ builder
306
+ .do("initialize", ctx => {
307
+ ctx.count = 0
308
+ ctx.results = []
309
+ })
310
+ .do("loop", ACTIVITY.WHILE, ctx => ctx.count < 100,
311
+ new ActionBuilder()
312
+ .do("increment", ctx => { ctx.count++; return ctx })
313
+ .do("exitAt10", ACTIVITY.BREAK, ctx => ctx.count > 10)
314
+ .do("skipEvens", ACTIVITY.CONTINUE, ctx => ctx.count % 2 === 0)
315
+ .do("processLarge", ACTIVITY.IF, ctx => ctx.count > 5, ctx => {
316
+ ctx.results.push(ctx.count * 10)
317
+ return ctx
318
+ })
319
+ .do("processAll", ctx => {
320
+ ctx.results.push(ctx.count)
321
+ return ctx
322
+ })
323
+ )
324
+ }
325
+ }
326
+ // Results: [1, 3, 5, 70, 7, 90, 9]
327
+ // - 1, 3, 5: odd numbers <= 5, just pushed
328
+ // - 7, 9: odd numbers > 5, pushed with *10 first, then pushed
329
+ // - evens skipped by CONTINUE
330
+ // - loop exits when count > 10
331
+ ```
332
+
206
333
  ### SPLIT Mode
207
334
 
208
335
  Executes with a split/rejoin pattern for parallel execution. This mode requires a splitter function to divide the context and a rejoiner function to recombine results:
@@ -301,18 +428,22 @@ class NestedParallel {
301
428
 
302
429
  ### Mode Constraints
303
430
 
304
- - **Only one mode per activity**: You cannot combine WHILE, UNTIL, and SPLIT. Attempting to use multiple modes will throw an error: `"You can't combine activity kinds. Pick one: WHILE, UNTIL, or SPLIT!"`
431
+ - **Only one mode per activity**: Each activity can have only one mode. Attempting to combine modes will throw an error
305
432
  - **SPLIT requires both functions**: The splitter and rejoiner are both mandatory for SPLIT mode
306
- - **Predicates must return boolean**: For WHILE and UNTIL modes, predicates should return `true` or `false`
433
+ - **Predicates must return boolean**: All predicates (WHILE, UNTIL, IF, BREAK, CONTINUE) should return `true` or `false`
434
+ - **BREAK/CONTINUE require loop context**: These modes only work inside a nested ActionBuilder within a WHILE or UNTIL loop
307
435
 
308
436
  ### Mode Summary Table
309
437
 
310
- | Mode | Signature | Predicate Timing | Use Case |
311
- | ----------- | ---------------------------------------------------------- | ---------------- | ------------------------------------ |
312
- | **Default** | `.do(name, operation)` | N/A | Execute once per context |
313
- | **WHILE** | `.do(name, ACTIVITY.WHILE, predicate, operation)` | Before iteration | Loop while condition is true |
314
- | **UNTIL** | `.do(name, ACTIVITY.UNTIL, predicate, operation)` | After iteration | Loop until condition is true |
315
- | **SPLIT** | `.do(name, ACTIVITY.SPLIT, splitter, rejoiner, operation)` | N/A | Parallel execution with split/rejoin |
438
+ | Mode | Signature | Predicate Timing | Use Case |
439
+ | ------------ | ---------------------------------------------------------- | ---------------- | ------------------------------------------- |
440
+ | **Default** | `.do(name, operation)` | N/A | Execute once per context |
441
+ | **WHILE** | `.do(name, ACTIVITY.WHILE, predicate, operation)` | Before iteration | Loop while condition is true |
442
+ | **UNTIL** | `.do(name, ACTIVITY.UNTIL, predicate, operation)` | After iteration | Loop until condition is true |
443
+ | **IF** | `.do(name, ACTIVITY.IF, predicate, operation)` | Before execution | Conditional execution (once or skip) |
444
+ | **BREAK** | `.do(name, ACTIVITY.BREAK, predicate)` | When reached | Exit enclosing WHILE/UNTIL loop |
445
+ | **CONTINUE** | `.do(name, ACTIVITY.CONTINUE, predicate)` | When reached | Skip to next iteration of enclosing loop |
446
+ | **SPLIT** | `.do(name, ACTIVITY.SPLIT, splitter, rejoiner, operation)` | N/A | Parallel execution with split/rejoin |
316
447
 
317
448
  ## Running Actions: `run()` vs `pipe()`
318
449
 
@@ -376,6 +507,74 @@ The `pipe()` method uses `Promise.allSettled()` internally and returns an array
376
507
 
377
508
  This design ensures error handling responsibility stays at the call site - you decide how to handle failures rather than the framework deciding for you.
378
509
 
510
+ ## Pipeline Completion: `done()`
511
+
512
+ The `done()` method registers a callback that executes after all activities in the pipeline complete, regardless of whether an error occurred. This is useful for cleanup, finalization, or returning a transformed result.
513
+
514
+ ```js
515
+ import { ActionBuilder, ActionRunner } from "@gesslar/actioneer"
516
+
517
+ class MyAction {
518
+ setup(builder) {
519
+ builder
520
+ .do("step1", ctx => { ctx.a = 1 })
521
+ .do("step2", ctx => { ctx.b = 2 })
522
+ .done(ctx => {
523
+ // This runs after all activities complete
524
+ return { total: ctx.a + ctx.b }
525
+ })
526
+ }
527
+ }
528
+
529
+ const builder = new ActionBuilder(new MyAction())
530
+ const runner = new ActionRunner(builder)
531
+ const result = await runner.run({})
532
+ console.log(result) // { total: 3 }
533
+ ```
534
+
535
+ ### Key Behaviors
536
+
537
+ - **Always executes**: The `done()` callback runs even if an earlier activity throws an error (similar to `finally` in try/catch)
538
+ - **Top-level only**: The callback only runs for the outermost pipeline, not for nested builders inside loops (WHILE/UNTIL). However, for SPLIT activities, `done()` runs for each split context since each is an independent execution
539
+ - **Transform the result**: Whatever you return from `done()` becomes the final pipeline result
540
+ - **Access to action context**: The callback is bound to the action instance, so `this` refers to your action class
541
+ - **Async support**: The callback can be async and return a Promise
542
+
543
+ ### Use Cases
544
+
545
+ **Cleanup resources:**
546
+
547
+ ```js
548
+ builder
549
+ .do("openConnection", ctx => { ctx.conn = openDb() })
550
+ .do("query", ctx => { ctx.data = ctx.conn.query("SELECT *") })
551
+ .done(ctx => {
552
+ ctx.conn.close() // Always close, even on error
553
+ return ctx.data
554
+ })
555
+ ```
556
+
557
+ **Transform the final result:**
558
+
559
+ ```js
560
+ builder
561
+ .do("gather", ctx => { ctx.items = [1, 2, 3] })
562
+ .do("process", ctx => { ctx.items = ctx.items.map(x => x * 2) })
563
+ .done(ctx => ctx.items) // Return just the items array, not the whole context
564
+ ```
565
+
566
+ **Logging and metrics:**
567
+
568
+ ```js
569
+ builder
570
+ .do("start", ctx => { ctx.startTime = Date.now() })
571
+ .do("work", ctx => { /* ... */ })
572
+ .done(ctx => {
573
+ console.log(`Pipeline completed in ${Date.now() - ctx.startTime}ms`)
574
+ return ctx
575
+ })
576
+ ```
577
+
379
578
  ## ActionHooks
380
579
 
381
580
  Actioneer supports lifecycle hooks that can execute before and after each activity in your pipeline. Hooks can be configured by file path (Node.js only) or by providing a pre-instantiated hooks object (Node.js and browser).
@@ -558,9 +757,9 @@ Run the comprehensive test suite with Node's built-in test runner:
558
757
  npm test
559
758
  ```
560
759
 
561
- The test suite includes 150+ tests covering all core classes and behaviors:
760
+ The test suite includes 200+ tests covering all core classes and behaviors:
562
761
 
563
- - **Activity** - Activity definitions, ACTIVITY flags (WHILE, UNTIL, SPLIT), and execution
762
+ - **Activity** - Activity definitions, ACTIVITY flags (WHILE, UNTIL, IF, BREAK, CONTINUE, SPLIT), and execution
564
763
  - **ActionBuilder** - Fluent builder API, activity registration, and hooks configuration
565
764
  - **ActionWrapper** - Activity iteration and integration with ActionBuilder
566
765
  - **ActionRunner** - Pipeline execution, loop semantics, nested builders, and error handling
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "gesslar",
6
6
  "url": "https://gesslar.dev"
7
7
  },
8
- "version": "2.0.2",
8
+ "version": "2.2.0",
9
9
  "license": "Unlicense",
10
10
  "homepage": "https://github.com/gesslar/toolkit#readme",
11
11
  "repository": {
@@ -49,19 +49,19 @@
49
49
  "node": ">=22"
50
50
  },
51
51
  "dependencies": {
52
- "@gesslar/toolkit": "^3.6.2"
52
+ "@gesslar/toolkit": "^3.24.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@gesslar/uglier": "^0.5.0",
55
+ "@gesslar/uglier": "^1.2.0",
56
56
  "eslint": "^9.39.2",
57
57
  "typescript": "^5.9.3"
58
58
  },
59
59
  "scripts": {
60
60
  "lint": "eslint src/",
61
61
  "lint:fix": "eslint src/ --fix",
62
- "types:build": "tsc -p tsconfig.types.json",
62
+ "types": "node -e \"require('fs').rmSync('types',{recursive:true,force:true});\" && tsc -p tsconfig.types.json",
63
63
  "submit": "pnpm publish --access public --//registry.npmjs.org/:_authToken=\"${NPM_ACCESS_TOKEN}\"",
64
- "update": "pnpm up --latest --recursive",
64
+ "update": "pnpm self-update && pnpx npm-check-updates -u && pnpm install",
65
65
  "test": "node --test tests/**/*.test.js",
66
66
  "test:browser": "node --test tests/browser/*.test.js",
67
67
  "test:node": "node --test tests/node/*.test.js",
@@ -66,9 +66,14 @@ export default class ActionBuilder {
66
66
  #debug = null
67
67
  /** @type {symbol|null} */
68
68
  #tag = null
69
+ /** @type {string|null} */
69
70
  #hooksFile = null
71
+ /** @type {string|null} */
70
72
  #hooksKind = null
73
+ /** @type {import("./ActionHooks.js").default|null} */
71
74
  #hooks = null
75
+ /** @type {ActionFunction|null} */
76
+ #done = null
72
77
 
73
78
  /**
74
79
  * Creates a new ActionBuilder instance with the provided action callback.
@@ -99,9 +104,10 @@ export default class ActionBuilder {
99
104
  * Register an activity that the runner can execute.
100
105
  *
101
106
  * Overloads:
102
- * - do(name, op)
103
- * - do(name, kind, pred, opOrWrapper)
104
- * - do(name, kind, splitter, rejoiner, opOrWrapper)
107
+ * - do(name, op) - Simple once-off activity
108
+ * - do(name, kind, pred) - BREAK/CONTINUE control flow (no op, just predicate)
109
+ * - do(name, kind, pred, opOrWrapper) - WHILE/UNTIL/IF with predicate and operation
110
+ * - do(name, kind, splitter, rejoiner, opOrWrapper) - SPLIT with parallel execution
105
111
  *
106
112
  * @overload
107
113
  * @param {string|symbol} name Activity name
@@ -112,9 +118,17 @@ export default class ActionBuilder {
112
118
  /**
113
119
  * @overload
114
120
  * @param {string|symbol} name Activity name
115
- * @param {number} kind Kind bitfield from {@link ActivityFlags}.
121
+ * @param {number} kind ACTIVITY.BREAK or ACTIVITY.CONTINUE flag.
122
+ * @param {(context: unknown) => boolean|Promise<boolean>} pred Predicate to evaluate for control flow.
123
+ * @returns {ActionBuilder}
124
+ */
125
+
126
+ /**
127
+ * @overload
128
+ * @param {string|symbol} name Activity name
129
+ * @param {number} kind Activity kind (WHILE, UNTIL, or IF) from {@link ActivityFlags}.
116
130
  * @param {(context: unknown) => boolean|Promise<boolean>} pred Predicate executed before/after the op.
117
- * @param {ActionFunction|import("./ActionWrapper.js").default} op Operation or nested wrapper to execute.
131
+ * @param {ActionFunction|ActionBuilder} op Operation or nested builder to execute.
118
132
  * @returns {ActionBuilder}
119
133
  */
120
134
 
@@ -124,7 +138,7 @@ export default class ActionBuilder {
124
138
  * @param {number} kind ACTIVITY.SPLIT flag.
125
139
  * @param {(context: unknown) => unknown} splitter Splitter function for SPLIT mode.
126
140
  * @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.
141
+ * @param {ActionFunction|ActionBuilder} op Operation or nested builder to execute.
128
142
  * @returns {ActionBuilder}
129
143
  */
130
144
 
@@ -141,6 +155,7 @@ export default class ActionBuilder {
141
155
  // signatures
142
156
  // name, [function] => once
143
157
  // name, [number,function,function] => some kind of control operation (WHILE/UNTIL)
158
+ // name, [number,function] => some kind of control operation (BREAK/CONTINUE)
144
159
  // name, [number,function,function,function] => SPLIT operation with splitter/rejoiner
145
160
  // name, [number,function,ActionBuilder] => some kind of branch
146
161
 
@@ -154,6 +169,13 @@ export default class ActionBuilder {
154
169
  Valid.type(op, "Function")
155
170
 
156
171
  Object.assign(activityDefinition, {op, kind})
172
+ } else if(args.length === 2) {
173
+ const [kind, pred] = args
174
+ Valid.type(kind, "Number")
175
+ Valid.type(pred, "Function")
176
+ Valid.assert(kind === ACTIVITY.BREAK || kind === ACTIVITY.CONTINUE, "Invalid arguments for BREAK/CONTINUE.")
177
+
178
+ Object.assign(activityDefinition, {kind, pred})
157
179
  } else if(args.length === 3) {
158
180
  const [kind, pred, op] = args
159
181
 
@@ -171,7 +193,7 @@ export default class ActionBuilder {
171
193
  Valid.type(op, "Function|ActionBuilder")
172
194
 
173
195
  // Validate that kind is SPLIT
174
- if((kind & ACTIVITY.SPLIT) !== ACTIVITY.SPLIT)
196
+ if(kind !== ACTIVITY.SPLIT)
175
197
  throw Sass.new("4-argument form of 'do' is only valid for ACTIVITY.SPLIT")
176
198
 
177
199
  Object.assign(activityDefinition, {kind, splitter, rejoiner, op})
@@ -246,6 +268,19 @@ export default class ActionBuilder {
246
268
  return this
247
269
  }
248
270
 
271
+ /**
272
+ * Register a callback to be executed after all activities complete.
273
+ *
274
+ * @param {ActionFunction} callback Function to execute at the end of the pipeline.
275
+ * @returns {ActionBuilder} The builder instance for chaining.
276
+ */
277
+ done(callback) {
278
+ Valid.type(callback, "Function")
279
+ this.#done = callback
280
+
281
+ return this
282
+ }
283
+
249
284
  /**
250
285
  * Validates that an activity name has not been reused.
251
286
  *
@@ -281,6 +316,8 @@ export default class ActionBuilder {
281
316
  activities: this.#activities,
282
317
  debug: this.#debug,
283
318
  hooks,
319
+ done: this.#done,
320
+ action: this.#action,
284
321
  })
285
322
  }
286
323