@gesslar/toolkit 0.3.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 +3 -2
- package/src/index.js +3 -0
- package/src/lib/Action.js +283 -0
- package/src/lib/ActionRunner.js +21 -51
- package/src/lib/Contract.js +257 -0
- package/src/lib/Data.js +1 -1
- package/src/lib/DirectoryObject.js +94 -18
- package/src/lib/FileObject.js +39 -19
- package/src/lib/Glog.js +3 -2
- package/src/lib/Hooks.js +194 -0
- package/src/lib/Piper.js +74 -100
- package/src/lib/Schemer.js +89 -0
- package/src/lib/Terms.js +74 -0
- package/src/types/Contract.d.ts +162 -0
- package/src/types/DirectoryObject.d.ts +65 -2
- package/src/types/FileObject.d.ts +38 -2
- package/src/types/Schemer.d.ts +179 -0
- package/src/types/Terms.d.ts +145 -0
- package/src/types/index.d.ts +3 -0
- package/src/lib/BaseActionManager.js +0 -246
- package/src/lib/BaseHookManager.js +0 -206
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gesslar/toolkit",
|
|
3
|
-
"version": "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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"update": "npx npm-check-updates -u && npm install",
|
|
27
27
|
"test": "node --test tests/unit/*.test.js",
|
|
28
28
|
"test:unit": "node --test tests/unit/*.test.js",
|
|
29
|
-
"pr": "gt submit --
|
|
29
|
+
"pr": "gt submit --publish --restack --ai"
|
|
30
30
|
},
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
@@ -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
|
+
}
|
package/src/lib/ActionRunner.js
CHANGED
|
@@ -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
|
-
#
|
|
14
|
+
#hooks = null
|
|
18
15
|
|
|
19
|
-
constructor({action, build,
|
|
16
|
+
constructor({action, build}, hooks) {
|
|
20
17
|
this.#action = action
|
|
21
18
|
this.#build = build
|
|
22
|
-
this.#
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(!
|
|
53
|
+
if(!await op.call(action, result))
|
|
66
54
|
break
|
|
67
55
|
|
|
68
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
100
|
-
|
|
70
|
+
return runner.run(item)
|
|
71
|
+
}, {name: `Process Parallel ActionRunner Activity`,})
|
|
101
72
|
|
|
102
|
-
|
|
103
|
-
// .sort((a,b) => a.index-b.index)
|
|
73
|
+
await piper.pipe(result.value)
|
|
104
74
|
}
|
|
105
75
|
}
|
|
106
76
|
|
|
107
|
-
return result
|
|
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
|
+
}
|