@gesslar/actioneer 0.2.1 → 0.2.2

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
@@ -44,6 +44,158 @@ import { ActionBuilder, ActionRunner } from "@gesslar/actioneer"
44
44
 
45
45
  If you'd like more complete typings or additional JSDoc, open an issue or send a PR — contributions welcome.
46
46
 
47
+ ## Activity Modes
48
+
49
+ Actioneer supports four distinct execution modes for activities, allowing you to control how operations are executed:
50
+
51
+ ### Execute Once (Default)
52
+
53
+ The simplest mode executes an activity exactly once per context:
54
+
55
+ ```js
56
+ class MyAction {
57
+ setup(builder) {
58
+ builder.do("processItem", ctx => {
59
+ ctx.result = ctx.input * 2
60
+ })
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### WHILE Mode
66
+
67
+ Loops while a predicate returns `true`. The predicate is evaluated **before** each iteration:
68
+
69
+ ```js
70
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
71
+
72
+ class CounterAction {
73
+ #shouldContinue = (ctx) => ctx.count < 10
74
+
75
+ #increment = (ctx) => {
76
+ ctx.count += 1
77
+ }
78
+
79
+ setup(builder) {
80
+ builder
81
+ .do("initialize", ctx => { ctx.count = 0 })
82
+ .do("countUp", ACTIVITY.WHILE, this.#shouldContinue, this.#increment)
83
+ .do("finish", ctx => { return ctx.count })
84
+ }
85
+ }
86
+ ```
87
+
88
+ The activity will continue executing as long as the predicate returns `true`. Once it returns `false`, execution moves to the next activity.
89
+
90
+ ### UNTIL Mode
91
+
92
+ Loops until a predicate returns `true`. The predicate is evaluated **after** each iteration:
93
+
94
+ ```js
95
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
96
+
97
+ class ProcessorAction {
98
+ #queueIsEmpty = (ctx) => ctx.queue.length === 0
99
+
100
+ #processItem = (ctx) => {
101
+ const item = ctx.queue.shift()
102
+ ctx.processed.push(item)
103
+ }
104
+
105
+ setup(builder) {
106
+ builder
107
+ .do("initialize", ctx => {
108
+ ctx.queue = [1, 2, 3, 4, 5]
109
+ ctx.processed = []
110
+ })
111
+ .do("process", ACTIVITY.UNTIL, this.#queueIsEmpty, this.#processItem)
112
+ .do("finish", ctx => { return ctx.processed })
113
+ }
114
+ }
115
+ ```
116
+
117
+ The activity executes at least once, then continues while the predicate returns `false`. Once it returns `true`, execution moves to the next activity.
118
+
119
+ ### SPLIT Mode
120
+
121
+ 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:
122
+
123
+ ```js
124
+ import { ActionBuilder, ACTIVITY } from "@gesslar/actioneer"
125
+
126
+ class ParallelProcessor {
127
+ #split = (ctx) => {
128
+ // Split context into multiple items for parallel processing
129
+ return ctx.items.map(item => ({ item, processedBy: "worker" }))
130
+ }
131
+
132
+ #rejoin = (originalCtx, splitResults) => {
133
+ // Recombine parallel results back into original context
134
+ originalCtx.results = splitResults.map(r => r.item)
135
+ return originalCtx
136
+ }
137
+
138
+ #processItem = (ctx) => {
139
+ ctx.item = ctx.item.toUpperCase()
140
+ }
141
+
142
+ setup(builder) {
143
+ builder
144
+ .do("initialize", ctx => {
145
+ ctx.items = ["apple", "banana", "cherry"]
146
+ })
147
+ .do("parallel", ACTIVITY.SPLIT, this.#split, this.#rejoin, this.#processItem)
148
+ .do("finish", ctx => { return ctx.results })
149
+ }
150
+ }
151
+ ```
152
+
153
+ **How SPLIT Mode Works:**
154
+
155
+ 1. The **splitter** function receives the context and returns an array of contexts (one per parallel task)
156
+ 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
158
+ 4. The rejoiner combines the results and returns the updated context
159
+
160
+ **Nested Pipelines with SPLIT:**
161
+
162
+ You can use nested ActionBuilders with SPLIT mode for complex parallel workflows:
163
+
164
+ ```js
165
+ class NestedParallel {
166
+ #split = (ctx) => ctx.batches.map(batch => ({ batch }))
167
+
168
+ #rejoin = (original, results) => {
169
+ original.processed = results.flatMap(r => r.batch)
170
+ return original
171
+ }
172
+
173
+ setup(builder) {
174
+ builder
175
+ .do("parallel", ACTIVITY.SPLIT, this.#split, this.#rejoin,
176
+ new ActionBuilder(this)
177
+ .do("step1", ctx => { /* ... */ })
178
+ .do("step2", ctx => { /* ... */ })
179
+ )
180
+ }
181
+ }
182
+ ```
183
+
184
+ ### Mode Constraints
185
+
186
+ - **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!"`
187
+ - **SPLIT requires both functions**: The splitter and rejoiner are both mandatory for SPLIT mode
188
+ - **Predicates must return boolean**: For WHILE and UNTIL modes, predicates should return `true` or `false`
189
+
190
+ ### Mode Summary Table
191
+
192
+ | Mode | Signature | Predicate Timing | Use Case |
193
+ | ----------- | ---------------------------------------------------------- | ---------------- | ------------------------------------ |
194
+ | **Default** | `.do(name, operation)` | N/A | Execute once per context |
195
+ | **WHILE** | `.do(name, ACTIVITY.WHILE, predicate, operation)` | Before iteration | Loop while condition is true |
196
+ | **UNTIL** | `.do(name, ACTIVITY.UNTIL, predicate, operation)` | After iteration | Loop until condition is true |
197
+ | **SPLIT** | `.do(name, ACTIVITY.SPLIT, splitter, rejoiner, operation)` | N/A | Parallel execution with split/rejoin |
198
+
47
199
  ## ActionHooks
