@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 +96 -0
- package/UNLICENSE.txt +24 -0
- package/package.json +60 -0
- package/src/index.js +6 -0
- package/src/lib/ActionBuilder.js +133 -0
- package/src/lib/ActionHooks.js +192 -0
- package/src/lib/ActionRunner.js +166 -0
- package/src/lib/ActionWrapper.js +28 -0
- package/src/lib/Activity.js +126 -0
- package/src/lib/Piper.js +174 -0
- package/src/types/ActionBuilder.d.ts +32 -0
- package/src/types/ActionHooks.d.ts +24 -0
- package/src/types/ActionRunner.d.ts +18 -0
- package/src/types/ActionWrapper.d.ts +27 -0
- package/src/types/Activity.d.ts +21 -0
- package/src/types/Piper.d.ts +15 -0
- package/src/types/index.d.ts +6 -0
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
|
+
}
|
package/src/lib/Piper.js
ADDED
|
@@ -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'
|