@gesslar/actioneer 0.1.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 ADDED
@@ -0,0 +1,96 @@
1
+ # Actioneer
2
+
3
+ Actioneer is a small, focused Node.js action orchestration library. 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+.
4
+
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
+
7
+ ## Install
8
+
9
+ From npm:
10
+
11
+ ```bash
12
+ npm install @gesslar/actioneer
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ Import the builder and runner, define an action and run it:
18
+
19
+ ```js
20
+ import { ActionBuilder, ActionRunner } from "@gesslar/actioneer"
21
+
22
+ class MyAction {
23
+ setup (builder) {
24
+ builder
25
+ .do("prepare", ctx => { ctx.count = 0 })
26
+ .do("work", ctx => { ctx.count += 1 })
27
+ .do("finalise", ctx => { return ctx.count })
28
+ }
29
+ }
30
+
31
+ const wrapper = new ActionBuilder(new MyAction()).build()
32
+ const runner = new ActionRunner(wrapper)
33
+ const result = await runner.pipe([{}], 4) // run up to 4 contexts concurrently
34
+ console.log(result)
35
+ ```
36
+
37
+ ## Types (TypeScript / VS Code)
38
+
39
+ This package ships basic TypeScript declaration files under `src/types` and exposes them via the package `types` entrypoint. VS Code users will get completions and quick help when consuming the package:
40
+
41
+ ```ts
42
+ import { ActionBuilder, ActionRunner } from "@gesslar/actioneer"
43
+ ```
44
+
45
+ If you'd like more complete typings or additional JSDoc, open an issue or send a PR — contributions welcome.
46
+
47
+ ### Optional TypeScript (local, opt-in)
48
+
49
+ This project intentionally avoids committing TypeScript tool configuration. If you'd like to use TypeScript's checker locally (for editor integration or optional JSDoc checking), you can drop a `tsconfig.json` in your working copy — `tsconfig.json` is already in the repository `.gitignore`, so feel free to typecheck yourselves into oblivion.
50
+
51
+ Two common local options:
52
+
53
+ - Editor/resolve-only (no checking): set `moduleResolution`/`module` and `noEmit` so the editor resolves imports consistently without typechecking.
54
+ - Local JSDoc checks: set `allowJs: true` and `checkJs: true` with `noEmit: true` and `strict: false` to let the TypeScript checker validate JSDoc without enforcing strict typing.
55
+
56
+ Examples of minimal configs and one-liners to run them are in the project discussion; use them locally if you want an optional safety net. The repository will not require or enforce these files.
57
+
58
+ ## Testing
59
+
60
+ Run the small smoke tests with Node's built-in test runner:
61
+
62
+ ```bash
63
+ npm test
64
+ ```
65
+
66
+ 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.
67
+
68
+ ## Publishing
69
+
70
+ This repository is prepared for npm publishing. The package uses ESM and targets Node 20+. The `files` field includes the `src/` folder and types. If you publish, ensure the `version` in `package.json` is updated and you have an npm token configured on the CI runner.
71
+
72
+ A simple publish checklist:
73
+
74
+ - Bump the package version
75
+ - Run `npm run lint` and `npm test`
76
+ - Build/typecheck if you add a build step
77
+ - Tag and push a Git release
78
+ - Run `npm publish --access public`
79
+
80
+ ## Contributing
81
+
82
+ Contributions and issues are welcome. Please open issues for feature requests or bugs. If you're submitting a PR, include tests for new behavior where possible.
83
+
84
+ ## License
85
+
86
+ This project is published under the Unlicense (see `UNLICENSE.txt`).
87
+
88
+ ## Most Portum
89
+
90
+ As this is my repo, I have some opinions I would like to express and be made clear.
91
+
92
+ - We use ESLint around here. I have a very opinionated and hand-rolled `eslint.config.js` that is a requirement to be observed for this repo. Prettier can fuck off. It is the worst tooling I have ever had the misfortune of experiencing (no offence to Prettier) and I will not suffer its poor conventions in my repos in any way except to be denigrated (again, no offence). If you come culting some cargo about that that product, you are reminded that this is released under the Unlicense and are invited to fork off and drown the beautiful code in your poisonous Kool-Aid. Oh, yeah!
93
+ - TypeScript is the devil and is the antithesis of pantser coding. It is discouraged to think that I have gone through rigourous anything that isn't development by sweat. If you're a plotter, I a-plot you for your work, and if you would like to extend this project with your rulers, your abacusi, and your Kanji tattoos that definitely mean exactly what you think they do, I invite you to please do, but in your own repos.
94
+ - Thank you, I love you. BYEBYE!
95
+
96
+ 🤗
package/UNLICENSE.txt ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@gesslar/actioneer",
3
+ "version": "0.1.0",
4
+ "description": "Ready? Set?? ACTION!! pew!pew!pew!pew!",
5
+ "main": "./src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/types/index.d.ts",
10
+ "default": "./src/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "src/",
15
+ "README.md",
16
+ "UNLICENSE.txt"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "lint": "eslint src/",
24
+ "lint:fix": "eslint src/ --fix",
25
+ "submit": "npm publish --access public",
26
+ "update": "npx npm-check-updates -u && npm install",
27
+ "test": "node --test tests/unit/*.test.js",
28
+ "test:unit": "node --test tests/unit/*.test.js",
29
+ "pr": "gt submit --publish --restack --ai"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/gesslar/actioneer.git"
34
+ },
35
+ "keywords": [
36
+ "actioneer",
37
+ "pipeline",
38
+ "workflow",
39
+ "sabrina",
40
+ "salem",
41
+ "package",
42
+ "composition",
43
+ "lasagna"
44
+ ],
45
+ "author": "gesslar",
46
+ "license": "Unlicense",
47
+ "homepage": "https://github.com/gesslar/toolkit#readme",
48
+ "devDependencies": {
49
+ "@stylistic/eslint-plugin": "^5.4.0",
50
+ "@types/node": "^24.6.2",
51
+ "@typescript-eslint/eslint-plugin": "^8.45.0",
52
+ "@typescript-eslint/parser": "^8.45.0",
53
+ "eslint": "^9.36.0",
54
+ "eslint-plugin-jsdoc": "^60.7.1",
55
+ "typescript": "^5.9.3"
56
+ },
57
+ "dependencies": {
58
+ "@gesslar/toolkit": "^0.6.0"
59
+ }
60
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export {default as ActionBuilder} from "./lib/ActionBuilder.js"
2
+ export {default as ActionHooks} from "./lib/ActionHooks.js"
3
+ export {default as ActionRunner} from "./lib/ActionRunner.js"
4
+ export {default as ActionWrapper} from "./lib/ActionWrapper.js"
5
+ export {default as Activity, ACTIVITY} from "./lib/Activity.js"
6
+ export {default as Piper} from "./lib/Piper.js"
@@ -0,0 +1,133 @@
1
+ import {Data, Sass, Valid} from "@gesslar/toolkit"
2
+
3
+ import ActionWrapper from "./ActionWrapper.js"
4
+
5
+ /** @typedef {import("./ActionRunner.js").default} ActionRunner */
6
+
7
+ /**
8
+ * Fluent builder for describing how an action should process the context that
9
+ * flows through the {@link ActionRunner}. Consumers register named activities,
10
+ * optional hook pairs, and nested parallel pipelines before handing the
11
+ * builder back to the runner for execution.
12
+ *
13
+ * Typical usage:
14
+ *
15
+ * ```js
16
+ * const pipeline = new ActionBuilder(myAction)
17
+ * .act("prepare", ACTIVITY.ONCE, ctx => ctx.initialise())
18
+ * .parallel(parallel => parallel
19
+ * .act("step", ACTIVITY.MANY, ctx => ctx.consume())
20
+ * )
21
+ * .act("finalise", ACTIVITY.ONCE, ctx => ctx.complete())
22
+ * .build()
23
+ * ```
24
+ *
25
+ * @class ActionBuilder
26
+ */
27
+ export default class ActionBuilder {
28
+ #action = null
29
+ #activities = new Map([])
30
+ #debug = null
31
+ #tag = null
32
+
33
+ /**
34
+ * Creates a new ActionBuilder instance with the provided action callback.
35
+ *
36
+ * @param {(ctx: unknown) => unknown} action Base action invoked by the runner when a block satisfies the configured structure.
37
+ * @param {{tag?: symbol, debug?: (message: string, level?: number, ...args: Array<unknown>) => void}} [config] Options
38
+ */
39
+ constructor(
40
+ action,
41
+ {tag = action?.tag ?? Symbol(performance.now()), debug = () => {}} = {},
42
+ ) {
43
+ this.#debug = debug
44
+ this.#tag = this.#tag || tag
45
+
46
+ if(action) {
47
+ if(Data.typeOf(action.setup) !== "Function")
48
+ throw Sass.new("Setup must be a function.")
49
+
50
+ this.#action = action
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Register an activity.
56
+ *
57
+ * Overloads:
58
+ * - do(name, op)
59
+ * - do(name, kind, pred, opOrWrapper)
60
+ *
61
+ * @param {string|symbol} name Activity name
62
+ * @param {...unknown} args See overloads
63
+ * @returns {ActionBuilder} The builder instance for chaining
64
+ */
65
+ do(name, ...args) {
66
+ this.#dupeActivityCheck(name)
67
+
68
+ // signatures
69
+ // name, [function] => once
70
+ // name, [number,function,function] => some kind of control operation
71
+ // name, [number,function,ActionBuilder] => some kind of branch
72
+
73
+ const action = this.#action
74
+ const debug = this.#debug
75
+ const activityDefinition = {name, action, debug}
76
+
77
+ if(args.length === 1) {
78
+ const [op, kind] = args
79
+ Valid.type(kind, "Number|undefined")
80
+ Valid.type(op, "Function")
81
+
82
+ Object.assign(activityDefinition, {op, kind})
83
+ } else if(args.length === 3) {
84
+ const [kind, pred, op] = args
85
+
86
+ Valid.type(kind, "Number")
87
+ Valid.type(pred, "Function")
88
+ Valid.type(op, "Function|ActionWrapper")
89
+
90
+ Object.assign(activityDefinition, {kind, pred, op})
91
+ } else {
92
+ throw Sass.new("Invalid number of arguments passed to 'do'")
93
+ }
94
+
95
+ this.#activities.set(name, activityDefinition)
96
+
97
+ return this
98
+ }
99
+
100
+ /**
101
+ * Validates that an activity name has not been reused.
102
+ *
103
+ * @private
104
+ * @param {string | symbol} name Activity identifier.
105
+ */
106
+ #dupeActivityCheck(name) {
107
+ Valid.assert(
108
+ !this.#activities.has(name),
109
+ `Activity '${String(name)}' has already been registered.`,
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Finalises the builder and returns a payload that can be consumed by the
115
+ * runner.
116
+ *
117
+ * @returns {{action: (context: unknown) => unknown, build: ActionBuilder}} Payload consumed by the {@link ActionRunner} constructor.
118
+ */
119
+ build() {
120
+ const action = this.#action
121
+
122
+ if(!action.tag) {
123
+ action.tag = this.#tag
124
+
125
+ action.setup.call(action, this)
126
+ }
127
+
128
+ return new ActionWrapper({
129
+ activities: this.#activities,
130
+ debug: this.#debug,
131
+ })
132
+ }
133
+ }
@@ -0,0 +1,192 @@
1
+ import {setTimeout as timeout} from "timers/promises"
2
+ import {FileObject, Sass, Util, Valid} from "@gesslar/toolkit"
3
+
4
+ /**
5
+ * Generic base class for managing hooks with configurable event types.
6
+ * Provides common functionality for hook registration, execution, and lifecycle management.
7
+ * Designed to be extended by specific implementations.
8
+ */
9
+ export default class ActionHooks {
10
+ #hooksFile = null
11
+ #hooks = null
12
+ #actionKind = null
13
+ #timeout = 1000 // Default 1 second timeout
14
+ #debug = null
15
+
16
+ /**
17
+ * Creates a new ActionHook instance.
18
+ *
19
+ * @param {object} config - Configuration object
20
+ * @param {string} config.actionKind - Action identifier
21
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
22
+ * @param {number} [config.hookTimeout] - Hook execution timeout in milliseconds
23
+ * @param {unknown} [config.hooks] - The hooks object
24
+ * @param {(message: string, level?: number, ...args: Array<unknown>) => void} config.debug - Debug function from Glog.
25
+ */
26
+ constructor({actionKind, hooksFile, hooks, hookTimeout = 1000, debug}) {
27
+ this.#actionKind = actionKind
28
+ this.#hooksFile = hooksFile
29
+ this.#hooks = hooks
30
+ this.#timeout = hookTimeout
31
+ this.#debug = debug
32
+ }
33
+
34
+ /**
35
+ * Gets the action identifier.
36
+ *
37
+ * @returns {string} Action identifier or instance
38
+ */
39
+ get actionKind() {
40
+ return this.#actionKind
41
+ }
42
+
43
+ /**
44
+ * Gets the hooks file object.
45
+ *
46
+ * @returns {FileObject} File object containing hooks
47
+ */
48
+ get hooksFile() {
49
+ return this.#hooksFile
50
+ }
51
+
52
+ /**
53
+ * Gets the loaded hooks object.
54
+ *
55
+ * @returns {object|null} Hooks object or null if not loaded
56
+ */
57
+ get hooks() {
58
+ return this.#hooks
59
+ }
60
+
61
+ /**
62
+ * Gets the hook execution timeout in milliseconds.
63
+ *
64
+ * @returns {number} Timeout in milliseconds
65
+ */
66
+ get timeout() {
67
+ return this.#timeout
68
+ }
69
+
70
+ /**
71
+ * Gets the setup hook function if available.
72
+ *
73
+ * @returns {(args: object) => unknown|null} Setup hook function or null
74
+ */
75
+ get setup() {
76
+ return this.hooks?.setup || null
77
+ }
78
+
79
+ /**
80
+ * Gets the cleanup hook function if available.
81
+ *
82
+ * @returns {(args: object) => unknown|null} Cleanup hook function or null
83
+ */
84
+ get cleanup() {
85
+ return this.hooks?.cleanup || null
86
+ }
87
+
88
+ /**
89
+ * Static factory method to create and initialize a hook manager.
90
+ * Loads hooks from the specified file and returns an initialized instance.
91
+ * Override loadHooks() in subclasses to customize hook loading logic.
92
+ *
93
+ * @param {object} config - Same configuration object as constructor
94
+ * @param {string|object} config.actionKind - Action identifier or instance
95
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
96
+ * @param {number} [config.timeOut] - Hook execution timeout in milliseconds
97
+ * @param {(message: string, level?: number, ...args: Array<unknown>) => void} debug - The debug function.
98
+ * @returns {Promise<ActionHooks|null>} Initialized hook manager or null if no hooks found
99
+ */
100
+ static async new(config, debug) {
101
+ debug("Creating new HookManager instance with args: %o", 2, config)
102
+
103
+ const instance = new this(config, debug)
104
+ const hooksFile = instance.hooksFile
105
+
106
+ debug("Loading hooks from %o", 2, hooksFile.uri)
107
+
108
+ debug("Checking hooks file exists: %o", 2, hooksFile.uri)
109
+ if(!await hooksFile.exists)
110
+ throw Sass.new(`No such hooks file, ${hooksFile.uri}`)
111
+
112
+ try {
113
+ const hooksImport = await hooksFile.import()
114
+
115
+ if(!hooksImport)
116
+ return null
117
+
118
+ debug("Hooks file imported successfully as a module", 2)
119
+
120
+ const actionKind = instance.actionKind
121
+ if(!hooksImport[actionKind])
122
+ return null
123
+
124
+ const hooks = new hooksImport[actionKind]({debug})
125
+
126
+ debug(hooks.constructor.name, 4)
127
+
128
+ // Attach common properties to hooks
129
+ instance.#hooks = hooks
130
+
131
+ debug("Hooks %o loaded successfully for %o", 2, hooksFile.uri, instance.actionKind)
132
+
133
+ return instance
134
+ } catch(error) {
135
+ debug("Failed to load hooks %o: %o", 1, hooksFile.uri, error.message)
136
+
137
+ return null
138
+ }
139
+ }
140
+
141
+ async callHook(kind, activityName, context) {
142
+ try {
143
+ const debug = this.#debug
144
+ const hooks = this.#hooks
145
+
146
+ if(!hooks)
147
+ return
148
+
149
+ const hookName = `${kind}$${activityName}`
150
+
151
+ debug("Looking for hook: %o", 4, hookName)
152
+
153
+ const hook = hooks[hookName]
154
+ if(!hook)
155
+ return
156
+
157
+ debug("Triggering hook: %o", 4, hookName)
158
+ Valid.type(hook, "Function", `Hook "${hookName}" is not a function`)
159
+
160
+ const hookFunction = async() => {
161
+ debug("Hook function starting execution: %o", 4, hookName)
162
+
163
+ const duration = (
164
+ await Util.time(() => hook.call(this.#hooks, context))
165
+ ).cost
166
+
167
+ debug("Hook function completed successfully: %o, after %oms", 4, hookName, duration)
168
+ }
169
+
170
+ const hookTimeout = this.timeout
171
+ const expireAsync = (async() => {
172
+ await timeout(hookTimeout)
173
+ throw Sass.new(`Hook ${hookName} execution exceeded timeout of ${hookTimeout}ms`)
174
+ })()
175
+
176
+ try {
177
+ debug("Starting Promise race for hook: %o", 4, hookName)
178
+ await Util.race([
179
+ hookFunction(),
180
+ expireAsync
181
+ ])
182
+ } catch(error) {
183
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
184
+ }
185
+
186
+ debug("We made it throoough the wildernessss", 4)
187
+
188
+ } catch(error) {
189
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,166 @@
1
+ import {FileObject, Sass, Valid} from "@gesslar/toolkit"
2
+
3
+ import ActionBuilder from "./ActionBuilder.js"
4
+ import {ACTIVITY} from "./Activity.js"
5
+ import Piper from "./Piper.js"
6
+ /**
7
+ * Orchestrates execution of {@link ActionBuilder}-produced pipelines.
8
+ *
9
+ * Activities run in insertion order, with support for once-off work, repeated
10
+ * loops, and nested parallel pipelines. Each activity receives a mutable
11
+ * context object under `result.value` that can be replaced or enriched.
12
+ */
13
+ export default class ActionRunner extends Piper {
14
+ #actionWrapper = null
15
+ #debug = null
16
+ #hooksPath = null
17
+ #hooksClassName = null
18
+ #hooks = null
19
+ #tag = null
20
+
21
+ constructor(wrappedAction, {hooks,debug=(() => {})} = {}) {
22
+ super({debug})
23
+
24
+ this.#tag = Symbol(performance.now())
25
+
26
+ this.#debug = debug
27
+
28
+ if(!wrappedAction)
29
+ return this
30
+
31
+ if(wrappedAction?.constructor?.name !== "ActionWrapper")
32
+ throw Sass.new("ActionRunner takes an instance of an ActionWrapper")
33
+
34
+ this.#actionWrapper = wrappedAction
35
+
36
+ if(hooks)
37
+ this.#hooks = hooks
38
+ else
39
+ this.addSetup(this.#loadHooks)
40
+
41
+ this.addStep(this.run)
42
+ }
43
+
44
+ /**
45
+ * Executes the configured action pipeline.
46
+ *
47
+ * @param {unknown} context - Seed value passed to the first activity.
48
+ * @param {boolean} asIs - When true, do not wrap context in {value} (internal nested runners)
49
+ * @returns {Promise<unknown>} Final value produced by the pipeline, or null when a parallel stage reports failures.
50
+ * @throws {Sass} When no activities are registered or required parallel builders are missing.
51
+ */
52
+ async run(context, asIs=false) {
53
+ this.#debug(this.#tag.description)
54
+ const actionWrapper = this.#actionWrapper
55
+ const activities = actionWrapper.activities
56
+
57
+ if(!asIs)
58
+ context = {value: context}
59
+
60
+ context
61
+
62
+ for(const activity of activities) {
63
+ activity.setActionHooks(this.#hooks)
64
+
65
+ const kind = activity.kind
66
+
67
+ // If we have no kind, then it's just a once.
68
+ // Get it over and done with!
69
+ if(!kind) {
70
+ context = await this.#executeActivity(activity, context)
71
+ } else {
72
+ const {WHILE,UNTIL} = ACTIVITY
73
+
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
90
+
91
+ context = await this.#executeActivity(activity,context)
92
+ context
93
+
94
+ if(kindUntil)
95
+ if(!await this.#predicateCheck(activity,pred,context))
96
+ break
97
+ }
98
+ } else {
99
+ context = await this.#executeActivity(activity, context)
100
+ context
101
+ }
102
+ }
103
+
104
+ }
105
+
106
+ return context
107
+ }
108
+
109
+ async #executeActivity(activity, context) {
110
+ // What kind of op are we looking at? Is it a function?
111
+ // Or a class instance of type ActionWrapper?
112
+ const opKind = activity.opKind
113
+ if(opKind === "ActionWrapper") {
114
+ const runner = new this.constructor(activity.op, {
115
+ debug: this.#debug,
116
+ hooks: this.#hooks,
117
+ })
118
+ .setHooks(this.#hooksPath, this.#hooksClassName)
119
+
120
+ return await runner.run(context, true)
121
+ } else if(opKind === "Function") {
122
+ return (await activity.run(context)).activityResult
123
+ }
124
+
125
+ throw Sass.new("We buy Functions and ActionWrappers. Only. Not whatever that was.")
126
+ }
127
+
128
+ async #predicateCheck(activity,predicate,context) {
129
+ Valid.type(predicate, "Function")
130
+
131
+ return !!(await predicate.call(activity.action, context))
132
+ }
133
+
134
+ toString() {
135
+ return `[object ${this.constructor.name}]`
136
+ }
137
+
138
+ setHooks(hooksPath, className) {
139
+ this.#hooksPath = hooksPath
140
+ this.#hooksClassName = className
141
+
142
+ this.addSetup(() => this.#loadHooks())
143
+
144
+ return this
145
+ }
146
+
147
+ async #loadHooks() {
148
+ if(!this.#hooksPath)
149
+ return null
150
+
151
+ const file = new FileObject(this.#hooksPath)
152
+ if(!await file.exists)
153
+ throw Sass.new(`File '${file.uri} does not exist.`)
154
+
155
+ const module = await file.import()
156
+ const hooksClassName = this.#hooksClassName
157
+
158
+ Valid.type(module[hooksClassName], "Function")
159
+
160
+ const loaded = new module[hooksClassName]({
161
+ debug: this.#debug
162
+ })
163
+
164
+ this.#hooks = loaded
165
+ }
166
+ }
@@ -0,0 +1,28 @@
1
+ import Activity from "./Activity.js"
2
+
3
+ export default class ActionWrapper {
4
+ #activities = new Map()
5
+ #debug = null
6
+
7
+ constructor({activities,debug}) {
8
+ this.#debug = debug
9
+ this.#activities = activities
10
+ this.#debug(
11
+ "Instantiating ActionWrapper with %o activities.",
12
+ 2,
13
+ activities.size,
14
+ )
15
+ }
16
+
17
+ *#_activities() {
18
+ for(const [_,activity] of this.#activities) {
19
+ const result = new Activity(activity)
20
+
21
+ yield result
22
+ }
23
+ }
24
+
25
+ get activities() {
26
+ return this.#_activities()
27
+ }
28
+ }
@@ -0,0 +1,126 @@
1
+ import {Data} from "@gesslar/toolkit"
2
+
3
+ /**
4
+ * Activity bit flags recognised by the builder. The flag decides
5
+ * loop semantics for an activity.
6
+ *
7
+ * @readonly
8
+ * @enum {number}
9
+ */
10
+ export const ACTIVITY = Object.freeze({
11
+ WHILE: 1<<1,
12
+ UNTIL: 1<<2,
13
+ })
14
+
15
+ export default class Activity {
16
+ #action = null
17
+ #name = null
18
+ #op = null
19
+ #kind = null
20
+ #pred = null
21
+ #hooks = null
22
+
23
+ /**
24
+ * Construct an Activity definition wrapper.
25
+ *
26
+ * @param {{action: unknown, name: string, op: (context: unknown) => unknown|Promise<unknown>|unknown, kind?: number, pred?: (context: unknown) => boolean|Promise<boolean>}} init - Initial properties describing the activity operation, loop semantics, and predicate
27
+ */
28
+ constructor({action,name,op,kind,pred}) {
29
+ this.#name = name
30
+ this.#op = op
31
+ this.#kind = kind
32
+ this.#action = action
33
+ this.#pred = pred
34
+ }
35
+
36
+ /**
37
+ * The activity name.
38
+ *
39
+ * @returns {string} - Activity identifier
40
+ */
41
+ get name() {
42
+ return this.#name
43
+ }
44
+
45
+ /**
46
+ * Bitflag kind for loop semantics.
47
+ *
48
+ * @returns {number|null} - Combined flags (e.g., WHILE or UNTIL)
49
+ */
50
+ get kind() {
51
+ return this.#kind
52
+ }
53
+
54
+ /**
55
+ * The predicate function for WHILE/UNTIL flows.
56
+ *
57
+ * @returns {(context: unknown) => boolean|Promise<boolean>|undefined} - Predicate used to continue/stop loops
58
+ */
59
+ get pred() {
60
+ return this.#pred
61
+ }
62
+
63
+ /**
64
+ * The operator kind name (Function or ActionWrapper).
65
+ *
66
+ * @returns {string} - Kind name extracted via Data.typeOf
67
+ */
68
+ get opKind() {
69
+ return Data.typeOf(this.#op)
70
+ }
71
+
72
+ /**
73
+ * The operator to execute (function or nested wrapper).
74
+ *
75
+ * @returns {unknown} - Activity operation
76
+ */
77
+ get op() {
78
+ return this.#op
79
+ }
80
+
81
+ /**
82
+ * The action instance this activity belongs to.
83
+ *
84
+ * @returns {unknown} - Bound action instance
85
+ */
86
+ get action() {
87
+ return this.#action
88
+ }
89
+
90
+ /**
91
+ * Execute the activity with before/after hooks.
92
+ *
93
+ * @param {unknown} context - Mutable context flowing through the pipeline
94
+ * @returns {Promise<{activityResult: unknown}>} - Activity result wrapper with new context
95
+ */
96
+ async run(context) {
97
+ const hooks = this.#hooks
98
+
99
+ // before hook
100
+ const before = hooks?.[`before$${this.#name}`]
101
+ if(Data.typeOf(before) === "Function")
102
+ await before.call(hooks,context)
103
+
104
+ const result = await this.#op.call(this.#action,context)
105
+
106
+ // after hook
107
+ const after = hooks?.[`after$${this.#name}`]
108
+ if(Data.typeOf(after) === "Function")
109
+ await after.call(hooks,context)
110
+
111
+ return {activityResult: result}
112
+ }
113
+
114
+ /**
115
+ * Attach hooks to this activity instance.
116
+ *
117
+ * @param {unknown} hooks - Hooks instance with optional before$/after$ methods
118
+ * @returns {this} - This activity for chaining
119
+ */
120
+ setActionHooks(hooks) {
121
+ if(hooks)
122
+ this.#hooks = hooks
123
+
124
+ return this
125
+ }
126
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Generic Pipeline - Process items through a series of steps with concurrency control
3
+ *
4
+ * This abstraction handles:
5
+ * - Concurrent processing with configurable limits
6
+ * - Pipeline of processing steps
7
+ * - Result categorization (success/warning/error)
8
+ * - Setup/cleanup lifecycle hooks
9
+ * - Error handling and reporting
10
+ */
11
+
12
+ import {Sass, Tantrum, Util} from "@gesslar/toolkit"
13
+
14
+ export default class Piper {
15
+ #debug
16
+
17
+ #lifeCycle = new Map([
18
+ ["setup", new Set()],
19
+ ["process", new Set()],
20
+ ["teardown", new Set()]
21
+ ])
22
+
23
+ /**
24
+ * Create a Piper instance.
25
+ *
26
+ * @param {{debug?: (message: string, level?: number, ...args: Array<unknown>) => void}} [config] Optional configuration with debug function
27
+ */
28
+ constructor({debug = (() => {})} = {}) {
29
+ this.#debug = debug
30
+ }
31
+
32
+ /**
33
+ * Add a processing step to the pipeline
34
+ *
35
+ * @param {(context: unknown) => Promise<unknown>|unknown} fn Function that processes an item
36
+ * @param {{name?: string, required?: boolean}} [options] Step options
37
+ * @param {unknown} [newThis] Optional this binding
38
+ * @returns {Piper} The pipeline instance (for chaining)
39
+ */
40
+ addStep(fn, options = {}, newThis) {
41
+ this.#lifeCycle.get("process").add({
42
+ fn: fn.bind(newThis ?? this),
43
+ name: options.name || `Step ${this.#lifeCycle.get("process").size + 1}`,
44
+ required: !!options.required, // Default to required
45
+ ...options
46
+ })
47
+
48
+ return this
49
+ }
50
+
51
+ /**
52
+ * Add setup hook that runs before processing starts.
53
+ *
54
+ * @param {() => Promise<void>|void} fn - Setup function executed before processing
55
+ * @param {unknown} [thisArg] - Optional this binding for the setup function
56
+ * @returns {Piper} - The pipeline instance
57
+ */
58
+ addSetup(fn, thisArg) {
59
+ this.#lifeCycle.get("setup").add(fn.bind(thisArg ?? this))
60
+
61
+ return this
62
+ }
63
+
64
+ /**
65
+ * Add cleanup hook that runs after processing completes
66
+ *
67
+ * @param {() => Promise<void>|void} fn - Cleanup function executed after processing
68
+ * @param {unknown} [thisArg] - Optional this binding for the cleanup function
69
+ * @returns {Piper} - The pipeline instance
70
+ */
71
+ addCleanup(fn, thisArg) {
72
+ this.#lifeCycle.get("teardown").add(fn.bind(thisArg ?? this))
73
+
74
+ return this
75
+ }
76
+
77
+ /**
78
+ * Process items through the pipeline with concurrency control
79
+ *
80
+ * @param {Array<unknown>|unknown} items - Items to process
81
+ * @param {number} maxConcurrent - Maximum concurrent items to process
82
+ * @returns {Promise<Array<unknown>>} - Collected results from steps
83
+ */
84
+ async pipe(items, maxConcurrent = 10) {
85
+ items = Array.isArray(items)
86
+ ? items
87
+ : [items]
88
+
89
+ let itemIndex = 0
90
+ const allResults = []
91
+
92
+ const processWorker = async() => {
93
+ while(true) {
94
+ const currentIndex = itemIndex++
95
+ if(currentIndex >= items.length)
96
+ break
97
+
98
+ const item = items[currentIndex]
99
+ try {
100
+ const result = await this.#processItem(item)
101
+ allResults.push(result)
102
+ } catch(error) {
103
+ throw Sass.new("Processing pipeline item.", error)
104
+ }
105
+ }
106
+ }
107
+
108
+ const setupResult = await Util.settleAll(
109
+ [...this.#lifeCycle.get("setup")
110
+
111
+ ].map(e => e()))
112
+ this.#processResult("Setting up the pipeline.", setupResult)
113
+
114
+ // Start workers up to maxConcurrent limit
115
+ const workers = []
116
+ const workerCount = Math.min(maxConcurrent, items.length)
117
+
118
+ for(let i = 0; i < workerCount; i++)
119
+ workers.push(processWorker())
120
+
121
+ // Wait for all workers to complete
122
+ const processResult = await Util.settleAll(workers)
123
+ this.#processResult("Processing pipeline.", processResult)
124
+
125
+ // Run cleanup hooks
126
+ const teardownResult = await Util.settleAll(
127
+ [...this.#lifeCycle.get("teardown")
128
+
129
+ ].map(e => e()))
130
+ this.#processResult("Tearing down the pipeline.", teardownResult)
131
+
132
+ return allResults
133
+ }
134
+
135
+ /**
136
+ * Validate settleAll results and throw a combined error when rejected.
137
+ *
138
+ * @param {string} message Context message
139
+ * @param {Array<unknown>} settled Results from settleAll
140
+ * @private
141
+ */
142
+ #processResult(message, settled) {
143
+ if(settled.some(r => r.status === "rejected"))
144
+ throw Tantrum.new(
145
+ message,
146
+ settled.filter(r => r.status==="rejected").map(r => r.reason)
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Process a single item through all pipeline steps
152
+ *
153
+ * @param {unknown} item The item to process
154
+ * @returns {Promise<unknown>} Result from the final step
155
+ * @private
156
+ */
157
+ async #processItem(item) {
158
+ try {
159
+ // Execute each step in sequence
160
+ let result = item
161
+
162
+ for(const step of this.#lifeCycle.get("process")) {
163
+ if(typeof this.#debug === "function")
164
+ this.#debug("Executing step: %o", 4, step.name)
165
+
166
+ result = await step.fn(result) ?? result
167
+ }
168
+
169
+ return result
170
+ } catch(error) {
171
+ throw Sass.new("Processing an item.", error)
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,32 @@
1
+ import type ActionWrapper from './ActionWrapper'
2
+
3
+ declare type DebugFn = (message: string, level?: number, ...args: Array<unknown>) => void
4
+ import type { ActionFunction } from './ActionWrapper'
5
+
6
+ declare class ActionBuilder {
7
+ /**
8
+ * @param action An object with a `setup(builder)` method or undefined for an empty builder
9
+ */
10
+ constructor(
11
+ action?: { setup?: (builder: ActionBuilder) => void } | unknown,
12
+ config?: { tag?: symbol, debug?: DebugFn }
13
+ )
14
+
15
+ // Overload: once-off activity
16
+ do(name: string | symbol, op: ActionFunction): this
17
+
18
+ // Overload: controlled activity with kind, predicate and op (function or nested ActionWrapper)
19
+ do(
20
+ name: string | symbol,
21
+ kind: number,
22
+ pred: (context: unknown) => Promise<boolean>,
23
+ op: ActionFunction | ActionWrapper
24
+ ): this
25
+
26
+ // Generic fallback
27
+ do(name: string | symbol, ...args: Array<unknown>): this
28
+
29
+ build(): ActionWrapper
30
+ }
31
+
32
+ export default ActionBuilder
@@ -0,0 +1,24 @@
1
+ declare type DebugFn = (message: string, level?: number, ...args: Array<unknown>) => void
2
+
3
+ declare class ActionHooks {
4
+ constructor(config: {
5
+ actionKind: unknown,
6
+ hooksFile: unknown,
7
+ hooks?: unknown,
8
+ hookTimeout?: number,
9
+ debug?: DebugFn
10
+ })
11
+ static new(
12
+ config: { actionKind: unknown, hooksFile: unknown, timeOut?: number },
13
+ debug?: DebugFn
14
+ ): Promise<ActionHooks | null>
15
+ callHook(kind: string, activityName: string, context: unknown): Promise<void>
16
+ get actionKind(): unknown
17
+ get hooksFile(): unknown
18
+ get hooks(): unknown | null
19
+ get timeout(): number
20
+ get setup(): ((args: object) => unknown) | null
21
+ get cleanup(): ((args: object) => unknown) | null
22
+ }
23
+
24
+ export default ActionHooks
@@ -0,0 +1,18 @@
1
+ import Piper from './Piper'
2
+
3
+ declare type DebugFn = (message: string, level?: number, ...args: Array<unknown>) => void
4
+
5
+ declare class ActionRunner extends Piper {
6
+ constructor(wrappedAction?: unknown, config?: { hooks?: unknown, debug?: DebugFn })
7
+
8
+ /**
9
+ * Execute the pipeline. When asIs is true, the context is not wrapped in {value}.
10
+ */
11
+ run(context: unknown, asIs?: boolean): Promise<unknown>
12
+
13
+ setHooks(hooksPath: string, className: string): this
14
+
15
+ toString(): string
16
+ }
17
+
18
+ export default ActionRunner
@@ -0,0 +1,27 @@
1
+ import type Activity from './Activity'
2
+
3
+ /** Operation function signature used by activities */
4
+ export type ActionFunction = (context: unknown) => unknown | Promise<unknown>
5
+
6
+ import type { ActionFunction as _AF } from './ActionBuilder'
7
+
8
+ declare type DebugFn = (message: string, level?: number, ...args: Array<unknown>) => void
9
+
10
+ declare class ActionWrapper {
11
+ constructor(config: {
12
+ activities: Map<unknown, {
13
+ name: string,
14
+ /** operation: either a function(context) or a nested ActionWrapper */
15
+ op: ActionFunction | ActionWrapper,
16
+ kind?: number,
17
+ /** predicate used for WHILE/UNTIL, returns Promise<boolean> */
18
+ pred?: (context: unknown) => Promise<boolean>,
19
+ action?: unknown,
20
+ debug?: DebugFn
21
+ }>,
22
+ debug?: DebugFn
23
+ })
24
+ get activities(): IterableIterator<Activity>
25
+ }
26
+
27
+ export default ActionWrapper
@@ -0,0 +1,21 @@
1
+ export const ACTIVITY: { WHILE: number; UNTIL: number }
2
+
3
+ declare class Activity {
4
+ constructor(init: {
5
+ action: unknown,
6
+ name: string,
7
+ op: (context: unknown) => unknown | Promise<unknown> | unknown,
8
+ kind?: number,
9
+ pred?: (context: unknown) => Promise<boolean>
10
+ })
11
+ get name(): string
12
+ get kind(): number | null
13
+ get pred(): ((context: unknown) => boolean | Promise<boolean>) | undefined
14
+ get opKind(): string
15
+ get op(): unknown
16
+ get action(): unknown
17
+ run(context: unknown): Promise<{ activityResult: unknown }>
18
+ setActionHooks(hooks: unknown): this
19
+ }
20
+
21
+ export default Activity
@@ -0,0 +1,15 @@
1
+ declare type DebugFn = (message: string, level?: number, ...args: Array<unknown>) => void
2
+
3
+ declare class Piper {
4
+ constructor(config?: { debug?: DebugFn })
5
+ addStep(
6
+ fn: (context: unknown) => Promise<unknown> | unknown,
7
+ options?: { name?: string, required?: boolean },
8
+ newThis?: unknown
9
+ ): this
10
+ addSetup(fn: () => Promise<void> | void, thisArg?: unknown): this
11
+ addCleanup(fn: () => Promise<void> | void, thisArg?: unknown): this
12
+ pipe(items: Array<unknown> | unknown, maxConcurrent?: number): Promise<Array<unknown>>
13
+ }
14
+
15
+ export default Piper
@@ -0,0 +1,6 @@
1
+ export {default as ActionBuilder} from './ActionBuilder'
2
+ export {default as ActionHooks} from './ActionHooks'
3
+ export {default as ActionRunner} from './ActionRunner'
4
+ export {default as ActionWrapper} from './ActionWrapper'
5
+ export {default as Activity, ACTIVITY} from './Activity'
6
+ export {default as Piper} from './Piper'