48
200
 
49
201
  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.1",
3
+ "version": "0.2.2",
4
4
  "description": "Ready? Set?? ACTION!! pew! pew! pew!",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -30,8 +30,6 @@ import ActionHooks from "./ActionHooks.js"
30
30
  * @property {ActionFunction|import("./ActionWrapper.js").default} op Operation to execute.
31
31
  * @property {number} [kind] Optional kind flags from {@link ActivityFlags}.
32
32
  * @property {(context: unknown) => boolean|Promise<boolean>} [pred] Loop predicate.
33
- * @property {(context: unknown) => unknown} [splitter] Function to split context for parallel execution (SPLIT activities).
34
- * @property {(originalContext: unknown, splitResults: unknown) => unknown} [rejoiner] Function to rejoin split results (SPLIT activities).
35
33
  */
36
34
 
37
35
  /**
@@ -67,29 +65,15 @@ export default class ActionBuilder {
67
65
  #debug = null
68
66
  /** @type {symbol|null} */
69
67
  #tag = null
70
- /** @type {string|null} */
71
68
  #hooksFile = null
72
- /** @type {string|null} */
73
69
  #hooksKind = null
74
- /** @type {unknown|null} */
75
70
  #hooks = null
76
- /** @type {import("./ActionHooks.js").default|null} */
77
- #actionHooks = null
78
-
79
- /**
80
- * Get the builder's tag symbol.
81
- *
82
- * @returns {symbol|null} The tag symbol for this builder instance
83
- */
84
- get tag() {
85
- return this.#tag
86
- }
87
71
 
88
72
  /**
89
73
  * Creates a new ActionBuilder instance with the provided action callback.
90
74
  *
91
- * @param {ActionBuilderAction} [action] - Base action invoked by the runner when a block satisfies the configured structure.
92
- * @param {ActionBuilderConfig} [config] - Options
75
+ * @param {ActionBuilderAction} [action] Base action invoked by the runner when a block satisfies the configured structure.
76
+ * @param {ActionBuilderConfig} [config] Options
93
77
  */
