core-services-sdk 1.3.73 → 1.3.75

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": "core-services-sdk",
3
- "version": "1.3.73",
3
+ "version": "1.3.75",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
@@ -45,7 +45,8 @@
45
45
  "pino": "^9.7.0",
46
46
  "ulid": "^3.0.1",
47
47
  "uuid": "^11.1.0",
48
- "xml2js": "^0.6.2"
48
+ "xml2js": "^0.6.2",
49
+ "zod": "^4.3.6"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@vitest/coverage-v8": "^3.2.4",
@@ -0,0 +1,309 @@
1
+ import { z } from 'zod'
2
+ import { mask as defaultMask } from '../util/mask-sensitive.js'
3
+
4
+ /**
5
+ * Converts a single field definition into a Zod schema.
6
+ *
7
+ * @param {Object} def
8
+ * @param {'string'|'number'|'boolean'} def.type
9
+ *
10
+ * @param {boolean} [def.required]
11
+ * Whether the value is required. Defaults to false.
12
+ *
13
+ * @param {*} [def.default]
14
+ * Default value if the variable is missing.
15
+ *
16
+ * @param {boolean} [def.secret]
17
+ * Marks the variable as secret (used only for masking/logging).
18
+ *
19
+ * // string options
20
+ * @param {number} [def.minLength]
21
+ * @param {number} [def.maxLength]
22
+ * @param {string} [def.pattern]
23
+ * @param {string[]} [def.enum]
24
+ * @param {'email'|'url'} [def.format]
25
+ *
26
+ * // number options
27
+ * @param {number} [def.min]
28
+ * @param {number} [def.max]
29
+ * @param {boolean} [def.int]
30
+ * @param {boolean} [def.positive]
31
+ * @param {boolean} [def.negative]
32
+ *
33
+ * @returns {import('zod').ZodTypeAny}
34
+ */
35
+ export function defToZod(def) {
36
+ let schema
37
+
38
+ switch (def.type) {
39
+ case 'string': {
40
+ schema = z.string()
41
+
42
+ if (def.minLength !== undefined) {
43
+ schema = schema.min(def.minLength)
44
+ }
45
+
46
+ if (def.maxLength !== undefined) {
47
+ schema = schema.max(def.maxLength)
48
+ }
49
+
50
+ if (def.pattern) {
51
+ schema = schema.regex(new RegExp(def.pattern))
52
+ }
53
+
54
+ if (def.enum) {
55
+ schema = z.enum(def.enum)
56
+ }
57
+
58
+ if (def.format === 'email') {
59
+ schema = schema.email()
60
+ }
61
+
62
+ if (def.format === 'url') {
63
+ schema = schema.url()
64
+ }
65
+
66
+ break
67
+ }
68
+
69
+ case 'number': {
70
+ schema = z.coerce.number()
71
+
72
+ if (def.int) {
73
+ schema = schema.int()
74
+ }
75
+
76
+ if (def.min !== undefined) {
77
+ schema = schema.min(def.min)
78
+ }
79
+
80
+ if (def.max !== undefined) {
81
+ schema = schema.max(def.max)
82
+ }
83
+
84
+ if (def.positive) {
85
+ schema = schema.positive()
86
+ }
87
+
88
+ if (def.negative) {
89
+ schema = schema.negative()
90
+ }
91
+
92
+ break
93
+ }
94
+
95
+ case 'boolean': {
96
+ schema = z.preprocess((val) => {
97
+ if (val === undefined) {
98
+ return val
99
+ }
100
+
101
+ if (typeof val === 'boolean') {
102
+ return val
103
+ }
104
+
105
+ if (typeof val === 'string') {
106
+ const normalized = val.toLowerCase().trim()
107
+
108
+ if (normalized === 'true') {
109
+ return true
110
+ }
111
+ if (normalized === 'false') {
112
+ return false
113
+ }
114
+ if (normalized === '1') {
115
+ return true
116
+ }
117
+ if (normalized === '0') {
118
+ return false
119
+ }
120
+ }
121
+
122
+ return val
123
+ }, z.boolean())
124
+
125
+ break
126
+ }
127
+
128
+ default: {
129
+ throw new Error(`Unsupported type: ${def.type}`)
130
+ }
131
+ }
132
+
133
+ if (def.default !== undefined) {
134
+ schema = schema.default(def.default)
135
+ }
136
+
137
+ if (!def.required) {
138
+ schema = schema.optional()
139
+ }
140
+
141
+ return schema
142
+ }
143
+
144
+ /**
145
+ * Builds a Zod object schema from a JSON definition map.
146
+ *
147
+ * @param {Record<string, Object>} definition
148
+ * Map of environment variable names to field definitions.
149
+ *
150
+ * @returns {import('zod').ZodObject<any>}
151
+ */
152
+ export function createZodSchema(definition) {
153
+ const shape = Object.entries(definition).reduce((shape, [key, def]) => {
154
+ return {
155
+ ...shape,
156
+ [key]: defToZod(def),
157
+ }
158
+ }, {})
159
+
160
+ return z.object(shape)
161
+ }
162
+
163
+ /**
164
+ * Validates values using a JSON definition and Zod.
165
+ *
166
+ * @param {Record<string, Object>} definition
167
+ * @param {Record<string, any>} values
168
+ *
169
+ * @returns {{
170
+ * success: true,
171
+ * data: Record<string, any>
172
+ * } | {
173
+ * success: false,
174
+ * summary: Record<string, string[]>
175
+ * }}
176
+ */
177
+ export function validateEnv(definition, values) {
178
+ const schema = createZodSchema(definition)
179
+ const result = schema.safeParse(values)
180
+
181
+ if (result.success) {
182
+ return {
183
+ success: true,
184
+ data: result.data,
185
+ }
186
+ }
187
+
188
+ const summary = result.error.issues.reduce((acc, issue) => {
189
+ const key = issue.path[0] || 'root'
190
+ acc[key] = acc[key] || []
191
+ acc[key].push(issue.message)
192
+ return acc
193
+ }, {})
194
+
195
+ return {
196
+ success: false,
197
+ summary,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Builds a structured environment validation report.
203
+ *
204
+ * @param {Record<string, Object>} definition
205
+ * @param {Record<string, any>} values
206
+ * Raw input values (e.g. process.env)
207
+ *
208
+ * @param {{
209
+ * success: boolean,
210
+ * data?: Record<string, any>,
211
+ * summary?: Record<string, string[]>
212
+ * }} validationResult
213
+ *
214
+ * @param {(value: any) => string} mask
215
+ *
216
+ * @returns {{
217
+ * success: boolean,
218
+ * params: Array<{
219
+ * key: string,
220
+ * value: any,
221
+ * displayValue: string,
222
+ * secret: boolean,
223
+ * valid: boolean,
224
+ * errors?: string[]
225
+ * }>
226
+ * }}
227
+ */
228
+ export function buildEnvReport(definition, values, validationResult, mask) {
229
+ const maskValue = typeof mask === 'function' ? mask : defaultMask
230
+
231
+ const params = Object.keys(definition).map((key) => {
232
+ const def = definition[key]
233
+ const isSecret = Boolean(def.secret)
234
+
235
+ // value precedence:
236
+ // 1. validated & parsed value
237
+ // 2. raw input value
238
+ const rawValue = values[key]
239
+ const value = validationResult.success
240
+ ? validationResult.data[key]
241
+ : rawValue
242
+
243
+ const displayValue = isSecret ? maskValue(value) : String(value)
244
+
245
+ const errors = validationResult.success
246
+ ? undefined
247
+ : validationResult.summary?.[key]
248
+
249
+ return {
250
+ key,
251
+ value,
252
+ displayValue,
253
+ secret: isSecret,
254
+ valid: !errors,
255
+ errors,
256
+ }
257
+ })
258
+
259
+ return {
260
+ success: validationResult.success,
261
+ params,
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Formats an environment validation report into a readable table.
267
+ *
268
+ * @param {{
269
+ * success: boolean,
270
+ * params: Array<{
271
+ * key: string,
272
+ * displayValue: string,
273
+ * secret: boolean,
274
+ * valid: boolean,
275
+ * errors?: string[]
276
+ * }>
277
+ * }} report
278
+ *
279
+ * @returns {string}
280
+ */
281
+ export function formatEnvReport(report) {
282
+ const headers = ['STATUS', 'KEY', 'VALUE', 'NOTES']
283
+
284
+ const rows = report.params.map((p) => {
285
+ const status = p.valid ? 'OK' : 'ERROR'
286
+ const notes = p.errors?.join('; ') || ''
287
+
288
+ return [status, p.key, p.displayValue, notes]
289
+ })
290
+
291
+ const allRows = [headers, ...rows]
292
+
293
+ const colWidths = headers.map((_, i) =>
294
+ Math.max(...allRows.map((row) => row[i].length)),
295
+ )
296
+
297
+ const formatRow = (row) =>
298
+ row.map((cell, i) => cell.padEnd(colWidths[i])).join(' ')
299
+
300
+ const lines = allRows.map(formatRow)
301
+
302
+ return [
303
+ 'Environment variables',
304
+ '',
305
+ ...lines,
306
+ '',
307
+ report.success ? 'All variables are valid.' : 'Some variables are invalid.',
308
+ ].join('\n')
309
+ }
package/src/index.js CHANGED
@@ -11,3 +11,5 @@ export * from './templates/index.js'
11
11
  export * from './util/index.js'
12
12
  export * from './instant-messages/index.js'
13
13
  export * from './postgresql/index.js'
14
+ export * from './env/env-validation.js'
15
+ export * from './json/load-json.js'
@@ -0,0 +1,15 @@
1
+ import fs from 'node:fs'
2
+
3
+ /**
4
+ * Loads and parses a JSON file relative to a module URL.
5
+ *
6
+ * @param {string|URL} moduleUrl
7
+ * @param {string} relativePath
8
+ * @returns {any}
9
+ */
10
+ export function loadJson(moduleUrl, relativePath) {
11
+ const fileUrl = new URL(relativePath, moduleUrl)
12
+ const raw = fs.readFileSync(fileUrl, 'utf8')
13
+
14
+ return JSON.parse(raw)
15
+ }
@@ -0,0 +1,132 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect } from 'vitest'
3
+ import { buildEnvReport } from '../../src/env/env-validation.js'
4
+
5
+ const mask = (v) => `***${String(v).slice(-2)}`
6
+
7
+ describe('buildEnvReport', () => {
8
+ it('builds report for successful validation', () => {
9
+ const definition = {
10
+ PORT: { type: 'number' },
11
+ API_KEY: { type: 'string', secret: true },
12
+ DEBUG: { type: 'boolean' },
13
+ }
14
+
15
+ const values = {
16
+ PORT: 3000,
17
+ API_KEY: 'secret123',
18
+ DEBUG: false,
19
+ }
20
+
21
+ const validationResult = {
22
+ success: true,
23
+ data: values,
24
+ }
25
+
26
+ const report = buildEnvReport(definition, values, validationResult, mask)
27
+
28
+ expect(report.success).toBe(true)
29
+ expect(report.params).toHaveLength(3)
30
+
31
+ const apiKey = report.params.find((p) => p.key === 'API_KEY')
32
+ expect(apiKey.secret).toBe(true)
33
+ expect(apiKey.displayValue).toBe('***23')
34
+ expect(apiKey.valid).toBe(true)
35
+ })
36
+
37
+ it('builds report for failed validation with errors', () => {
38
+ const definition = {
39
+ PORT: { type: 'number' },
40
+ DEBUG: { type: 'boolean' },
41
+ }
42
+
43
+ const values = {
44
+ PORT: 0,
45
+ DEBUG: false,
46
+ }
47
+
48
+ const validationResult = {
49
+ success: false,
50
+ summary: {
51
+ PORT: ['Invalid number'],
52
+ },
53
+ }
54
+
55
+ const report = buildEnvReport(definition, values, validationResult, mask)
56
+
57
+ expect(report.success).toBe(false)
58
+
59
+ const port = report.params.find((p) => p.key === 'PORT')
60
+ expect(port.valid).toBe(false)
61
+ expect(port.errors).toEqual(['Invalid number'])
62
+ expect(port.displayValue).toBe('0')
63
+ })
64
+
65
+ it('marks parameter as invalid when errors exist', () => {
66
+ const definition = {
67
+ PORT: { type: 'number' },
68
+ }
69
+
70
+ const values = {
71
+ PORT: 'x',
72
+ }
73
+
74
+ const validationResult = {
75
+ success: false,
76
+ summary: {
77
+ PORT: ['Invalid number'],
78
+ },
79
+ }
80
+
81
+ const report = buildEnvReport(definition, values, validationResult, mask)
82
+
83
+ const port = report.params[0]
84
+ expect(port.valid).toBe(false)
85
+ expect(port.errors).toEqual(['Invalid number'])
86
+ })
87
+
88
+ it('uses defaultMask when mask is not a function', () => {
89
+ const definition = {
90
+ API_KEY: { type: 'string', secret: true },
91
+ }
92
+
93
+ const values = {
94
+ API_KEY: 'secret123',
95
+ }
96
+
97
+ const validationResult = {
98
+ success: true,
99
+ data: values,
100
+ }
101
+
102
+ const report = buildEnvReport(definition, values, validationResult, null)
103
+
104
+ const apiKey = report.params[0]
105
+ expect(apiKey.secret).toBe(true)
106
+ expect(typeof apiKey.displayValue).toBe('string')
107
+ expect(apiKey.displayValue).not.toBe('secret123')
108
+ })
109
+
110
+ it('keeps params order as definition order', () => {
111
+ const definition = {
112
+ A: { type: 'string' },
113
+ B: { type: 'string' },
114
+ C: { type: 'string' },
115
+ }
116
+
117
+ const values = {
118
+ A: '1',
119
+ B: '2',
120
+ C: '3',
121
+ }
122
+
123
+ const validationResult = {
124
+ success: true,
125
+ data: values,
126
+ }
127
+
128
+ const report = buildEnvReport(definition, values, validationResult, mask)
129
+
130
+ expect(report.params.map((p) => p.key)).toEqual(['A', 'B', 'C'])
131
+ })
132
+ })
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { z } from 'zod'
3
+
4
+ import { createZodSchema } from '../../src/env/env-validation.js'
5
+
6
+ describe('createZodSchema', () => {
7
+ it('creates a Zod object schema from definition', () => {
8
+ const definition = {
9
+ PORT: { type: 'number', required: true },
10
+ DEBUG: { type: 'boolean' },
11
+ }
12
+
13
+ const schema = createZodSchema(definition)
14
+
15
+ expect(schema).toBeInstanceOf(z.ZodObject)
16
+ })
17
+
18
+ it('parses valid values successfully', () => {
19
+ const definition = {
20
+ PORT: { type: 'number', required: true, min: 1 },
21
+ DEBUG: { type: 'boolean', default: false },
22
+ }
23
+
24
+ const schema = createZodSchema(definition)
25
+
26
+ const result = schema.parse({
27
+ PORT: '3000',
28
+ })
29
+
30
+ expect(result).toEqual({
31
+ PORT: 3000,
32
+ DEBUG: false,
33
+ })
34
+ })
35
+
36
+ it('throws error when required field is missing', () => {
37
+ const definition = {
38
+ PORT: { type: 'number', required: true },
39
+ DEBUG: { type: 'boolean' },
40
+ }
41
+
42
+ const schema = createZodSchema(definition)
43
+
44
+ expect(() => {
45
+ schema.parse({
46
+ DEBUG: true,
47
+ })
48
+ }).toThrow()
49
+ })
50
+
51
+ it('allows optional fields to be missing', () => {
52
+ const definition = {
53
+ PORT: { type: 'number', required: true },
54
+ DEBUG: { type: 'boolean' },
55
+ }
56
+
57
+ const schema = createZodSchema(definition)
58
+
59
+ const result = schema.parse({
60
+ PORT: 8080,
61
+ })
62
+
63
+ expect(result).toEqual({
64
+ PORT: 8080,
65
+ })
66
+ })
67
+
68
+ it('applies string constraints correctly', () => {
69
+ const definition = {
70
+ NODE_ENV: {
71
+ type: 'string',
72
+ required: true,
73
+ enum: ['development', 'production'],
74
+ },
75
+ }
76
+
77
+ const schema = createZodSchema(definition)
78
+
79
+ expect(() => {
80
+ schema.parse({ NODE_ENV: 'prod' })
81
+ }).toThrow()
82
+
83
+ const result = schema.parse({ NODE_ENV: 'production' })
84
+ expect(result.NODE_ENV).toBe('production')
85
+ })
86
+
87
+ it('applies number constraints correctly', () => {
88
+ const definition = {
89
+ PORT: {
90
+ type: 'number',
91
+ required: true,
92
+ int: true,
93
+ min: 1,
94
+ max: 65535,
95
+ },
96
+ }
97
+
98
+ const schema = createZodSchema(definition)
99
+
100
+ expect(() => {
101
+ schema.parse({ PORT: 0 })
102
+ }).toThrow()
103
+
104
+ expect(() => {
105
+ schema.parse({ PORT: 70000 })
106
+ }).toThrow()
107
+
108
+ const result = schema.parse({ PORT: '3000' })
109
+ expect(result.PORT).toBe(3000)
110
+ })
111
+
112
+ it('validates string formats', () => {
113
+ const definition = {
114
+ DATABASE_URL: {
115
+ type: 'string',
116
+ required: true,
117
+ format: 'url',
118
+ },
119
+ }
120
+
121
+ const schema = createZodSchema(definition)
122
+
123
+ expect(() => {
124
+ schema.parse({ DATABASE_URL: 'not-a-url' })
125
+ }).toThrow()
126
+
127
+ const result = schema.parse({
128
+ DATABASE_URL: 'https://example.com',
129
+ })
130
+
131
+ expect(result.DATABASE_URL).toBe('https://example.com')
132
+ })
133
+
134
+ it('collects multiple validation errors', () => {
135
+ const definition = {
136
+ PORT: { type: 'number', required: true, min: 1 },
137
+ NODE_ENV: {
138
+ type: 'string',
139
+ required: true,
140
+ enum: ['development', 'production'],
141
+ },
142
+ }
143
+
144
+ const schema = createZodSchema(definition)
145
+
146
+ try {
147
+ schema.parse({
148
+ PORT: 0,
149
+ NODE_ENV: 'prod',
150
+ })
151
+ } catch (error) {
152
+ // @ts-ignore
153
+ expect(error.issues).toHaveLength(2)
154
+
155
+ // @ts-ignore
156
+ const paths = error.issues.map((i) => i.path[0])
157
+ expect(paths).toContain('PORT')
158
+ expect(paths).toContain('NODE_ENV')
159
+ }
160
+ })
161
+
162
+ it('coerces boolean values correctly', () => {
163
+ const definition = {
164
+ DEBUG: { type: 'boolean', required: true },
165
+ }
166
+
167
+ const schema = createZodSchema(definition)
168
+
169
+ const result = schema.parse({
170
+ DEBUG: 'true',
171
+ })
172
+
173
+ expect(result.DEBUG).toBe(true)
174
+ })
175
+ })
@@ -0,0 +1,154 @@
1
+ // @ts-nocheck
2
+ import {
3
+ validateEnv,
4
+ buildEnvReport,
5
+ formatEnvReport,
6
+ } from '../../src/env/env-validation.js'
7
+
8
+ // mask example
9
+ const mask = (value) => {
10
+ if (value == null) {
11
+ return '******'
12
+ }
13
+
14
+ const str = String(value)
15
+ if (str.length <= 4) {
16
+ return '****'
17
+ }
18
+
19
+ return `${str.slice(0, 2)}****${str.slice(-2)}`
20
+ }
21
+
22
+ /**
23
+ * Definitions
24
+ */
25
+ const definition = {
26
+ PORT: {
27
+ type: 'number',
28
+ required: true,
29
+ int: true,
30
+ min: 1,
31
+ max: 65535,
32
+ },
33
+
34
+ TIMEOUT: {
35
+ type: 'number',
36
+ min: 100,
37
+ max: 10000,
38
+ },
39
+
40
+ RETRIES: {
41
+ type: 'number',
42
+ int: true,
43
+ min: 0,
44
+ },
45
+
46
+ DEBUG: {
47
+ type: 'boolean',
48
+ },
49
+
50
+ MODE: {
51
+ type: 'string',
52
+ enum: ['development', 'production'],
53
+ },
54
+
55
+ API_KEY: {
56
+ type: 'string',
57
+ secret: true,
58
+ },
59
+ }
60
+
61
+ /**
62
+ * Values scenarios
63
+ */
64
+ const scenarios = [
65
+ {
66
+ title: '✅ All valid values',
67
+ values: {
68
+ PORT: 3000,
69
+ TIMEOUT: 500,
70
+ RETRIES: 3,
71
+ DEBUG: false,
72
+ MODE: 'development',
73
+ API_KEY: 'secret123',
74
+ },
75
+ },
76
+
77
+ {
78
+ title: '❌ Number too small (PORT)',
79
+ values: {
80
+ PORT: 0,
81
+ TIMEOUT: 500,
82
+ RETRIES: 3,
83
+ DEBUG: true,
84
+ MODE: 'production',
85
+ API_KEY: 'secret123',
86
+ },
87
+ },
88
+
89
+ {
90
+ title: '❌ Number too large (TIMEOUT)',
91
+ values: {
92
+ PORT: 3000,
93
+ TIMEOUT: 20000,
94
+ RETRIES: 1,
95
+ DEBUG: false,
96
+ MODE: 'development',
97
+ API_KEY: 'secret123',
98
+ },
99
+ },
100
+
101
+ {
102
+ title: '❌ Invalid number (string)',
103
+ values: {
104
+ PORT: 'abc',
105
+ TIMEOUT: 500,
106
+ RETRIES: 2,
107
+ DEBUG: false,
108
+ MODE: 'development',
109
+ API_KEY: 'secret123',
110
+ },
111
+ },
112
+
113
+ {
114
+ title: '❌ Invalid enum (MODE)',
115
+ values: {
116
+ PORT: 3000,
117
+ TIMEOUT: 500,
118
+ RETRIES: 2,
119
+ DEBUG: false,
120
+ MODE: 'prod',
121
+ API_KEY: 'secret123',
122
+ },
123
+ },
124
+
125
+ {
126
+ title: '✅ Boolean coercion + missing secret',
127
+ values: {
128
+ PORT: '8080',
129
+ TIMEOUT: '1000',
130
+ RETRIES: '0',
131
+ DEBUG: 'true',
132
+ MODE: 'production',
133
+ },
134
+ },
135
+ ]
136
+
137
+ /**
138
+ * Run scenarios
139
+ */
140
+ for (const scenario of scenarios) {
141
+ console.log('\n===================================================')
142
+ console.log(scenario.title)
143
+ console.log('===================================================\n')
144
+
145
+ const validationResult = validateEnv(definition, scenario.values)
146
+ const report = buildEnvReport(
147
+ definition,
148
+ scenario.values,
149
+ validationResult,
150
+ mask,
151
+ )
152
+
153
+ console.log(formatEnvReport(report))
154
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { validateEnv } from '../../src/env/env-validation.js'
3
+
4
+ describe('validateEnv', () => {
5
+ it('returns success result when values are valid', () => {
6
+ const definition = {
7
+ PORT: { type: 'number', required: true, min: 1 },
8
+ DEBUG: { type: 'boolean', default: false },
9
+ }
10
+
11
+ const values = {
12
+ PORT: '3000',
13
+ }
14
+
15
+ const result = validateEnv(definition, values)
16
+
17
+ expect(result.success).toBe(true)
18
+ // @ts-ignore
19
+ expect(result.data).toEqual({
20
+ PORT: 3000,
21
+ DEBUG: false,
22
+ })
23
+ })
24
+
25
+ it('returns error result with summary when required field is missing', () => {
26
+ const definition = {
27
+ PORT: { type: 'number', required: true },
28
+ DEBUG: { type: 'boolean' },
29
+ }
30
+
31
+ const values = {
32
+ DEBUG: true,
33
+ }
34
+
35
+ const result = validateEnv(definition, values)
36
+
37
+ expect(result.success).toBe(false)
38
+ // @ts-ignore
39
+ expect(result.summary).toBeDefined()
40
+ // @ts-ignore
41
+ expect(result.summary).toHaveProperty('PORT')
42
+ // @ts-ignore
43
+ expect(result.summary.PORT.length).toBeGreaterThan(0)
44
+ })
45
+
46
+ it('returns summary with errors for the field when value is invalid', () => {
47
+ const definition = {
48
+ PORT: {
49
+ type: 'number',
50
+ required: true,
51
+ int: true,
52
+ min: 10,
53
+ },
54
+ }
55
+
56
+ const values = {
57
+ PORT: '5.5',
58
+ }
59
+
60
+ const result = validateEnv(definition, values)
61
+
62
+ expect(result.success).toBe(false)
63
+ // @ts-ignore
64
+ expect(result.summary).toHaveProperty('PORT')
65
+ // @ts-ignore
66
+ expect(result.summary.PORT.length).toBeGreaterThan(0)
67
+ })
68
+
69
+ it('returns summary with errors from multiple fields', () => {
70
+ const definition = {
71
+ PORT: { type: 'number', required: true, min: 1 },
72
+ NODE_ENV: {
73
+ type: 'string',
74
+ required: true,
75
+ enum: ['development', 'production'],
76
+ },
77
+ }
78
+
79
+ const values = {
80
+ PORT: 0,
81
+ NODE_ENV: 'prod',
82
+ }
83
+
84
+ const result = validateEnv(definition, values)
85
+
86
+ expect(result.success).toBe(false)
87
+ // @ts-ignore
88
+ expect(result.summary).toHaveProperty('PORT')
89
+ // @ts-ignore
90
+ expect(result.summary).toHaveProperty('NODE_ENV')
91
+ })
92
+
93
+ it('does not include summary when validation succeeds', () => {
94
+ const definition = {
95
+ PORT: { type: 'number', required: true },
96
+ }
97
+
98
+ const values = {
99
+ PORT: 8080,
100
+ }
101
+
102
+ const result = validateEnv(definition, values)
103
+
104
+ expect(result.success).toBe(true)
105
+ // @ts-ignore
106
+ expect(result.summary).toBeUndefined()
107
+ })
108
+ })
@@ -0,0 +1,2 @@
1
+ {
2
+ "foo": "bar"
@@ -0,0 +1,4 @@
1
+ {
2
+ "foo": "bar",
3
+ "answer": 42
4
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { describe, it, expect } from 'vitest'
4
+
5
+ import { loadJson } from '../../src/json/load-json.js'
6
+
7
+ const __filename = fileURLToPath(import.meta.url)
8
+ const __dirname = path.dirname(__filename)
9
+
10
+ describe('loadJson', () => {
11
+ it('loads and parses a JSON file relative to module URL', () => {
12
+ const moduleUrl = new URL(`file://${__dirname}/dummy.js`)
13
+
14
+ const result = loadJson(moduleUrl, './fixtures/valid.json')
15
+
16
+ expect(result).toEqual({
17
+ foo: 'bar',
18
+ answer: 42,
19
+ })
20
+ })
21
+
22
+ it('throws if file does not exist', () => {
23
+ const moduleUrl = new URL(`file://${__dirname}/dummy.js`)
24
+
25
+ expect(() => {
26
+ loadJson(moduleUrl, './fixtures/missing.json')
27
+ }).toThrow()
28
+ })
29
+
30
+ it('throws if JSON is invalid', () => {
31
+ const moduleUrl = new URL(`file://${__dirname}/dummy.js`)
32
+
33
+ expect(() => {
34
+ loadJson(moduleUrl, './fixtures/invalid.json')
35
+ }).toThrow(SyntaxError)
36
+ })
37
+ })
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Converts a single field definition into a Zod schema.
3
+ *
4
+ * @param {Object} def
5
+ * @param {'string'|'number'|'boolean'} def.type
6
+ *
7
+ * @param {boolean} [def.required]
8
+ * Whether the value is required. Defaults to false.
9
+ *
10
+ * @param {*} [def.default]
11
+ * Default value if the variable is missing.
12
+ *
13
+ * @param {boolean} [def.secret]
14
+ * Marks the variable as secret (used only for masking/logging).
15
+ *
16
+ * // string options
17
+ * @param {number} [def.minLength]
18
+ * @param {number} [def.maxLength]
19
+ * @param {string} [def.pattern]
20
+ * @param {string[]} [def.enum]
21
+ * @param {'email'|'url'} [def.format]
22
+ *
23
+ * // number options
24
+ * @param {number} [def.min]
25
+ * @param {number} [def.max]
26
+ * @param {boolean} [def.int]
27
+ * @param {boolean} [def.positive]
28
+ * @param {boolean} [def.negative]
29
+ *
30
+ * @returns {import('zod').ZodTypeAny}
31
+ */
32
+ export function defToZod(def: {
33
+ type: 'string' | 'number' | 'boolean'
34
+ required?: boolean
35
+ default?: any
36
+ secret?: boolean
37
+ minLength?: number
38
+ maxLength?: number
39
+ pattern?: string
40
+ enum?: string[]
41
+ format?: 'email' | 'url'
42
+ min?: number
43
+ max?: number
44
+ int?: boolean
45
+ positive?: boolean
46
+ negative?: boolean
47
+ }): import('zod').ZodTypeAny
48
+ /**
49
+ * Builds a Zod object schema from a JSON definition map.
50
+ *
51
+ * @param {Record<string, Object>} definition
52
+ * Map of environment variable names to field definitions.
53
+ *
54
+ * @returns {import('zod').ZodObject<any>}
55
+ */
56
+ export function createZodSchema(
57
+ definition: Record<string, any>,
58
+ ): import('zod').ZodObject<any>
59
+ /**
60
+ * Validates values using a JSON definition and Zod.
61
+ *
62
+ * @param {Record<string, Object>} definition
63
+ * @param {Record<string, any>} values
64
+ *
65
+ * @returns {{
66
+ * success: true,
67
+ * data: Record<string, any>
68
+ * } | {
69
+ * success: false,
70
+ * summary: Record<string, string[]>
71
+ * }}
72
+ */
73
+ export function validateEnv(
74
+ definition: Record<string, any>,
75
+ values: Record<string, any>,
76
+ ):
77
+ | {
78
+ success: true
79
+ data: Record<string, any>
80
+ }
81
+ | {
82
+ success: false
83
+ summary: Record<string, string[]>
84
+ }
85
+ /**
86
+ * Builds a structured environment validation report.
87
+ *
88
+ * @param {Record<string, Object>} definition
89
+ * @param {Record<string, any>} values
90
+ * Raw input values (e.g. process.env)
91
+ *
92
+ * @param {{
93
+ * success: boolean,
94
+ * data?: Record<string, any>,
95
+ * summary?: Record<string, string[]>
96
+ * }} validationResult
97
+ *
98
+ * @param {(value: any) => string} mask
99
+ *
100
+ * @returns {{
101
+ * success: boolean,
102
+ * params: Array<{
103
+ * key: string,
104
+ * value: any,
105
+ * displayValue: string,
106
+ * secret: boolean,
107
+ * valid: boolean,
108
+ * errors?: string[]
109
+ * }>
110
+ * }}
111
+ */
112
+ export function buildEnvReport(
113
+ definition: Record<string, any>,
114
+ values: Record<string, any>,
115
+ validationResult: {
116
+ success: boolean
117
+ data?: Record<string, any>
118
+ summary?: Record<string, string[]>
119
+ },
120
+ mask: (value: any) => string,
121
+ ): {
122
+ success: boolean
123
+ params: Array<{
124
+ key: string
125
+ value: any
126
+ displayValue: string
127
+ secret: boolean
128
+ valid: boolean
129
+ errors?: string[]
130
+ }>
131
+ }
132
+ /**
133
+ * Formats an environment validation report into a readable table.
134
+ *
135
+ * @param {{
136
+ * success: boolean,
137
+ * params: Array<{
138
+ * key: string,
139
+ * displayValue: string,
140
+ * secret: boolean,
141
+ * valid: boolean,
142
+ * errors?: string[]
143
+ * }>
144
+ * }} report
145
+ *
146
+ * @returns {string}
147
+ */
148
+ export function formatEnvReport(report: {
149
+ success: boolean
150
+ params: Array<{
151
+ key: string
152
+ displayValue: string
153
+ secret: boolean
154
+ valid: boolean
155
+ errors?: string[]
156
+ }>
157
+ }): string
package/types/index.d.ts CHANGED
@@ -11,3 +11,5 @@ export * from './templates/index.js'
11
11
  export * from './util/index.js'
12
12
  export * from './instant-messages/index.js'
13
13
  export * from './postgresql/index.js'
14
+ export * from './env/env-validation.js'
15
+ export * from './json/load-json.js'
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Loads and parses a JSON file relative to a module URL.
3
+ *
4
+ * @param {string|URL} moduleUrl
5
+ * @param {string} relativePath
6
+ * @returns {any}
7
+ */
8
+ export function loadJson(moduleUrl: string | URL, relativePath: string): any
@@ -1,17 +1,28 @@
1
1
  /**
2
- * Applies ORDER BY clause.
2
+ * Applies ORDER BY clause(s) to a Knex query builder.
3
+ *
4
+ * Supports a single orderBy object or an array of orderBy objects.
5
+ * Validates order direction to prevent invalid SQL.
3
6
  *
4
7
  * @param {Object} params
5
- * @param {import('knex').Knex.QueryBuilder} params.query
6
- * @param {{ column: string, direction?: 'asc'|'desc' }} params.orderBy
8
+ * @param {import('knex').Knex.QueryBuilder} params.query - Knex query builder instance
9
+ * @param {OrderByItem | OrderByItem[]} params.orderBy - Order by definition(s)
10
+ * @returns {import('knex').Knex.QueryBuilder} The modified query builder
7
11
  */
8
12
  export function applyOrderBy({
9
13
  query,
10
14
  orderBy,
11
15
  }: {
12
16
  query: import('knex').Knex.QueryBuilder
13
- orderBy: {
14
- column: string
15
- direction?: 'asc' | 'desc'
16
- }
17
- }): import('knex').Knex.QueryBuilder<any, any>
17
+ orderBy: OrderByItem | OrderByItem[]
18
+ }): import('knex').Knex.QueryBuilder
19
+ export type OrderByItem = {
20
+ /**
21
+ * - Column name to order by
22
+ */
23
+ column: string
24
+ /**
25
+ * - Order direction
26
+ */
27
+ direction?: 'asc' | 'desc'
28
+ }