@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/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
+ }
@@ -0,0 +1,162 @@
1
+ // Implementation: ../lib/Contract.js
2
+
3
+ import type { ValidateFunction } from 'ajv'
4
+
5
+ /**
6
+ * Debug function type for Contract operations
7
+ */
8
+ export type DebugFunction = (message: string, level?: number, ...args: unknown[]) => void
9
+
10
+ /**
11
+ * Contract represents a successful negotiation between Terms.
12
+ * It handles validation and compatibility checking between what
13
+ * one action provides and what another accepts.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Two-party contract between provider and consumer
18
+ * const provider = new Terms(providerDefinition)
19
+ * const consumer = new Terms(consumerDefinition)
20
+ * const contract = new Contract(provider, consumer, { debug: console.log })
21
+ *
22
+ * // Validate data against the contract
23
+ * const isValid = contract.validate(someData)
24
+ * ```
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * // Single-party contract from terms definition
29
+ * const contract = Contract.fromTerms("parser", {
30
+ * provides: {
31
+ * type: "object",
32
+ * properties: {
33
+ * name: { type: "string" },
34
+ * age: { type: "number" }
35
+ * }
36
+ * }
37
+ * })
38
+ *
39
+ * contract.validate({ name: "John", age: 30 }) // true
40
+ * ```
41
+ */
42
+ declare class Contract {
43
+ /**
44
+ * Creates a contract by negotiating between provider and consumer terms
45
+ *
46
+ * @param providerTerms - What the provider offers
47
+ * @param consumerTerms - What the consumer expects
48
+ * @param options - Configuration options
49
+ * @param options.debug - Debug function for logging negotiation details
50
+ *
51
+ * @throws {Sass} If contract negotiation fails due to incompatible terms
52
+ */
53
+ constructor(
54
+ providerTerms: import('./Terms.js').default | null,
55
+ consumerTerms: import('./Terms.js').default | null,
56
+ options?: { debug?: DebugFunction }
57
+ )
58
+
59
+ /**
60
+ * Creates a contract from terms with schema validation
61
+ *
62
+ * @param name - Contract identifier for error reporting
63
+ * @param termsDefinition - The terms definition object
64
+ * @param validator - Optional AJV schema validator function with .errors property
65
+ * @param debug - Debug function for logging validation details
66
+ * @returns New contract instance ready for data validation
67
+ *
68
+ * @throws {Sass} If terms definition is invalid according to the validator
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * const contract = Contract.fromTerms("user-parser", {
73
+ * provides: {
74
+ * type: "object",
75
+ * properties: {
76
+ * id: { type: "string" },
77
+ * name: { type: "string" }
78
+ * },
79
+ * required: ["id", "name"]
80
+ * }
81
+ * })
82
+ * ```
83
+ */
84
+ static fromTerms(
85
+ name: string,
86
+ termsDefinition: object,
87
+ validator?: ValidateFunction | null,
88
+ debug?: DebugFunction
89
+ ): Contract
90
+
91
+ /**
92
+ * Validates data against this contract's schema
93
+ *
94
+ * @param data - Data object to validate against the contract
95
+ * @returns True if validation passes
96
+ *
97
+ * @throws {Sass} If validation fails with detailed error messages
98
+ * @throws {Sass} If contract has not been successfully negotiated
99
+ * @throws {Sass} If no validator is available for this contract
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * try {
104
+ * contract.validate({ id: "123", name: "John" })
105
+ * console.log("Data is valid!")
106
+ * } catch (error) {
107
+ * console.error("Validation failed:", error.message)
108
+ * }
109
+ * ```
110
+ */
111
+ validate(data: object): boolean
112
+
113
+ /**
114
+ * Check if contract negotiation was successful
115
+ *
116
+ * @returns True if the contract has been successfully negotiated
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * if (contract.isNegotiated) {
121
+ * contract.validate(data)
122
+ * } else {
123
+ * console.error("Contract negotiation failed")
124
+ * }
125
+ * ```
126
+ */
127
+ get isNegotiated(): boolean
128
+
129
+ /**
130
+ * Get the provider terms (if any)
131
+ *
132
+ * @returns Provider terms or null for single-party contracts
133
+ */
134
+ get providerTerms(): import('./Terms.js').default | null
135
+
136
+ /**
137
+ * Get the consumer terms (if any)
138
+ *
139
+ * @returns Consumer terms or null for single-party contracts
140
+ */
141
+ get consumerTerms(): import('./Terms.js').default | null
142
+
143
+ /**
144
+ * Get the contract validator function
145
+ *
146
+ * @returns The AJV validator function used by this contract, or null if none available
147
+ *
148
+ * @example
149
+ * ```typescript
150
+ * const validator = contract.validator
151
+ * if (validator) {
152
+ * const isValid = validator(someData)
153
+ * if (!isValid) {
154
+ * console.log("Validation errors:", validator.errors)
155
+ * }
156
+ * }
157
+ * ```
158
+ */
159
+ get validator(): ((data: object) => boolean) | null
160
+ }
161
+
162
+ export default Contract
@@ -40,6 +40,12 @@ export default class DirectoryObject extends FS {
40
40
  /** The directory extension (usually empty) */
41
41
  readonly extension: string
42
42
 
43
+ /** The platform-specific path separator (e.g., '/' on Unix, '\\' on Windows) */
44
+ readonly sep: string
45
+
46
+ /** Array of directory path segments split by separator */
47
+ readonly trail: string[]
48
+
43
49
  /** Always false for directories */
44
50
  readonly isFile: false
45
51
 
@@ -49,6 +55,25 @@ export default class DirectoryObject extends FS {
49
55
  /** Whether the directory exists (async) */
50
56
  readonly exists: Promise<boolean>
51
57
 
58
+ /**
59
+ * Generator that walks up the directory tree, yielding parent directories.
60
+ * Starts from the current directory and yields each parent until reaching the root.
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * const dir = new DirectoryObject('/path/to/deep/directory')
65
+ * for (const parent of dir.walkUp) {
66
+ * console.log(parent.path)
67
+ * // /path/to/deep/directory
68
+ * // /path/to/deep
69
+ * // /path/to
70
+ * // /path
71
+ * // /
72
+ * }
73
+ * ```
74
+ */
75
+ readonly walkUp: Generator<DirectoryObject, void, unknown>
76
+
52
77
  /** Returns a string representation of the DirectoryObject */
53
78
  toString(): string
54
79
 
@@ -64,9 +89,47 @@ export default class DirectoryObject extends FS {
64
89
  isDirectory: boolean
65
90
  }
66
91
 
67
- /** List the contents of this directory */
92
+ /**
93
+ * Lists the contents of this directory.
94
+ * Returns FileObject instances for files and DirectoryObject instances for subdirectories.
95
+ *
96
+ * @returns Promise resolving to object with files and directories arrays
97
+ * @throws {Error} If directory cannot be read
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const dir = new DirectoryObject('./src')
102
+ * const {files, directories} = await dir.read()
103
+ *
104
+ * console.log(`Found ${files.length} files`)
105
+ * files.forEach(file => console.log(file.name))
106
+ *
107
+ * console.log(`Found ${directories.length} subdirectories`)
108
+ * directories.forEach(subdir => console.log(subdir.name))
109
+ * ```
110
+ */
68
111
  read(): Promise<DirectoryListing>
69
112
 
70
- /** Ensure this directory exists, creating it if necessary */
113
+ /**
114
+ * Ensures this directory exists, creating it if necessary.
115
+ * Gracefully handles the case where the directory already exists (EEXIST error).
116
+ * Pass options to control directory creation behavior (e.g., recursive, mode).
117
+ *
118
+ * @param options - Options to pass to fs.mkdir (e.g., {recursive: true, mode: 0o755})
119
+ * @returns Promise that resolves when directory exists or has been created
120
+ * @throws {Sass} If directory creation fails for reasons other than already existing
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * const dir = new DirectoryObject('./build/output')
125
+ *
126
+ * // Create directory recursively
127
+ * await dir.assureExists({recursive: true})
128
+ *
129
+ * // Now safe to write files
130
+ * const file = new FileObject('result.json', dir)
131
+ * await file.write(JSON.stringify(data))
132
+ * ```
133
+ */
71
134
  assureExists(options?: any): Promise<void>
72
135
  }