94
78
  constructor(
95
79
  action,
@@ -128,16 +112,6 @@ export default class ActionBuilder {
128
112
  * @returns {ActionBuilder}
129
113
  */
130
114
 
131
- /**
132
- * @overload
133
- * @param {string|symbol} name Activity name
134
- * @param {number} kind Kind bitfield (ACTIVITY.SPLIT).
135
- * @param {(context: unknown) => unknown} splitter Function to split context for parallel execution.
136
- * @param {(originalContext: unknown, splitResults: unknown) => unknown} rejoiner Function to rejoin split results with original context.
137
- * @param {ActionFunction|ActionBuilder} op Operation or nested ActionBuilder to execute on split context.
138
- * @returns {ActionBuilder}
139
- */
140
-
141
115
  /**
142
116
  * Handles runtime dispatch across the documented overloads.
143
117
  *
@@ -155,32 +129,22 @@ export default class ActionBuilder {
155
129
 
156
130
  const action = this.#action
157
131
  const debug = this.#debug
158
- const activityDefinition = {name,action,debug}
132
+ const activityDefinition = {name, action, debug}
159
133
 
160
134
  if(args.length === 1) {
161
- const [op,kind] = args
135
+ const [op, kind] = args
162
136
  Valid.type(kind, "Number|undefined")
163
137
  Valid.type(op, "Function")
164
138
 
165
- Object.assign(activityDefinition, {op,kind})
139
+ Object.assign(activityDefinition, {op, kind})
166
140
  } else if(args.length === 3) {
167
- const [kind,pred,op] = args
141
+ const [kind, pred, op] = args
168
142
 
169
143
  Valid.type(kind, "Number")
170
144
  Valid.type(pred, "Function")
171
145
  Valid.type(op, "Function|ActionBuilder")
172
146
 
173
- Object.assign(activityDefinition, {kind,pred,op})
174
- } else if(args.length === 4) {
175
- const [kind,splitter,rejoiner,op] = args
176
-
177
- Valid.type(kind, "Number")
178
- Valid.type(splitter, "Function")
179
- Valid.type(rejoiner, "Function")
180
- Valid.type(op, "Function|ActionBuilder")
181
-
182
- Object.assign(activityDefinition, {kind,splitter,rejoiner,op})
183
-
147
+ Object.assign(activityDefinition, {kind, pred, op})
184
148
  } else {
185
149
  throw Sass.new("Invalid number of arguments passed to 'do'")
186
150
  }
@@ -199,7 +163,9 @@ export default class ActionBuilder {
199
163
  * @throws {Sass} If hooks have already been configured.
200
164
  */
201
165
  withHooksFile(hooksFile, hooksKind) {
202
- Valid.assert(this.#exclusiveHooksCheck(), "Hooks have already been configured.")
166
+ Valid.assert(this.#hooksFile === null, "Hooks have already been configured.")
167
+ Valid.assert(this.#hooksKind === null, "Hooks have already been configured.")
168
+ Valid.assert(this.#hooks === null, "Hooks have already been configured.")
203
169
 
204
170
  this.#hooksFile = hooksFile
205
171
  this.#hooksKind = hooksKind
@@ -215,40 +181,15 @@ export default class ActionBuilder {
215
181
  * @throws {Sass} If hooks have already been configured.
216
182
  */
217
183
  withHooks(hooks) {
218
- Valid.assert(this.#exclusiveHooksCheck(), "Hooks have already been configured.")
184
+ Valid.assert(this.#hooksFile === null, "Hooks have already been configured.")
185
+ Valid.assert(this.#hooksKind === null, "Hooks have already been configured.")
186
+ Valid.assert(this.#hooks === null, "Hooks have already been configured.")
219
187
 
220
188
  this.#hooks = hooks
221
189
 
222
190
  return this
223
191
  }
224
192
 
225
- /**
226
- * Configure hooks using an ActionHooks instance directly (typically used internally).
227
- *
228
- * @param {import("./ActionHooks.js").default} actionHooks Pre-configured ActionHooks instance.
229
- * @returns {ActionBuilder} The builder instance for chaining.
230
- * @throws {Sass} If hooks have already been configured.
231
- */
232
- withActionHooks(actionHooks) {
233
- Valid.assert(this.#exclusiveHooksCheck(), "Hooks have already been configured.")
234
-
235
- this.#actionHooks = actionHooks
236
-
237
- return this
238
- }
239
-
240
- /**
241
- * Ensures only one hooks configuration method is used at a time.
242
- *
243
- * @returns {boolean} True if no hooks have been configured yet, false otherwise.
244
- * @private
245
- */
246
- #exclusiveHooksCheck() {
247
- return !!(this.#hooksFile && this.#hooksKind) +
248
- !!(this.#hooks) +
249
- !!(this.#actionHooks) === 0
250
- }
251
-
252
193
  /**
253
194
  * Validates that an activity name has not been reused.
254
195
  *
@@ -270,8 +211,6 @@ export default class ActionBuilder {
270
211
  */
271
212
  async build() {
272
213
  const action = this.#action
273
- const activities = this.#activities
274
- const debug = this.#debug
275
214
 
276
215
  if(!action.tag) {
277
216
  action.tag = this.#tag
@@ -282,47 +221,24 @@ export default class ActionBuilder {
282
221
  // All children in a branch also get the same hooks.
283
222
  const hooks = await this.#getHooks()
284
223
 
285
- return new ActionWrapper({activities,hooks,debug})
286
- }
287
-
288
- /**
289
- * Check if this builder has ActionHooks configured.
290
- *
291
- * @returns {boolean} True if ActionHooks have been configured.
292
- */
293
- get hasActionHooks() {
294
- return this.#actionHooks !== null
224
+ return new ActionWrapper({
225
+ activities: this.#activities,
226
+ debug: this.#debug,
227
+ hooks,
228
+ })
295
229
  }
296
230
 
297
- /**
298
- * Internal method to retrieve or create ActionHooks instance.
299
- * Caches the hooks instance to avoid redundant instantiation.
300
- *
301
- * @returns {Promise<import("./ActionHooks.js").default|undefined>} The ActionHooks instance if configured.
302
- * @private
303
- */
304
231
  async #getHooks() {
305
- if(this.#actionHooks) {
306
- return this.#actionHooks
307
- }
308
-
309
232
  const newHooks = ActionHooks.new
310
233
 
311
234
  const hooks = this.#hooks
312
-
313
- if(hooks) {
314
- this.#actionHooks = await newHooks({hooks}, this.#debug)
315
-
316
- return this.#actionHooks
317
- }
235
+ if(hooks)
236
+ return await newHooks({hooks}, this.#debug)
318
237
 
319
238
  const hooksFile = this.#hooksFile
320
239
  const hooksKind = this.#hooksKind
321
240
 
322
- if(hooksFile && hooksKind) {
323
- this.#actionHooks = await newHooks({hooksFile,hooksKind}, this.#debug)
324
-
325
- return this.#actionHooks
326
- }
241
+ if(hooksFile && hooksKind)
242
+ return await newHooks({hooksFile,hooksKind}, this.#debug)
327
243
  }
328
244
  }
@@ -7,10 +7,11 @@ import {Data, FileObject, Sass, Util, Valid} from "@gesslar/toolkit"
7
7
 
8
8
  /**
9
9
  * @typedef {object} ActionHooksConfig
10
- * @property {string} [actionKind] Action identifier shared between runner and hooks.
11
- * @property {FileObject|string} [hooksFile] File handle or path used to import the hooks module.
12
- * @property {unknown} [hooksObject] Already-instantiated hooks implementation (skips loading).
10
+ * @property {string} actionKind Action identifier shared between runner and hooks.
11
+ * @property {FileObject} hooksFile File handle used to import the hooks module.
12
+ * @property {unknown} [hooks] Already-instantiated hooks implementation (skips loading).
13
13
  * @property {number} [hookTimeout] Timeout applied to hook execution in milliseconds.
14
+ * @property {DebugFn} debug Logger to emit diagnostics.
14
15
  */
15
16
 
16
17
  /**
@@ -26,7 +27,7 @@ export default class ActionHooks {
26
27
  /** @type {FileObject|null} */
27
28
  #hooksFile = null
28
29
  /** @type {HookModule|null} */
29
- #hooksObject = null
30
+ #hooks = null
30
31
  /** @type {string|null} */
31
32
  #actionKind = null
32
33
  /** @type {number} */
@@ -38,15 +39,11 @@ export default class ActionHooks {
38
39
  * Creates a new ActionHook instance.
39
40
  *
40
41
  * @param {ActionHooksConfig} config Configuration values describing how to load the hooks.
41
- * @param {(message: string, level?: number, ...args: Array<unknown>) => void} debug Debug function
42
42
  */
43
- constructor(
44
- {actionKind, hooksFile, hooksObject, hookTimeout = 1_000},
45
- debug,
46
- ) {
43
+ constructor({actionKind, hooksFile, hooks, hookTimeout = 1_000, debug}) {
47
44
  this.#actionKind = actionKind
48
45
  this.#hooksFile = hooksFile
49
- this.#hooksObject = hooksObject
46
+ this.#hooks = hooks
50
47
  this.#timeout = hookTimeout
51
48
  this.#debug = debug
52
49
  }
@@ -54,7 +51,7 @@ export default class ActionHooks {
54
51
  /**
55
52
  * Gets the action identifier.
56
53
  *
57
- * @returns {string|null} Action identifier or instance
54
+ * @returns {string} Action identifier or instance
58
55
  */
59
56
  get actionKind() {
60
57
  return this.#actionKind
@@ -63,7 +60,7 @@ export default class ActionHooks {
63
60
  /**
64
61
  * Gets the hooks file object.
65
62
  *
66
- * @returns {FileObject|null} File object containing hooks
63
+ * @returns {FileObject} File object containing hooks
67
64
  */
68
65
  get hooksFile() {
69
66
  return this.#hooksFile
@@ -72,10 +69,10 @@ export default class ActionHooks {
72
69
  /**
73
70
  * Gets the loaded hooks object.
74
71
  *
75
- * @returns {HookModule|null} Hooks object or null if not loaded
72
+ * @returns {object|null} Hooks object or null if not loaded
76
73
  */
77
74
  get hooks() {
78
- return this.#hooksObject
75
+ return this.#hooks
79
76
  }
80
77
 
81
78
  /**
@@ -108,17 +105,17 @@ export default class ActionHooks {
108
105
  /**
109
106
  * Static factory method to create and initialize a hook manager.
110
107
  * Loads hooks from the specified file and returns an initialized instance.
111
- * If a hooksObject is provided in config, it's used directly; otherwise, hooks are loaded from file.
108
+ * Override loadHooks() in subclasses to customize hook loading logic.
112
109
  *
113
- * @param {ActionHooksConfig} config Configuration object with hooks settings
110
+ * @param {ActionHooksConfig} config Same configuration object as constructor
114
111
  * @param {DebugFn} debug The debug function.
115
- * @returns {Promise<ActionHooks>} Initialized hook manager
112
+ * @returns {Promise<ActionHooks|null>} Initialized hook manager or null if no hooks found
116
113
  */
117
114
  static async new(config, debug) {
118
115
  debug("Creating new HookManager instance with args: %o", 2, config)
119
116
 
120
117
  const instance = new ActionHooks(config, debug)
121
- if(!instance.#hooksObject) {
118
+ if(!instance.#hooks) {
122
119
  const hooksFile = new FileObject(instance.#hooksFile)
123
120
 
124
121
  debug("Loading hooks from %o", 2, hooksFile.uri)
@@ -143,7 +140,7 @@ export default class ActionHooks {
143
140
 
144
141
  debug(hooks.constructor.name, 4)
145
142
 
146
- instance.#hooksObject = hooks
143
+ instance.#hooks = hooks
147
144
  debug("Hooks %o loaded successfully for %o", 2, hooksFile.uri, instance.actionKind)
148
145
 
149
146
  return instance
@@ -154,34 +151,31 @@ export default class ActionHooks {
154
151
  }
155
152
  }
156
153
 
157
- return instance
154
+ return this
158
155
  }
159
156
 
160
157
  /**
161
- * Invoke a dynamically-named hook such as `before$foo` or `after$foo`.
162
- * The hook name is constructed by combining the kind with the activity name.
163
- * Symbols are converted to their description. Non-alphanumeric characters are filtered out.
158
+ * Invoke a dynamically-named hook such as `before$foo`.
164
159
  *
165
160
  * @param {'before'|'after'|'setup'|'cleanup'|string} kind Hook namespace.
166
161
  * @param {string|symbol} activityName Activity identifier.
167
162
  * @param {unknown} context Pipeline context supplied to the hook.
168
163
  * @returns {Promise<void>}
169
- * @throws {Sass} If the hook execution fails or exceeds timeout.
170
164
  */
171
165
  async callHook(kind, activityName, context) {
172
- const debug = this.#debug
173
- const hooks = this.#hooksObject
166
+ try {
167
+ const debug = this.#debug
168
+ const hooks = this.#hooks
174
169
 
175
- if(!hooks)
176
- return
170
+ if(!hooks)
171
+ return
177
172
 
178
- const stringActivityName = Data.isType(activityName, "Symbol")
179
- ? activityName.description()
180
- : activityName
173
+ const stringActivityName = Data.isType("Symbol")
174
+ ? activityName.description()
175
+ : activityName
181
176
 
182
- const hookName = this.#getActivityHookName(kind, stringActivityName)
177
+ const hookName = this.#getActivityHookName(kind, stringActivityName)
183
178
 
184
- try {
185
179
  debug("Looking for hook: %o", 4, hookName)
186
180
 
187
181
  const hook = hooks[hookName]
@@ -195,7 +189,7 @@ export default class ActionHooks {
195
189
  debug("Hook function starting execution: %o", 4, hookName)
196
190
 
197
191
  const duration = (
198
- await Util.time(() => hook.call(this.#hooksObject, context))
192
+ await Util.time(() => hook.call(this.#hooks, context))
199
193
  ).cost
200
194
 
201
195
  debug("Hook function completed successfully: %o, after %oms", 4, hookName, duration)
@@ -214,26 +208,16 @@ export default class ActionHooks {
214
208
  expireAsync
215
209
  ])
216
210
  } catch(error) {
217
- throw Sass.new(`Processing hook ${hookName}`, error)
211
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
218
212
  }
219
213
 
220
214
  debug("We made it throoough the wildernessss", 4)
221
215
 
222
216
  } catch(error) {
223
- throw Sass.new(`Processing hook ${hookName}`, error)
217
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
224
218
  }
225
219
  }
226
220
 
227
- /**
228
- * Transforms an activity name into a hook-compatible name.
229
- * Converts "my activity name" to "myActivityName" and combines with event kind.
230
- * Example: ("before", "my activity") => "before$myActivity"
231
- *
232
- * @param {string} event Hook event type (before, after, etc.)
233
- * @param {string} activityName The raw activity name
234
- * @returns {string} The formatted hook name
235
- * @private
236
- */
237
221
  #getActivityHookName(event, activityName) {
238
222
  const name = activityName
239
223
  .split(" ")