@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gesslar/toolkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Get in, bitches, we're going toolkitting.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "homepage": "https://github.com/gesslar/toolkit#readme",
52
52
  "dependencies": {
53
53
  "@gesslar/colours": "^0.0.1",
54
+ "ajv": "^8.17.1",
54
55
  "globby": "^15.0.0",
55
56
  "json5": "^2.2.3",
56
57
  "yaml": "^2.8.1"
package/src/index.js CHANGED
@@ -6,11 +6,14 @@ export {default as FS} from "./lib/FS.js"
6
6
  // Utility classes
7
7
  export {default as Cache} from "./lib/Cache.js"
8
8
  export {default as Collection} from "./lib/Collection.js"
9
+ export {default as Contract} from "./lib/Contract.js"
9
10
  export {default as Data} from "./lib/Data.js"
10
11
  export {default as Glog} from "./lib/Glog.js"
11
12
  export {default as Sass} from "./lib/Sass.js"
13
+ export {default as Schemer} from "./lib/Schemer.js"
12
14
  export {default as Tantrum} from "./lib/Tantrum.js"
13
15
  export {default as Term} from "./lib/Term.js"
16
+ export {default as Terms} from "./lib/Terms.js"
14
17
  export {default as Type} from "./lib/TypeSpec.js"
15
18
  export {default as Util} from "./lib/Util.js"
16
19
  export {default as Valid} from "./lib/Valid.js"
@@ -0,0 +1,283 @@
1
+ import ActionBuilder from "./ActionBuilder.js"
2
+ import ActionRunner from "./ActionRunner.js"
3
+ import Data from "./Data.js"
4
+ import FileObject from "./FileObject.js"
5
+ import Hooks from "./Hooks.js"
6
+ import Sass from "./Sass.js"
7
+ import Terms from "./Terms.js"
8
+
9
+ /**
10
+ * Generic base class for managing actions with lifecycle hooks.
11
+ * Provides common functionality for action setup, execution, and cleanup.
12
+ * Designed to be extended by specific implementations.
13
+ */
14
+ export default class Action {
15
+ #action = null
16
+ #hooks = null
17
+ #file = null
18
+ #variables = null
19
+ #runner = null
20
+ #id = null
21
+ #debug
22
+
23
+ /**
24
+ * Creates a new BaseActionManager instance.
25
+ *
26
+ * @param {object} config - Configuration object
27
+ * @param {object} config.actionDefinition - Action definition containing action class and file info
28
+ * @param {object} [config.variables] - Variables to pass to action during setup
29
+ * @param {import('../types.js').DebugFunction} config.debug - The logger's debug function
30
+ */
31
+ constructor({actionDefinition, variables, debug}) {
32
+ this.#id = Symbol(performance.now())
33
+ this.#variables = variables || {}
34
+ this.#debug = debug
35
+
36
+ const {action,file} = actionDefinition
37
+ this.#action = action
38
+ this.#file = file
39
+ }
40
+ /**
41
+ * Gets the unique identifier for this action manager instance.
42
+ *
43
+ * @returns {symbol} Unique symbol identifier
44
+ */
45
+ get id() {
46
+ return this.#id
47
+ }
48
+
49
+ /**
50
+ * Gets the action class constructor.
51
+ *
52
+ * @returns {new () => object} Action class constructor
53
+ */
54
+ get action() {
55
+ return this.#action
56
+ }
57
+
58
+ /**
59
+ * Gets the current hook manager instance.
60
+ *
61
+ * @returns {Hooks|null} Hook manager instance or null if not set
62
+ */
63
+ get hooks() {
64
+ return this.#hooks
65
+ }
66
+
67
+ /**
68
+ * Sets the hook manager and attaches hooks to the action.
69
+ *
70
+ * @param {Hooks} hooks - Hook manager instance with hooks and on method.
71
+ * @returns {Promise<this>} Promise of this instance.
72
+ * @throws {Sass} If hook manager is already set.
73
+ */
74
+ setHooks(hooks) {
75
+ if(this.#hooks)
76
+ throw Sass.new("Hook manager already set")
77
+
78
+ this.#hooks = hooks
79
+
80
+ return this
81
+ }
82
+
83
+ // async callHook(kind, activity, action, context) {
84
+ // const hooks = this.#hooks
85
+
86
+ // }
87
+
88
+ /**
89
+ * Gets the action metadata.
90
+ *
91
+ * @returns {object|undefined} Action metadata object
92
+ */
93
+ get meta() {
94
+ return this.#action?.meta
95
+ }
96
+
97
+ /**
98
+ * Gets the variables passed to the action.
99
+ *
100
+ * @returns {object} Variables object
101
+ */
102
+ get variables() {
103
+ return this.#variables
104
+ }
105
+
106
+ /**
107
+ * Gets the action runner instance.
108
+ *
109
+ * @returns {ActionRunner?} ActionRunner instance or null if not set up
110
+ */
111
+ get runner() {
112
+ return this.#runner
113
+ }
114
+
115
+ /**
116
+ * Gets the file information object.
117
+ *
118
+ * @returns {FileObject?} File information object
119
+ */
120
+ get file() {
121
+ return this.#file
122
+ }
123
+
124
+ /**
125
+ * Setup the action by creating and configuring the runner.
126
+ * This is the main public method to initialize the action for use.
127
+ *
128
+ * @returns {Promise<this>} Promise of this instance.
129
+ * @throws {Sass} If action setup fails
130
+ */
131
+ async setupAction() {
132
+ this.#debug("Setting up action for %o on %o", 2, this.#action.meta?.kind, this.id)
133
+
134
+ await this.#setupHooks()
135
+ await this.#setupAction()
136
+
137
+ return this
138
+ }
139
+
140
+ /**
141
+ * Setup the action instance and create the runner.
142
+ * Creates a new action instance, calls its setup method with an
143
+ * ActionBuilder, and creates an ActionRunner from the result.
144
+ *
145
+ * Can be overridden in subclasses to customize action setup.
146
+ *
147
+ * @returns {Promise<void>}
148
+ * @throws {Sass} If action setup method is not a function
149
+ * @protected
150
+ */
151
+ async #setupAction() {
152
+ const actionInstance = new this.#action()
153
+ const setup = actionInstance?.setup
154
+
155
+ // Setup is required for actions.
156
+ if(Data.typeOf(setup) === "Function") {
157
+ const builder = new ActionBuilder(actionInstance)
158
+ const configuredBuilder = setup(builder)
159
+ const buildResult = configuredBuilder.build()
160
+ const runner = new ActionRunner({
161
+ action: buildResult.action,
162
+ build: buildResult.build
163
+ }, this.#hooks)
164
+
165
+ this.#runner = runner
166
+ } else {
167
+ throw Sass.new("Action setup must be a function.")
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Run the action with the provided input.
173
+ * The action must be set up via setupAction() before calling this method.
174
+ *
175
+ * @param {unknown} context - Input data to pass to the action runner
176
+ * @returns {Promise<unknown>} Result from the action execution
177
+ * @throws {Sass} If action is not set up
178
+ */
179
+ async runAction(context) {
180
+ if(!this.#runner)
181
+ throw Sass.new("Action not set up. Call setupAction() first.")
182
+
183
+ return await this.#runner.run(context)
184
+ }
185
+
186
+ /**
187
+ * Cleanup the action and hooks.
188
+ * This should be called when the action is no longer needed to free
189
+ * resources.
190
+ *
191
+ * Calls cleanupHooks() and cleanupActionInstance() which can be overridden.
192
+ *
193
+ * @returns {Promise<this>} Promise of this instance.
194
+ */
195
+ async cleanupAction() {
196
+ this.#debug("Cleaning up action for %o on %o", 2, this.#action.meta?.kind, this.id)
197
+
198
+ await this.#cleanupHooks()
199
+ await this.#cleanupAction()
200
+
201
+ return this
202
+ }
203
+
204
+ /**
205
+ * Setup hooks if hook manager is present.
206
+ * Calls the hook manager's setup function with action context.
207
+ * Override in subclasses to customize hook setup.
208
+ *
209
+ * @returns {Promise<void>}
210
+ * @throws {Sass} If hook setup is not a function
211
+ * @private
212
+ */
213
+ async #setupHooks() {
214
+ const setup = this.#hooks?.setup
215
+ const type = Data.typeOf(setup)
216
+
217
+ // No hooks attached.
218
+ if(type === "Null" || type === "Undefined")
219
+ return
220
+
221
+ if(type !== "Function")
222
+ throw Sass.new("Hook setup must be a function.")
223
+
224
+ await setup.call(
225
+ this.hooks.hooks, {
226
+ action: this.#action,
227
+ variables: this.#variables,
228
+ }
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Cleanup hooks if hook manager is present.
234
+ * Calls the hook manager's cleanup function.
235
+ * Override in subclasses to customize hook cleanup.
236
+ *
237
+ * @returns {Promise<void>}
238
+ * @protected
239
+ */
240
+ async #cleanupHooks() {
241
+ const cleanup = this.hooks?.cleanup
242
+
243
+ if(!cleanup)
244
+ return
245
+
246
+ await cleanup.call(this.hooks.hooks)
247
+ }
248
+
249
+ /**
250
+ * Cleanup the action instance.
251
+ * Calls the action's cleanup method if it exists.
252
+ * Override in subclasses to add custom cleanup logic.
253
+ *
254
+ * @returns {Promise<void>}
255
+ * @protected
256
+ */
257
+ async #cleanupAction() {
258
+ const cleanup = this.#action?.cleanup
259
+
260
+ if(!cleanup)
261
+ return
262
+
263
+ await cleanup.call(this.#action)
264
+ }
265
+
266
+ /**
267
+ * Returns a string representation of this action manager.
268
+ *
269
+ * @returns {string} String representation with module and action info
270
+ */
271
+ toString() {
272
+ return `${this.#file?.module || "UNDEFINED"} (${this.meta?.action || "UNDEFINED"})`
273
+ }
274
+
275
+ /**
276
+ * Get contract/terms for this action (override in subclasses)
277
+ *
278
+ * @returns {Terms?} Contract terms or null if not implemented
279
+ */
280
+ get terms() {
281
+ return null
282
+ }
283
+ }
@@ -1,9 +1,6 @@
1
1
  import ActionBuilder, {ACTIVITY} from "./ActionBuilder.js"
2
- import Data from "./Data.js"
3
2
  import Piper from "./Piper.js"
4
3
  import Sass from "./Sass.js"
5
- import Glog from "./Glog.js"
6
-
7
4
  /**
8
5
  * Orchestrates execution of {@link ActionBuilder}-produced pipelines.
9
6
  *
@@ -14,12 +11,12 @@ import Glog from "./Glog.js"
14
11
  export default class ActionRunner {
15
12
  #action = null
16
13
  #build = null
17
- #logger = null
14
+ #hooks = null
18
15
 
19
- constructor({action, build, logger}) {
16
+ constructor({action, build}, hooks) {
20
17
  this.#action = action
21
18
  this.#build = build
22
- this.#logger = logger ?? {newDebug: () => () => {}}
19
+ this.#hooks = hooks
23
20
  }
24
21
 
25
22
  /**
@@ -30,43 +27,33 @@ export default class ActionRunner {
30
27
  * @throws {Sass} When no activities are registered or required parallel builders are missing.
31
28
  */
32
29
  async run(content) {
33
- const AR = ActionRunner
34
- const result = {value: content}
35
- const action = this.#action
36
30
  const activities = this.#build.activities
37
31
 
38
32
  if(!activities.size)
39
33
  throw Sass.new("No activities defined in action.")
40
34
 
41
- for(const [_,activity] of activities) {
35
+ const result = content
36
+ const action = this.#action
37
+
38
+ for(const [name,activity] of activities) {
42
39
  const {op} = activity
43
40
 
44
41
  if(activity.kind === ACTIVITY.ONCE) {
42
+ this.#hooks && await this.#hooks.callHook("before", name, result)
45
43
 
46
- if(Data.typeOf(activity.hooks?.before) === "Function")
47
- await activity.hooks.before.call(action, result)
48
-
49
- const activityResult = await op.call(action, result)
50
-
51
- if(!activityResult)
44
+ if(!await op.call(action, result))
52
45
  break
53
46
 
54
- if(Data.typeOf(activity.hooks?.after) === "Function")
55
- await activity.hooks.after.call(action, result)
56
-
47
+ this.#hooks && await this.#hooks.callHook("after", name, result)
57
48
  } else if(activity.kind == ACTIVITY.MANY) {
58
49
  for(;;) {
59
50
 
60
- if(Data.typeOf(activity.hooks?.before) === "Function")
61
- await activity.hooks.before.call(action, result)
62
-
63
- const activityResult = await op.call(action, result)
51
+ this.#hooks && await this.#hooks.callHook("before", name, result)
64
52
 
65
- if(!activityResult)
53
+ if(!await op.call(action, result))
66
54
  break
67
55
 
68
- if(Data.typeOf(activity.hooks?.after) === "Function")
69
- await activity.hooks.after.call(action, result)
56
+ this.#hooks && await this.#hooks.callHook("after", name, result)
70
57
  }
71
58
  } else if(activity.kind === ACTIVITY.PARALLEL) {
72
59
  if(op === undefined)
@@ -75,35 +62,18 @@ export default class ActionRunner {
75
62
  if(!op)
76
63
  throw Sass.new("Okay, cheeky monkey, you need to return the builder for this to work.")
77
64
 
78
- const piper = new Piper({logger: this.#logger})
79
- .addStep(c => new AR(op.build()).run(c))
80
-
81
- result.value = await piper.pipe()
82
- Glog(result)
83
- throw Sass.new("Nope")
84
-
85
- // // wheeeeeeeeeeeeee! ZOOMZOOM!
86
- // const settled = await Util.settleAll(
87
- // result.value.map()
88
- // )
89
-
90
- // const rejected = settled
91
- // .filter(r => r.status === "rejected")
92
- // .map(r => {
93
- // return r.reason instanceof Sass
94
- // ? r.reason
95
- // : Sass.new("Running structured parsing.", r.reason)
96
- // })
97
- // .map(r => r.report(true))
65
+ const builder = op.build()
66
+ const piper = new Piper()
67
+ .addStep(item => {
68
+ const runner = new ActionRunner(builder, this.#hooks)
98
69
 
99
- // if(rejected.length)
100
- // return null
70
+ return runner.run(item)
71
+ }, {name: `Process Parallel ActionRunner Activity`,})
101
72
 
102
- // result.value = settled.map(s => s.value)
103
- // .sort((a,b) => a.index-b.index)
73
+ await piper.pipe(result.value)
104
74
  }
105
75
  }
106
76
 
107
- return result.value
77
+ return result
108
78
  }
109
79
  }
@@ -0,0 +1,257 @@
1
+ import Sass from "./Sass.js"
2
+ import Schemer from "./Schemer.js"
3
+ import Terms from "./Terms.js"
4
+ import Data from "./Data.js"
5
+
6
+ /**
7
+ * Contract represents a successful negotiation between Terms.
8
+ * It handles validation and compatibility checking between what
9
+ * one action provides and what another accepts.
10
+ */
11
+ export default class Contract {
12
+ #providerTerms = null
13
+ #consumerTerms = null
14
+ #validator = null
15
+ #debug = null
16
+ #isNegotiated = false
17
+
18
+ /**
19
+ * Creates a contract by negotiating between provider and consumer terms
20
+ *
21
+ * @param {Terms} providerTerms - What the provider offers
22
+ * @param {Terms} consumerTerms - What the consumer expects
23
+ * @param {object} options - Configuration options
24
+ * @param {import('../types.js').DebugFunction} [options.debug] - Debug function
25
+ */
26
+ constructor(providerTerms, consumerTerms, {debug = null} = {}) {
27
+ this.#providerTerms = providerTerms
28
+ this.#consumerTerms = consumerTerms
29
+ this.#debug = debug
30
+
31
+ // Perform the negotiation
32
+ this.#negotiate()
33
+ }
34
+
35
+ /**
36
+ * Extracts the actual schema from a terms definition
37
+ *
38
+ * @param {object} definition - Terms definition with TLD descriptor
39
+ * @returns {object} Extracted schema content
40
+ * @throws {Sass} If definition structure is invalid
41
+ * @private
42
+ */
43
+ static #extractSchemaFromTerms(definition) {
44
+ // Must be a plain object
45
+ if(!Data.isPlainObject(definition)) {
46
+ throw Sass.new("Terms definition must be a plain object")
47
+ }
48
+
49
+ // Must have exactly one key (the TLD/descriptor)
50
+ const keys = Object.keys(definition)
51
+ if(keys.length !== 1) {
52
+ throw Sass.new("Terms definition must have exactly one top-level key (descriptor)")
53
+ }
54
+
55
+ // Extract the content under the TLD
56
+ const [key] = keys
57
+
58
+ return definition[key]
59
+ }
60
+
61
+ /**
62
+ * Creates a contract from terms with schema validation
63
+ *
64
+ * @param {string} name - Contract identifier
65
+ * @param {object} termsDefinition - The terms definition
66
+ * @param {import('ajv').ValidateFunction|null} [validator] - Optional AJV schema validator function with .errors property
67
+ * @param {import('../types.js').DebugFunction} [debug] - Debug function
68
+ * @returns {Contract} New contract instance
69
+ */
70
+ static fromTerms(name, termsDefinition, validator = null, debug = null) {
71
+ // Validate the terms definition if validator provided
72
+ if(validator) {
73
+ const valid = validator(termsDefinition)
74
+
75
+ if(!valid) {
76
+ const error = Schemer.reportValidationErrors(validator.errors)
77
+ throw Sass.new(`Invalid terms definition for ${name}:\n${error}`)
78
+ }
79
+ }
80
+
81
+ // Extract schema from terms definition for validation
82
+ const schemaDefinition = Contract.#extractSchemaFromTerms(termsDefinition)
83
+ const termsSchemaValidator = Schemer.getValidator(schemaDefinition)
84
+
85
+ const contract = new Contract(null, null, {debug})
86
+ contract.#validator = termsSchemaValidator
87
+ contract.#isNegotiated = true // Single-party contract is automatically negotiated
88
+
89
+ return contract
90
+ }
91
+
92
+ /**
93
+ * Performs negotiation between provider and consumer terms
94
+ *
95
+ * @private
96
+ */
97
+ #negotiate() {
98
+ if(!this.#providerTerms || !this.#consumerTerms) {
99
+ // Single-party contract scenario
100
+ this.#isNegotiated = true
101
+
102
+ return
103
+ }
104
+
105
+ // Extract content for comparison (ignore TLD metadata)
106
+ const providerContent = Contract.#extractSchemaFromTerms(
107
+ this.#providerTerms.definition
108
+ )
109
+ const consumerContent = Contract.#extractSchemaFromTerms(
110
+ this.#consumerTerms.definition
111
+ )
112
+
113
+ // Compare terms for compatibility
114
+ const compatibility = this.#compareTerms(providerContent, consumerContent)
115
+
116
+ if(compatibility.status === "error") {
117
+ throw Sass.new(
118
+ `Contract negotiation failed: ${compatibility.errors.map(e => e.message).join(", ")}`
119
+ )
120
+ }
121
+
122
+ this.#isNegotiated = true
123
+ this.#debug?.(`Contract negotiated successfully`, 3)
124
+ }
125
+
126
+ /**
127
+ * Validates data against this contract
128
+ *
129
+ * @param {object} data - Data to validate
130
+ * @returns {boolean} True if valid
131
+ * @throws {Sass} If validation fails or contract not negotiated
132
+ */
133
+ validate(data) {
134
+ const debug = this.#debug
135
+
136
+ if(!this.#isNegotiated)
137
+ throw Sass.new("Cannot validate against unnegotiated contract")
138
+
139
+ if(!this.#validator)
140
+ throw Sass.new("No validator available for this contract")
141
+
142
+ debug?.("Validating data %o", 4, data)
143
+
144
+ const valid = this.#validator(data)
145
+
146
+ if(!valid) {
147
+ const error = Schemer.reportValidationErrors(this.#validator.errors)
148
+ throw Sass.new(`Contract validation failed:\n${error}`)
149
+ }
150
+
151
+ return true
152
+ }
153
+
154
+ /**
155
+ * Compares terms for compatibility
156
+ *
157
+ * @param {object} providerTerms - Terms offered by provider
158
+ * @param {object} consumerTerms - Terms expected by consumer
159
+ * @param {Array} stack - Stack trace for nested validation
160
+ * @returns {object} Result with status and errors
161
+ * @private
162
+ */
163
+ #compareTerms(providerTerms, consumerTerms, stack = []) {
164
+ const debug = this.#debug
165
+ const breadcrumb = key => (stack.length ? `@${stack.join(".")}` : key)
166
+ const errors = []
167
+
168
+ if(!providerTerms || !consumerTerms) {
169
+ return {
170
+ status: "error",
171
+ errors: [Sass.new("Both provider and consumer terms are required")]
172
+ }
173
+ }
174
+
175
+ debug?.("Comparing provider keys:%o with consumer keys:%o", 3,
176
+ Object.keys(providerTerms), Object.keys(consumerTerms))
177
+
178
+ // Check that consumer requirements are met by provider
179
+ for(const [key, consumerRequirement] of Object.entries(consumerTerms)) {
180
+ debug?.("Checking consumer requirement: %o [required = %o]", 3,
181
+ key, consumerRequirement.required ?? false)
182
+
183
+ if(consumerRequirement.required && !(key in providerTerms)) {
184
+ debug?.("Provider missing required capability: %o", 2, key)
185
+ errors.push(
186
+ Sass.new(`Provider missing required capability: ${key} ${breadcrumb(key)}`)
187
+ )
188
+ continue
189
+ }
190
+
191
+ if(key in providerTerms) {
192
+ const expectedType = consumerRequirement.dataType
193
+ const providedType = providerTerms[key]?.dataType
194
+
195
+ if(expectedType && providedType && expectedType !== providedType) {
196
+ errors.push(
197
+ Sass.new(
198
+ `Type mismatch for ${key}: Consumer expects ${expectedType}, provider offers ${providedType} ${breadcrumb(key)}`
199
+ )
200
+ )
201
+ }
202
+
203
+ // Recursive validation for nested requirements
204
+ if(consumerRequirement.contains) {
205
+ debug?.("Recursing into nested requirement: %o", 3, key)
206
+ const nestedResult = this.#compareTerms(
207
+ providerTerms[key]?.contains,
208
+ consumerRequirement.contains,
209
+ [...stack, key]
210
+ )
211
+
212
+ if(nestedResult.errors.length) {
213
+ errors.push(...nestedResult.errors)
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ return {status: errors.length === 0 ? "success" : "error", errors}
220
+ }
221
+
222
+ /**
223
+ * Check if contract negotiation was successful
224
+ *
225
+ * @returns {boolean} True if negotiated
226
+ */
227
+ get isNegotiated() {
228
+ return this.#isNegotiated
229
+ }
230
+
231
+ /**
232
+ * Get the provider terms (if any)
233
+ *
234
+ * @returns {Terms|null} Provider terms
235
+ */
236
+ get providerTerms() {
237
+ return this.#providerTerms
238
+ }
239
+
240
+ /**
241
+ * Get the consumer terms (if any)
242
+ *
243
+ * @returns {Terms|null} Consumer terms
244
+ */
245
+ get consumerTerms() {
246
+ return this.#consumerTerms
247
+ }
248
+
249
+ /**
250
+ * Get the contract validator
251
+ *
252
+ * @returns {(data: object) => boolean|null} The contract validator function
253
+ */
254
+ get validator() {
255
+ return this.#validator
256
+ }
257
+ }
package/src/lib/Data.js CHANGED
@@ -160,7 +160,7 @@ export default class Data {
160
160
  // Special cases that need extra validation
161
161
  switch(valueType) {
162
162
  case "Number":
163
- return valueType === "Number" && !isNaN(value) // Excludes NaN
163
+ return type === "Number" && !isNaN(value) // Excludes NaN
164
164
  default:
165
165
  return valueType === type
166
166
  }