@gesslar/toolkit 0.4.0 → 0.5.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/src/lib/Glog.js CHANGED
@@ -1,7 +1,8 @@
1
- import Data from "./Data.js"
2
- import Util from "./Util.js"
3
1
  import c from "@gesslar/colours"
2
+
3
+ import Data from "./Data.js"
4
4
  import Term from "./Term.js"
5
+ import Util from "./Util.js"
5
6
  // ErrorStackParser will be dynamically imported when needed
6
7
 
7
8
  /**
@@ -0,0 +1,194 @@
1
+ import {setTimeout as timeout} from "timers/promises"
2
+
3
+ import FileObject from "./FileObject.js"
4
+ import Sass from "./Sass.js"
5
+ import Util from "./Util.js"
6
+ import Valid from "./Valid.js"
7
+
8
+ /**
9
+ * Generic base class for managing hooks with configurable event types.
10
+ * Provides common functionality for hook registration, execution, and lifecycle management.
11
+ * Designed to be extended by specific implementations.
12
+ */
13
+ export default class Hooks {
14
+ #hooksFile = null
15
+ #hooks = null
16
+ #actionKind = null
17
+ #timeout = 1000 // Default 1 second timeout
18
+ #debug = null
19
+
20
+ /**
21
+ * Creates a new BaseHookManager instance.
22
+ *
23
+ * @param {object} config - Configuration object
24
+ * @param {string} config.actionKind - Action identifier
25
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
26
+ * @param {number} [config.hookTimeout] - Hook execution timeout in milliseconds
27
+ * @param {unknown} [config.hooks] - The hooks object
28
+ * @param {import('../types.js').DebugFunction} debug - Debug function from Glog.
29
+ */
30
+ constructor({actionKind, hooksFile, hooks, hookTimeout = 1000}, debug) {
31
+ this.#actionKind = actionKind
32
+ this.#hooksFile = hooksFile
33
+ this.#hooks = hooks
34
+ this.#timeout = hookTimeout
35
+ this.#debug = debug
36
+ }
37
+
38
+ /**
39
+ * Gets the action identifier.
40
+ *
41
+ * @returns {string} Action identifier or instance
42
+ */
43
+ get actionKind() {
44
+ return this.#actionKind
45
+ }
46
+
47
+ /**
48
+ * Gets the hooks file object.
49
+ *
50
+ * @returns {FileObject} File object containing hooks
51
+ */
52
+ get hooksFile() {
53
+ return this.#hooksFile
54
+ }
55
+
56
+ /**
57
+ * Gets the loaded hooks object.
58
+ *
59
+ * @returns {object|null} Hooks object or null if not loaded
60
+ */
61
+ get hooks() {
62
+ return this.#hooks
63
+ }
64
+
65
+ /**
66
+ * Gets the hook execution timeout in milliseconds.
67
+ *
68
+ * @returns {number} Timeout in milliseconds
69
+ */
70
+ get timeout() {
71
+ return this.#timeout
72
+ }
73
+
74
+ /**
75
+ * Gets the setup hook function if available.
76
+ *
77
+ * @returns {(args: object) => unknown|null} Setup hook function or null
78
+ */
79
+ get setup() {
80
+ return this.hooks?.setup || null
81
+ }
82
+
83
+ /**
84
+ * Gets the cleanup hook function if available.
85
+ *
86
+ * @returns {(args: object) => unknown|null} Cleanup hook function or null
87
+ */
88
+ get cleanup() {
89
+ return this.hooks?.cleanup || null
90
+ }
91
+
92
+ /**
93
+ * Static factory method to create and initialize a hook manager.
94
+ * Loads hooks from the specified file and returns an initialized instance.
95
+ * Override loadHooks() in subclasses to customize hook loading logic.
96
+ *
97
+ * @param {object} config - Same configuration object as constructor
98
+ * @param {string|object} config.actionKind - Action identifier or instance
99
+ * @param {FileObject} config.hooksFile - File object containing hooks with uri property
100
+ * @param {number} [config.timeOut] - Hook execution timeout in milliseconds
101
+ * @param {import('../types.js').DebugFunction} debug - The debug function.
102
+ * @returns {Promise<Hooks|null>} Initialized hook manager or null if no hooks found
103
+ */
104
+ static async new(config, debug) {
105
+ debug("Creating new HookManager instance with args: %o", 2, config)
106
+
107
+ const instance = new this(config, debug)
108
+ const hooksFile = instance.hooksFile
109
+
110
+ debug("Loading hooks from %o", 2, hooksFile.uri)
111
+
112
+ debug("Checking hooks file exists: %o", 2, hooksFile.uri)
113
+ if(!await hooksFile.exists)
114
+ throw Sass.new(`No such hooks file, ${hooksFile.uri}`)
115
+
116
+ try {
117
+ const hooksImport = await hooksFile.import()
118
+
119
+ if(!hooksImport)
120
+ return null
121
+
122
+ debug("Hooks file imported successfully as a module", 2)
123
+
124
+ const actionKind = instance.actionKind
125
+ if(!hooksImport[actionKind])
126
+ return null
127
+
128
+ const hooks = new hooksImport[actionKind]({debug})
129
+
130
+ debug(hooks.constructor.name, 4)
131
+
132
+ // Attach common properties to hooks
133
+ instance.#hooks = hooks
134
+
135
+ debug("Hooks %o loaded successfully for %o", 2, hooksFile.uri, instance.actionKind)
136
+
137
+ return instance
138
+ } catch(error) {
139
+ debug("Failed to load hooks %o: %o", 1, hooksFile.uri, error.message)
140
+
141
+ return null
142
+ }
143
+ }
144
+
145
+ async callHook(kind, activityName, context) {
146
+ try {
147
+ const debug = this.#debug
148
+ const hooks = this.#hooks
149
+
150
+ if(!hooks)
151
+ return
152
+
153
+ const hookName = `${kind}$${activityName}`
154
+
155
+ debug("Looking for hook: %o", 4, hookName)
156
+
157
+ const hook = hooks[hookName]
158
+ if(!hook)
159
+ return
160
+
161
+ debug("Triggering hook: %o", 4, hookName)
162
+ Valid.type(hook, "Function", `Hook "${hookName}" is not a function`)
163
+
164
+ const hookFunction = async() => {
165
+ debug("Hook function starting execution: %o", 4, hookName)
166
+
167
+ const duration = (await Util.time(() => hook.call(this.#hooks, context))).cost
168
+
169
+ debug("Hook function completed successfully: %o, after %oms", 4, hookName, duration)
170
+ }
171
+
172
+ const hookTimeout = this.timeout
173
+ const expireAsync = (async() => {
174
+ await timeout(hookTimeout)
175
+ throw Sass.new(`Hook ${hookName} execution exceeded timeout of ${hookTimeout}ms`)
176
+ })()
177
+
178
+ try {
179
+ debug("Starting Promise race for hook: %o", 4, hookName)
180
+ await Util.race([
181
+ hookFunction(),
182
+ expireAsync
183
+ ])
184
+ } catch(error) {
185
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
186
+ }
187
+
188
+ debug("We made it throoough the wildernessss", 4)
189
+
190
+ } catch(error) {
191
+ throw Sass.new(`Processing hook ${kind}$${activityName}`, error)
192
+ }
193
+ }
194
+ }
package/src/lib/Piper.js CHANGED
@@ -9,31 +9,36 @@
9
9
  * - Error handling and reporting
10
10
  */
11
11
 
12
+ import Glog from "./Glog.js"
13
+ import Sass from "./Sass.js"
14
+ import Tantrum from "./Tantrum.js"
15
+ import Util from "./Util.js"
16
+
12
17
  export default class Piper {
13
- #succeeded = []
14
- #warned = []
15
- #errored = []
16
- #steps = []
17
- #setupHooks = []
18
- #cleanupHooks = []
19
- #logger
20
-
21
- constructor(options = {}) {
22
- this.#logger = options.logger || {newDebug: () => () => {}}
18
+ #debug
19
+
20
+ #lifeCycle = new Map([
21
+ ["setup", new Set()],
22
+ ["process", new Set()],
23
+ ["teardown", new Set()]
24
+ ])
25
+
26
+ constructor(arg) {
27
+ this.#debug = arg?.debug ?? new Glog().newDebug("[PIPER]")
23
28
  }
24
29
 
25
30
  /**
26
31
  * Add a processing step to the pipeline
27
32
  *
28
- * @param {(context: object) => Promise<object>} stepFn - Function that processes an item: (context) => Promise<result>
33
+ * @param {(context: object) => Promise<object>} fn - Function that processes an item: (context) => Promise<result>
29
34
  * @param {object} options - Step options (name, required, etc.)
30
35
  * @returns {Piper} The pipeline instance (for chaining)
31
36
  */
32
- addStep(stepFn, options = {}) {
33
- this.#steps.push({
34
- fn: stepFn,
35
- name: options.name || `Step ${this.#steps.length + 1}`,
36
- required: options.required !== false, // Default to required
37
+ addStep(fn, options = {}) {
38
+ this.#lifeCycle.get("process").add({
39
+ fn,
40
+ name: options.name || `Step ${this.#lifeCycle.get("process").size + 1}`,
41
+ required: !!options.required, // Default to required
37
42
  ...options
38
43
  })
39
44
 
@@ -43,11 +48,11 @@ export default class Piper {
43
48
  /**
44
49
  * Add setup hook that runs before processing starts
45
50
  *
46
- * @param {() => Promise<void>} setupFn - Setup function: () => Promise<void>
51
+ * @param {() => Promise<void>} fn - Setup function: () => Promise<void>
47
52
  * @returns {Piper} The pipeline instance (for chaining)
48
53
  */
49
- addSetup(setupFn) {
50
- this.#setupHooks.push(setupFn)
54
+ addSetup(fn) {
55
+ this.#lifeCycle.get("setup").add(fn)
51
56
 
52
57
  return this
53
58
  }
@@ -55,11 +60,11 @@ export default class Piper {
55
60
  /**
56
61
  * Add cleanup hook that runs after processing completes
57
62
  *
58
- * @param {() => Promise<void>} cleanupFn - Cleanup function: () => Promise<void>
63
+ * @param {() => Promise<void>} fn - Cleanup function: () => Promise<void>
59
64
  * @returns {Piper} The pipeline instance (for chaining)
60
65
  */
61
- addCleanup(cleanupFn) {
62
- this.#cleanupHooks.push(cleanupFn)
66
+ addCleanup(fn) {
67
+ this.#lifeCycle.get("teardown").add(fn)
63
68
 
64
69
  return this
65
70
  }
@@ -72,50 +77,54 @@ export default class Piper {
72
77
  * @returns {Promise<object>} - Results object with succeeded, warned, errored arrays
73
78
  */
74
79
  async pipe(items, maxConcurrent = 10) {
75
- const itemQueue = [...items]
76
- const activePromises = []
77
-
78
- // Run setup hooks
79
- await Promise.allSettled(this.#setupHooks.map(hook => hook()))
80
-
81
- const processNextItem = item => {
82
- return this.#processItem(item).then(result => {
83
- // Categorize result
84
- if(result.status === "success") {
85
- this.#succeeded.push({input: item, ...result})
86
- } else if(result.status === "warning") {
87
- this.#warned.push({input: item, ...result})
88
- } else {
89
- this.#errored.push({input: item, ...result})
80
+ items.forEach(item => item.pipeStamp = Symbol(performance.now()))
81
+
82
+ let itemIndex = 0
83
+ const allResults = []
84
+
85
+ const processWorker = async() => {
86
+ while(true) {
87
+ const currentIndex = itemIndex++
88
+ if(currentIndex >= items.length)
89
+ break
90
+
91
+ const item = items[currentIndex]
92
+ try {
93
+ const result = await this.#processItem(item)
94
+ allResults.push(result)
95
+ } catch(error) {
96
+ throw Sass.new("Processing pipeline item.", error)
90
97
  }
91
-
92
- // Process next item if queue has items
93
- if(itemQueue.length > 0) {
94
- const nextItem = itemQueue.shift()
95
-
96
- return processNextItem(nextItem)
97
- }
98
- })
98
+ }
99
99
  }
100
100
 
101
- // Fill initial concurrent slots
102
- while(activePromises.length < maxConcurrent && itemQueue.length > 0) {
103
- const item = itemQueue.shift()
101
+ const setupResult = await Util.settleAll([...this.#lifeCycle.get("setup")].map(e => e()))
102
+ this.#processResult("Setting up the pipeline.", setupResult)
104
103
 
105
- activePromises.push(processNextItem(item))
106
- }
104
+ // Start workers up to maxConcurrent limit
105
+ const workers = []
106
+ const workerCount = Math.min(maxConcurrent, items.length)
107
+
108
+ for(let i = 0; i < workerCount; i++)
109
+ workers.push(processWorker())
107
110
 
108
- // Wait for all processing to complete
109
- await Promise.allSettled(activePromises)
111
+ // Wait for all workers to complete
112
+ const processResult = await Util.settleAll(workers)
113
+ this.#processResult("Processing pipeline.", processResult)
110
114
 
111
115
  // Run cleanup hooks
112
- await Promise.allSettled(this.#cleanupHooks.map(hook => hook()))
116
+ const teardownResult = await Util.settleAll([...this.#lifeCycle.get("teardown")].map(e => e()))
117
+ this.#processResult("Tearing down the pipeline.", teardownResult)
113
118
 
114
- return {
115
- succeeded: this.#succeeded,
116
- warned: this.#warned,
117
- errored: this.#errored
118
- }
119
+ return allResults
120
+ }
121
+
122
+ #processResult(message, settled) {
123
+ if(settled.some(r => r.status === "rejected"))
124
+ throw Tantrum.new(
125
+ message,
126
+ settled.filter(r => r.status==="rejected").map(r => r.reason)
127
+ )
119
128
  }
120
129
 
121
130
  /**
@@ -126,56 +135,21 @@ export default class Piper {
126
135
  * @private
127
136
  */
128
137
  async #processItem(item) {
129
- const debug = this.#logger.newDebug()
130
- const context = {item, data: {}}
138
+ const debug = this.#debug
131
139
 
132
140
  try {
133
141
  // Execute each step in sequence
134
- for(const step of this.#steps) {
135
- debug(`Executing step: ${step.name}`, 2)
136
-
137
- const result = await step.fn(context)
142
+ let result = item
138
143
 
139
- // Handle step result
140
- if(result && typeof result === "object") {
141
- if(result.status === "error") {
142
- return result
143
- }
144
+ for(const step of this.#lifeCycle.get("process")) {
145
+ debug("Executing step: %o", 2, step.name)
144
146
 
145
- if(result.status === "warning" && step.required) {
146
- return result
147
- }
148
-
149
- // Merge result data into context for next steps
150
- context.data = {...context.data, ...result.data}
151
- context.status = result.status || context.status
152
- }
153
- }
154
-
155
- return {
156
- status: context.status || "success",
157
- ...context.data
147
+ result = await step.fn(result) ?? result
158
148
  }
159
149
 
150
+ return result
160
151
  } catch(error) {
161
- return {
162
- status: "error",
163
- error,
164
- item
165
- }
152
+ throw Sass.new("Processing an item.", error)
166
153
  }
167
154
  }
168
-
169
- /**
170
- * Clear results (useful for reusing pipeline instance)
171
- *
172
- * @returns {Piper} The pipeline instance (for chaining)
173
- */
174
- clearResults() {
175
- this.#succeeded = []
176
- this.#warned = []
177
- this.#errored = []
178
-
179
- return this
180
- }
181
155
  }
@@ -0,0 +1,89 @@
1
+ import Ajv from "ajv"
2
+
3
+ import Data from "./Data.js"
4
+ import Util from "./Util.js"
5
+ import Valid from "./Valid.js"
6
+
7
+ /**
8
+ * Schemer provides utilities for compiling and validating JSON schemas using AJV.
9
+ *
10
+ * Usage:
11
+ * - Use Schemer.fromFile(file, options) to create a validator from a file.
12
+ * - Use Schemer.from(schemaData, options) to create a validator from a schema object.
13
+ * - Use Schemer.getValidator(schema, options) to get a raw AJV validator function.
14
+ * - Use Schemer.reportValidationErrors(errors) to format AJV validation errors.
15
+ */
16
+ export default class Schemer {
17
+ static async fromFile(file, options={}) {
18
+ Valid.type(file, "FileObject")
19
+ Valid.assert(Data.isPlainObject(options), "Options must be a plain object.")
20
+
21
+ const schemaData = await file.loadData()
22
+
23
+ return Schemer.getValidator(schemaData, options)
24
+ }
25
+
26
+ static async from(schemaData={}, options={}) {
27
+ Valid.assert(Data.isPlainObject(schemaData), "Schema data must be a plain object.")
28
+ Valid.assert(Data.isPlainObject(options), "Options must be a plain object.")
29
+
30
+ return Schemer.getValidator(schemaData, options)
31
+ }
32
+
33
+ /**
34
+ * Creates a validator function from a schema object
35
+ *
36
+ * @param {object} schema - The schema to compile
37
+ * @param {object} [options] - AJV options
38
+ * @returns {(data: unknown) => boolean} The AJV validator function, which may have additional properties (e.g., `.errors`)
39
+ */
40
+ static getValidator(schema, options = {allErrors: true, verbose: true}) {
41
+ const ajv = new Ajv(options)
42
+
43
+ return ajv.compile(schema)
44
+ }
45
+
46
+ static reportValidationErrors(errors) {
47
+ if(!errors) {
48
+ return ""
49
+ }
50
+
51
+ return errors.reduce((errorMessages, error) => {
52
+ let msg = `- "${error.instancePath || "(root)"}" ${error.message}`
53
+
54
+ if(error.params) {
55
+ const details = []
56
+
57
+ if(error.params.type)
58
+ details.push(` ➜ Expected type: ${error.params.type}`)
59
+
60
+ if(error.params.missingProperty)
61
+ details.push(` ➜ Missing required field: ${error.params.missingProperty}`)
62
+
63
+ if(error.params.allowedValues) {
64
+ details.push(` ➜ Allowed values: "${error.params.allowedValues.join('", "')}"`)
65
+ details.push(` ➜ Received value: "${error.data}"`)
66
+ const closestMatch =
67
+ Util.findClosestMatch(error.data, error.params.allowedValues)
68
+
69
+ if(closestMatch)
70
+ details.push(` ➜ Did you mean: "${closestMatch}"?`)
71
+ }
72
+
73
+ if(error.params.pattern)
74
+ details.push(` ➜ Expected pattern: ${error.params.pattern}`)
75
+
76
+ if(error.params.format)
77
+ details.push(` ➜ Expected format: ${error.params.format}`)
78
+
79
+ if(error.params.additionalProperty)
80
+ details.push(` ➜ Unexpected property: ${error.params.additionalProperty}`)
81
+
82
+ if(details.length)
83
+ msg += `\n${details.join("\n")}`
84
+ }
85
+
86
+ return errorMessages ? `${errorMessages}\n${msg}` : msg
87
+ }, "")
88
+ }
89
+ }
@@ -0,0 +1,74 @@
1
+ import JSON5 from "json5"
2
+ import yaml from "yaml"
3
+
4
+ import Data from "./Data.js"
5
+ import DirectoryObject from "./DirectoryObject.js"
6
+ import FileObject from "./FileObject.js"
7
+ import Sass from "./Sass.js"
8
+ import Valid from "./Valid.js"
9
+
10
+ const refex = /^ref:\/\/(?<file>.*)$/
11
+
12
+ /**
13
+ * Terms represents an interface definition - what an action promises to provide or accept.
14
+ * It's just the specification, not the negotiation. Contract handles the negotiation.
15
+ */
16
+ export default class Terms {
17
+ #definition = null
18
+
19
+ constructor(definition) {
20
+ this.#definition = definition
21
+ }
22
+
23
+ /**
24
+ * Parses terms data, handling file references
25
+ *
26
+ * @param {string|object} termsData - Terms data or reference
27
+ * @param {DirectoryObject?} directoryObject - Directory context for file resolution
28
+ * @returns {object} Parsed terms data
29
+ */
30
+ static async parse(termsData, directoryObject) {
31
+ if(Data.isBaseType(termsData, "String")) {
32
+ const match = refex.exec(termsData)
33
+
34
+ if(match?.groups?.file) {
35
+ Valid.type(directoryObject, "DirectoryObject")
36
+
37
+ const file = new FileObject(match.groups.file, directoryObject)
38
+
39
+ return await file.loadData()
40
+ }
41
+
42
+ // Try parsing as YAML/JSON
43
+ try {
44
+ const result = JSON5.parse(termsData)
45
+
46
+ return result
47
+ } catch {
48
+ try {
49
+ const result = yaml.parse(termsData)
50
+
51
+ return result
52
+ } catch {
53
+ throw Sass.new(`Could not parse terms data as YAML or JSON: ${termsData}`)
54
+ }
55
+ }
56
+ }
57
+
58
+ if(Data.isBaseType(termsData, "Object")) {
59
+ return termsData
60
+ }
61
+
62
+ throw Sass.new(`Invalid terms data type: ${typeof termsData}`)
63
+ }
64
+
65
+ /**
66
+ * Get the terms definition
67
+ *
68
+ * @returns {object} The terms definition
69
+ */
70
+ get definition() {
71
+ return this.#definition
72
+ }
73
+
74
+ }