@empyria/common 0.1.9 → 0.3.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/README.md CHANGED
@@ -41,14 +41,14 @@ import { defineSchema } from '@empyria/common/lib/Ata.js'
41
41
 
42
42
  ## Modules
43
43
 
44
- | Module | Purpose |
45
- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
- | [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
47
- | [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors. |
48
- | [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults. |
49
- | [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`) or non-throwing/structured (`createChecker`, `checkSchema`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). |
50
- | [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
51
- | [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
44
+ | Module | Purpose |
45
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | [index.js](./index.js) | Package entry point; re-exports every module below plus Moleculer meta helpers (`moleculerMeta`, `moleculerEmpyriaMeta`) and workflow ID helpers (`workflowName`, `workflowId`, `identifyWorkflow`). |
47
+ | [lib/Errors.js](./lib/Errors.js) | `EmpyriaError`, the `ErrorCreator` factory, and `BaseErrors` — Empyria's catalog of pre-built, templated errors. |
48
+ | [lib/Env.js](./lib/Env.js) | `createEnv` — builds a service's environment-variable JSON schema from Empyria-wide defaults; pass `{ closed: true }` to reject undeclared vars and bind `{ coerceTypes, removeAdditional }` to the schema. |
49
+ | [lib/Ata.js](./lib/Ata.js) | JSON schema building blocks (`string`, `number`, `integer`, `bool`, `enumType`, `constant`, `nullable`, `array`, `discriminated`, `email`, `isoDateTime`, ...) and validation — throwing (`defineSchema`, `createValidator`, `validate`), transform-intent aliases (`parse`, `createParser`), or non-throwing/structured (`createChecker`, `checkSchema`, now returning the parsed `data`) — built on [ata-validator](https://www.npmjs.com/package/ata-validator). See _Schema builders_ and _Validation options_ below. |
50
+ | [lib/Codogram.js](./lib/Codogram.js) | `generateCodogram` — one-time challenge codes (PIN, alphanumeric, alphabetic) for SMS/TOTP-style verification flows. |
51
+ | [lib/Defaulter.js](./lib/Defaulter.js) | `defaultsFromSchema` — builds a fully-populated default object for a JSON schema. |
52
52
 
53
53
  Every exported function is documented with JSDoc directly in its source file — hovering
54
54
  a function in VSCode or Zed shows its parameters and return type without any extra
@@ -56,6 +56,71 @@ tooling, since both editors read JSDoc from plain `.js` files automatically.
56
56
 
57
57
  Tests live under [test/](./test/), one file per module, separate from the `lib/` sources.
58
58
 
59
+ ## Validation options
60
+
61
+ `createValidator` / `validate` / `parse` / `createChecker` / `checkSchema` forward
62
+ [`ata-validator`'s options](https://www.npmjs.com/package/ata-validator) to the underlying
63
+ `Validator`. Options are resolved in three layers, **last wins**:
64
+
65
+ 1. **library defaults** — `setAtaDefaults({ ... })`, called once at startup for a
66
+ deployment-wide policy; `getAtaDefaults()` reads it back, `setAtaDefaults()` clears it.
67
+ 2. **schema-bound** — `defineSchema(map, { validation: { ... } })` stores options on a
68
+ non-enumerable key, so a schema carries its own parsing policy (`schemaOptions(schema)`
69
+ reads it). The schema still serialises and compares as plain JSON Schema.
70
+ 3. **per-call** — the `options` argument to any of the functions above.
71
+
72
+ `formats` is merged one level deep, with `EMPYRIA_FORMATS` always underneath.
73
+
74
+ | Option | What it does (Empyria relevance) |
75
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
76
+ | `coerceTypes` | `"4040"` → `4040` before validation. The reason `env.js` files work — every `process.env` value is a string. |
77
+ | `useDefaults` | Fill missing props from their schema `default`. **On by default**; every `string()/number()/bool()/enumType()` emits one. |
78
+ | `removeAdditional` | Strip keys not covered by the schema. Only visible with `additionalProperties: false` (i.e. `defineSchema({ strict: true })`). |
79
+ | `assertFormat` | Whether a `format` mismatch is a hard error. On by default. |
80
+ | `abortEarly` | Stop at the first error, skip enrichment. For hot verdict-only paths (pair with `createChecker`, not the throwing path). |
81
+ | `richErrors` | Keep `ATA####` codes / doc URLs / hints that `renderPretty` & co. consume. On by default; leave it on. |
82
+ | `verbose` | Add `parentSchema` to each error (ajv's `verbose`). |
83
+ | `formats` | Custom `format` checkers. `EMPYRIA_FORMATS` (`empyria-email`, `empyria-password`, `empyria-ipv4`, `empyria-ipv6`) is folded in automatically. |
84
+
85
+ **Return value / mutation.** `createValidator` & `validate` return `ata-validator`'s
86
+ `result.data` — the same object as the input unless `coerceTypes` / `removeAdditional` /
87
+ `useDefaults` applied. Those passes also **mutate the input object in place**, so hand them a
88
+ copy when the caller must keep its input pristine (as the `env.js` files do with
89
+ `{ ...process.env }`). `parse` / `createParser` are behavioural aliases for call sites whose
90
+ intent is to transform rather than merely assert. `createChecker` / `checkSchema` now also
91
+ return `data` (the parsed value on success, `undefined` on failure).
92
+
93
+ ## Schema builders
94
+
95
+ Each returns a plain JSON Schema fragment; compose them in `defineSchema(map, opts)`.
96
+
97
+ | Builder | Produces |
98
+ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
99
+ | `bool(default?)` | `{ type: 'boolean' }` |
100
+ | `string(default?, minLength \| { minLength, maxLength, pattern, format, description })` | `{ type: 'string', … }` |
101
+ | `number(default?, { min, max, exclusiveMin, exclusiveMax, multipleOf, description })` | `{ type: 'number', … }` |
102
+ | `integer(default?, …)` | `{ type: 'integer', … }` — use for ports/counts; rejects `3.5` |
103
+ | `enumType(list, default?, { type, description })` | `{ enum, type? }` |
104
+ | `constant(value, { description })` | `{ const: value }` |
105
+ | `nullable(schema)` | `type: [t, 'null']`, or `{ anyOf: [schema, { type: 'null' }] }` |
106
+ | `array(items?, { minItems, maxItems, uniqueItems, default, description })` | `{ type: 'array', … }` |
107
+ | `email() / password() / ip4() / ip6()` | `{ type: 'string', format: 'empyria-*' }` (see `EMPYRIA_FORMATS`) |
108
+ | `isoDateTime() / isoDate() / isoTime() / duration() / uri() / uuid()` | `{ type: 'string', format: … }` (ata built-in formats) |
109
+ | `logLevel() / nodeEnv() / tmpFile() / logFile()` | domain presets |
110
+ | `discriminated(prop, { value: schema, … }, { description })` | JSON Schema **v1** `propertyDependencies` — an object whose shape depends on `prop`'s value |
111
+
112
+ ### Dialect
113
+
114
+ `defineSchema(map, { dialect: 'v1' \| '2020-12' \| 'draft-07' \| <uri> })` stamps `$schema`
115
+ (`ata-validator` reads it to pick the dialect). `setDefaultDialect('v1')` / `getDefaultDialect()`
116
+ set a process-wide default a per-call `dialect` overrides. v1 differs from 2020-12 only in
117
+ `$dynamicRef` scoping and unlocks `propertyDependencies`; `discriminated()` works on any
118
+ dialect (ata supports the keyword as an extension) but `dialect: 'v1'` makes it spec-legitimate.
119
+
120
+ `defineSchema(map, { strict: 'unevaluated' })` sets `unevaluatedProperties: false` while
121
+ leaving `additionalProperties` open — the composable form of strict, for schemas meant to be
122
+ `allOf`-combined.
123
+
59
124
  ## Scripts
60
125
 
61
126
  ```bash
package/lib/Ata.js CHANGED
@@ -31,6 +31,134 @@ export const P_IPv6 = `
31
31
  .replace(/\n/g, '')
32
32
  .trim()
33
33
 
34
+ const RE_EMAIL = new RegExp(`^${P_EMAIL}`, 'i')
35
+ const RE_PASS = new RegExp(P_PASS)
36
+ const RE_IPv4 = new RegExp(`^${P_IPv4}`)
37
+ const RE_IPv6 = new RegExp(`^${P_IPv6}`)
38
+
39
+ /**
40
+ * Empyria's custom `format` checkers, keyed by the `format` name a schema references.
41
+ * Registered automatically on every validator this module builds (see `resolveOptions`), so
42
+ * a schema carrying `{ type: 'string', format: 'empyria-email' }` — as produced by
43
+ * {@link email}, {@link password}, {@link ip4}, {@link ip6} — is enforced with no per-call
44
+ * wiring.
45
+ *
46
+ * The names are deliberately prefixed rather than reusing `email` / `ipv4` / `ipv6`:
47
+ * `ata-validator`'s compiled path only lets a custom `formats` entry override a *built-in*
48
+ * name at the schema root, not on a nested property, whereas an unknown name is honoured
49
+ * everywhere. A caller's own `options.formats` entry for the same key still wins.
50
+ *
51
+ * A schema from {@link email} & co. is only enforced when validated through this module (or
52
+ * through a validator the caller seeded with this map); a bare `new Validator(schema)` sees
53
+ * these as unknown (ignored) formats.
54
+ * @type {Record<string, (value: unknown) => boolean>}
55
+ */
56
+ export const EMPYRIA_FORMATS = {
57
+ 'empyria-email': (s) => typeof s === 'string' && RE_EMAIL.test(s),
58
+ 'empyria-password': (s) => typeof s === 'string' && RE_PASS.test(s),
59
+ 'empyria-ipv4': (s) => typeof s === 'string' && RE_IPv4.test(s),
60
+ 'empyria-ipv6': (s) => typeof s === 'string' && RE_IPv6.test(s),
61
+ }
62
+
63
+ /** Non-enumerable key under which {@link defineSchema} stashes a schema's bound options. */
64
+ const SCHEMA_OPTIONS = Symbol('ataOptions')
65
+
66
+ /**
67
+ * Known JSON Schema dialect URIs, keyed by short name. `defineSchema({ dialect })` accepts a
68
+ * short name or a raw URI and stamps it as the schema's `$schema`. `ata-validator` reads
69
+ * `$schema` to pick the dialect; `'v1'` (the forthcoming IETF standard) differs from
70
+ * `'2020-12'` only in `$dynamicRef` scoping and unlocks `propertyDependencies` (see
71
+ * {@link discriminated}).
72
+ */
73
+ export const DIALECTS = {
74
+ '2020-12': 'https://json-schema.org/draft/2020-12/schema',
75
+ 'draft-07': 'http://json-schema.org/draft-07/schema#',
76
+ v1: 'https://json-schema.org/v1',
77
+ }
78
+
79
+ /** Process-wide default dialect for {@link defineSchema}; `null` = emit no `$schema`. */
80
+ let defaultDialect = null
81
+
82
+ /**
83
+ * Sets the process-wide default dialect for {@link defineSchema} — the `$schema` it stamps
84
+ * when a call passes no `dialect` of its own. Call once at startup to move a service to a
85
+ * dialect (e.g. `'v1'`) without touching every schema.
86
+ * @param {('2020-12'|'draft-07'|'v1'|string|null)} [dialect=null] - Short name, raw URI, or
87
+ * `null` to go back to emitting no `$schema`.
88
+ * @returns {void}
89
+ */
90
+ export function setDefaultDialect(dialect = null) {
91
+ defaultDialect = dialect
92
+ }
93
+
94
+ /**
95
+ * Returns the process-wide default dialect (see {@link setDefaultDialect}).
96
+ * @returns {string|null}
97
+ */
98
+ export function getDefaultDialect() {
99
+ return defaultDialect
100
+ }
101
+
102
+ /** Process-wide default `ata-validator` options; the base layer for every call here. */
103
+ let libraryDefaults = {}
104
+
105
+ /**
106
+ * Sets this module's process-wide default `ata-validator` options — the base layer every
107
+ * {@link createValidator} / {@link validate} / {@link parse} / {@link createChecker} call in
108
+ * this process starts from. Meant to be called once at startup to express a deployment-wide
109
+ * policy (e.g. `{ coerceTypes: true }`). Individual calls stay free to override any key, and
110
+ * a schema built by {@link defineSchema} with a `validation` bag overrides these too.
111
+ *
112
+ * Merge order, lowest to highest precedence: **library defaults → schema-bound → per-call**.
113
+ * `formats` is merged one level deep, with {@link EMPYRIA_FORMATS} always underneath.
114
+ * @param {import('ata-validator').ValidatorOptions} [options={}] - Replaces the current
115
+ * defaults wholesale. Pass `{}` (or call with no argument) to clear them.
116
+ * @returns {void}
117
+ */
118
+ export function setAtaDefaults(options = {}) {
119
+ libraryDefaults = { ...options }
120
+ }
121
+
122
+ /**
123
+ * Returns a shallow copy of the current process-wide default options (see {@link setAtaDefaults}).
124
+ * @returns {import('ata-validator').ValidatorOptions}
125
+ */
126
+ export function getAtaDefaults() {
127
+ return { ...libraryDefaults }
128
+ }
129
+
130
+ /**
131
+ * Returns a shallow copy of the `ata-validator` options bound to `schema` by
132
+ * {@link defineSchema}'s `validation` option, or `{}` if none were bound.
133
+ * @param {Object} schema
134
+ * @returns {import('ata-validator').ValidatorOptions}
135
+ */
136
+ export function schemaOptions(schema) {
137
+ const bound = schema?.[SCHEMA_OPTIONS]
138
+ return bound ? { ...bound } : {}
139
+ }
140
+
141
+ /**
142
+ * Merges the three option layers for a single call and folds in {@link EMPYRIA_FORMATS}.
143
+ * @param {Object} schema - Schema being validated (may carry bound options).
144
+ * @param {import('ata-validator').ValidatorOptions} [perCall] - Options passed to this call.
145
+ * @returns {import('ata-validator').ValidatorOptions}
146
+ */
147
+ function resolveOptions(schema, perCall) {
148
+ const bound = schema?.[SCHEMA_OPTIONS] ?? {}
149
+ return {
150
+ ...libraryDefaults,
151
+ ...bound,
152
+ ...perCall,
153
+ formats: {
154
+ ...EMPYRIA_FORMATS,
155
+ ...libraryDefaults.formats,
156
+ ...bound.formats,
157
+ ...(perCall && perCall.formats),
158
+ },
159
+ }
160
+ }
161
+
34
162
  /**
35
163
  * Builds a function that clones only the keys declared on a schema's `properties`.
36
164
  * @param {Object} schema - JSON schema with a `properties` map.
@@ -45,12 +173,27 @@ export function cloneBySchema(schema) {
45
173
  * Builds a JSON schema object from a flat map of property schemas.
46
174
  * @param {Object} schemaDef - Map of property name to its JSON schema.
47
175
  * @param {Object} [options={}]
48
- * @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`.
176
+ * @param {(boolean|'unevaluated')} [options.strict] - `true` disallows properties not listed
177
+ * in `schemaDef` (`additionalProperties: false`); combine with
178
+ * `validation: { removeAdditional: true }` to *strip* unknown keys instead of rejecting
179
+ * them. `'unevaluated'` sets `unevaluatedProperties: false` and leaves `additionalProperties`
180
+ * open — the composable form of strict, for a schema meant to be `allOf`-combined. Ignored
181
+ * when `options.additionalProperties` is given.
182
+ * @param {boolean|Object} [options.additionalProperties] - Explicit `additionalProperties`
183
+ * value (a boolean, or a sub-schema for the extra keys). Overrides `options.strict`.
184
+ * @param {('2020-12'|'draft-07'|'v1'|string)} [options.dialect] - Stamp this dialect as the
185
+ * schema's `$schema` (short name from {@link DIALECTS}, or a raw URI). Defaults to
186
+ * {@link getDefaultDialect}; when both are unset no `$schema` is emitted.
49
187
  * @param {boolean} [options.optional] - If true, a property whose own schema carries
50
188
  * `optional: true` is excluded from `required` (that marker is then stripped from the
51
189
  * property itself — never leaked into the resulting schema, since it isn't a real JSON
52
190
  * Schema keyword). Off by default: every declared property is required, exactly as before —
53
191
  * a caller that never sets `optional: true` on any property sees no difference either way.
192
+ * @param {import('ata-validator').ValidatorOptions} [options.validation] - `ata-validator`
193
+ * options bound to the returned schema: every {@link createValidator} / {@link validate} /
194
+ * {@link parse} / {@link createChecker} call for it starts from these (below per-call
195
+ * options, above {@link setAtaDefaults}). Stored on a non-enumerable key, so the schema
196
+ * still serialises and compares as a plain JSON Schema.
54
197
  * @returns {Object} A JSON schema: `{ type: 'object', properties, required, additionalProperties }`.
55
198
  */
56
199
  export function defineSchema(schemaDef, options = {}) {
@@ -66,11 +209,25 @@ export function defineSchema(schemaDef, options = {}) {
66
209
  required.push(key)
67
210
  }
68
211
  }
212
+ const unevaluated = options.strict === 'unevaluated'
69
213
  const schema = {
70
214
  type: 'object',
71
215
  properties,
72
216
  required,
73
- additionalProperties: !options.strict,
217
+ additionalProperties: defined(options.additionalProperties)
218
+ ? options.additionalProperties
219
+ : !options.strict || unevaluated,
220
+ }
221
+ if (unevaluated) schema.unevaluatedProperties = false
222
+
223
+ const dialect = options.dialect ?? defaultDialect
224
+ if (dialect) schema.$schema = DIALECTS[dialect] ?? dialect
225
+
226
+ if (options.validation) {
227
+ Object.defineProperty(schema, SCHEMA_OPTIONS, {
228
+ value: { ...options.validation },
229
+ enumerable: false,
230
+ })
74
231
  }
75
232
 
76
233
  return schema
@@ -78,17 +235,26 @@ export function defineSchema(schemaDef, options = {}) {
78
235
 
79
236
  /**
80
237
  * Builds a validating function for a JSON schema.
238
+ *
239
+ * Options are resolved in three layers — {@link setAtaDefaults} → the schema's bound
240
+ * `validation` (see {@link defineSchema}) → the `options` argument here — with the last
241
+ * winning, and {@link EMPYRIA_FORMATS} always folded into `formats`.
242
+ *
243
+ * The returned function returns the schema-shaped value: `ata-validator`'s `result.data`,
244
+ * which is `data` itself unless `coerceTypes` / `removeAdditional` / `useDefaults` (the last
245
+ * on by default) applied. Those passes also mutate `data` in place, so pass a copy when the
246
+ * caller must keep its input pristine (see `createEnv` / the `env.js` files).
81
247
  * @param {Object} schema - JSON schema to validate against.
82
- * @param {Object} [options] - Forwarded to `ata-validator`'s `Validator` constructor
83
- * (`coerceTypes`, `removeAdditional`, `useDefaults`, ...). Left unset, validation is
84
- * strict: a schema expecting `type: 'number'` rejects `"32"`. Pass `{ coerceTypes: true }`
85
- * for input that's inherently string-typed, such as `process.env` (see `createEnv`).
86
- * @returns {(data: *) => *} Function that returns `data` unchanged if valid.
248
+ * @param {import('ata-validator').ValidatorOptions} [options] - Per-call options (see above):
249
+ * `coerceTypes`, `removeAdditional`, `useDefaults`, `assertFormat`, `abortEarly`,
250
+ * `richErrors`, `verbose`, `formats`, ... Left unset, validation is strict: a schema
251
+ * expecting `type: 'number'` rejects `"32"`.
252
+ * @returns {(data: *) => *} Function that returns the (possibly coerced/defaulted) value.
87
253
  * @throws {EmpyriaError} `BaseErrors.ValidationError` (with a pretty-rendered violation list
88
254
  * in its `validation` param) if `data` doesn't satisfy the schema.
89
255
  */
90
256
  export function createValidator(schema, options) {
91
- const v = new Validator(schema, options)
257
+ const v = new Validator(schema, resolveOptions(schema, options))
92
258
 
93
259
  return (data) => {
94
260
  const result = v.validate(data)
@@ -99,7 +265,7 @@ export function createValidator(schema, options) {
99
265
  validation: renderPretty(result.errors),
100
266
  })
101
267
  }
102
- return data
268
+ return result.data ?? data
103
269
  }
104
270
  }
105
271
 
@@ -107,8 +273,8 @@ export function createValidator(schema, options) {
107
273
  * Validates `input` against `schema` in one call.
108
274
  * @param {Object} schema - JSON schema to validate against.
109
275
  * @param {*} input - Value to validate.
110
- * @param {Object} [options] - Forwarded to {@link createValidator} (see its docs).
111
- * @returns {*} `input` unchanged, if valid.
276
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createValidator}.
277
+ * @returns {*} `input` — coerced/defaulted/stripped per the resolved options — if valid.
112
278
  * @throws {EmpyriaError} `BaseErrors.ValidationError` if `input` doesn't satisfy the schema.
113
279
  */
114
280
  export function validate(schema, input, options) {
@@ -118,25 +284,57 @@ export function validate(schema, input, options) {
118
284
  return validatedInput
119
285
  }
120
286
 
287
+ /**
288
+ * {@link createValidator} under an intent-revealing name, for call sites whose purpose is to
289
+ * *transform* input — coerce string-typed values, strip unknown keys, apply schema defaults —
290
+ * rather than merely assert its shape. Behaviour is identical; the name documents that the
291
+ * returned value is expected to differ from the input.
292
+ * @param {Object} schema - JSON schema to validate/parse against.
293
+ * @param {import('ata-validator').ValidatorOptions} [options] - Typically `{ coerceTypes: true }`
294
+ * and/or `{ removeAdditional: true }` (with a closed schema).
295
+ * @returns {(data: *) => *} Function returning the parsed value.
296
+ * @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
297
+ */
298
+ export function createParser(schema, options) {
299
+ return createValidator(schema, options)
300
+ }
301
+
302
+ /**
303
+ * {@link validate} under an intent-revealing name — see {@link createParser}.
304
+ * @param {Object} schema - JSON schema to parse against.
305
+ * @param {*} input - Value to parse.
306
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createParser}.
307
+ * @returns {*} The parsed (coerced/defaulted/stripped) value, if valid.
308
+ * @throws {EmpyriaError} `BaseErrors.ValidationError` on invalid input.
309
+ */
310
+ export function parse(schema, input, options) {
311
+ return createParser(schema, options)(input)
312
+ }
313
+
121
314
  /**
122
315
  * Builds a non-throwing checking function for a JSON schema — the structured-result
123
316
  * counterpart to {@link createValidator}. Use it when a caller needs `ata-validator`'s own
124
317
  * error list (to build its own error type, count violations, or render them itself with
125
318
  * {@link renderCompact} / {@link renderJSON}) rather than a `BaseErrors.ValidationError`
126
- * carrying a pre-rendered string.
319
+ * carrying a pre-rendered string, or when it wants the parsed value without a `try`/`catch`.
127
320
  * @param {Object} schema - JSON schema to validate against.
128
- * @param {Object} [options] - Forwarded to `ata-validator`'s `Validator` constructor
129
- * (see {@link createValidator} for the common flags).
130
- * @returns {(data: *) => { valid: boolean, errors: Array<Object> }} Function that returns the
131
- * verdict and, when invalid, `ata-validator`'s raw error objects (empty array when valid).
132
- * Never throws on a validation failure.
321
+ * @param {import('ata-validator').ValidatorOptions} [options] - Resolved the same way as for
322
+ * {@link createValidator} (see its docs for the layers and the common flags).
323
+ * @returns {(data: *) => { valid: boolean, data: *, errors: Array<Object> }} Function that
324
+ * returns the verdict, the parsed value (`data` when valid — coerced/defaulted/stripped per
325
+ * the resolved options; `undefined` when invalid), and, when invalid, `ata-validator`'s raw
326
+ * error objects (empty array when valid). Never throws on a validation failure.
133
327
  */
134
328
  export function createChecker(schema, options) {
135
- const v = new Validator(schema, options)
329
+ const v = new Validator(schema, resolveOptions(schema, options))
136
330
 
137
331
  return (data) => {
138
332
  const result = v.validate(data)
139
- return { valid: result.valid, errors: result.valid ? [] : result.errors }
333
+ return {
334
+ valid: result.valid,
335
+ data: result.valid ? (result.data ?? data) : undefined,
336
+ errors: result.valid ? [] : result.errors,
337
+ }
140
338
  }
141
339
  }
142
340
 
@@ -144,9 +342,9 @@ export function createChecker(schema, options) {
144
342
  * Checks `input` against `schema` in one call, without throwing.
145
343
  * @param {Object} schema - JSON schema to validate against.
146
344
  * @param {*} input - Value to validate.
147
- * @param {Object} [options] - Forwarded to {@link createChecker} (see its docs).
148
- * @returns {{ valid: boolean, errors: Array<Object> }} The verdict, plus `ata-validator`'s raw
149
- * error objects when invalid (empty array when valid).
345
+ * @param {import('ata-validator').ValidatorOptions} [options] - Forwarded to {@link createChecker}.
346
+ * @returns {{ valid: boolean, data: *, errors: Array<Object> }} The verdict, the parsed value
347
+ * (when valid), plus `ata-validator`'s raw error objects when invalid.
150
348
  */
151
349
  export function checkSchema(schema, input, options) {
152
350
  return createChecker(schema, options)(input)
@@ -175,15 +373,26 @@ export function bool(defaultValue) {
175
373
  /**
176
374
  * JSON schema for a string property.
177
375
  * @param {string} [defaultValue] - Default value, if any.
178
- * @param {number} [minLength] - Minimum string length, if greater than 0.
179
- * @returns {Object} `{ type: 'string', default?, minLength? }`.
376
+ * @param {number|Object} [opts] - A bare number is shorthand for `{ minLength }` (kept for
377
+ * back-compat). As an object:
378
+ * @param {number} [opts.minLength] - Minimum length (`0` is honoured, unlike the old
379
+ * number-shorthand which dropped non-positive values).
380
+ * @param {number} [opts.maxLength] - Maximum length.
381
+ * @param {string} [opts.pattern] - `pattern` regex (string form, unanchored per JSON Schema).
382
+ * @param {string} [opts.format] - `format` name (e.g. an {@link EMPYRIA_FORMATS} key or a
383
+ * built-in like `'uri'`; see {@link isoDateTime} & co. for ready-made wrappers).
384
+ * @param {string} [opts.description]
385
+ * @returns {Object} `{ type: 'string', default?, minLength?, maxLength?, pattern?, format?, description? }`.
180
386
  */
181
- export function string(defaultValue, minLength) {
182
- const schema = {
183
- type: 'string',
184
- }
387
+ export function string(defaultValue, opts) {
388
+ const o = typeof opts === 'number' ? { minLength: opts } : (opts ?? {})
389
+ const schema = { type: 'string' }
185
390
  if (defined(defaultValue)) schema.default = `${defaultValue}`
186
- if (minLength > 0) schema.minLength = minLength
391
+ if (defined(o.minLength) && o.minLength >= 0) schema.minLength = o.minLength
392
+ if (defined(o.maxLength)) schema.maxLength = o.maxLength
393
+ if (defined(o.pattern)) schema.pattern = o.pattern
394
+ if (defined(o.format)) schema.format = o.format
395
+ if (defined(o.description)) schema.description = o.description
187
396
  return schema
188
397
  }
189
398
 
@@ -191,13 +400,19 @@ export function string(defaultValue, minLength) {
191
400
  * JSON schema for an enumerated property.
192
401
  * @param {Array} list - Allowed values.
193
402
  * @param {*} [defaultValue] - Default value, if any.
194
- * @returns {Object} `{ enum: list, default? }`.
403
+ * @param {Object} [opts]
404
+ * @param {string} [opts.type] - Optional `type` alongside `enum` (helps tooling / type
405
+ * inference; validation is unchanged since `enum` already pins the values).
406
+ * @param {string} [opts.description]
407
+ * @returns {Object} `{ enum: list, type?, default?, description? }`.
195
408
  */
196
- export function enumType(list, defaultValue) {
409
+ export function enumType(list, defaultValue, opts = {}) {
197
410
  const schema = {
198
411
  enum: list,
199
412
  }
413
+ if (defined(opts.type)) schema.type = opts.type
200
414
  if (defined(defaultValue)) schema.default = defaultValue
415
+ if (defined(opts.description)) schema.description = opts.description
201
416
  return schema
202
417
  }
203
418
 
@@ -237,63 +452,200 @@ export function nodeEnv() {
237
452
  }
238
453
 
239
454
  /**
240
- * JSON schema for a numeric property.
455
+ * @param {'number'|'integer'} type
456
+ * @param {number} [defaultValue]
457
+ * @param {Object} [opts] - `min`/`max` (inclusive → `minimum`/`maximum`),
458
+ * `exclusiveMin`/`exclusiveMax`, `multipleOf`, `description`.
459
+ * @returns {Object}
460
+ */
461
+ function numeric(type, defaultValue, opts = {}) {
462
+ const schema = { type }
463
+ if (defined(defaultValue)) schema.default = Number(defaultValue)
464
+ if (defined(opts.min)) schema.minimum = opts.min
465
+ if (defined(opts.max)) schema.maximum = opts.max
466
+ if (defined(opts.exclusiveMin)) schema.exclusiveMinimum = opts.exclusiveMin
467
+ if (defined(opts.exclusiveMax)) schema.exclusiveMaximum = opts.exclusiveMax
468
+ if (defined(opts.multipleOf)) schema.multipleOf = opts.multipleOf
469
+ if (defined(opts.description)) schema.description = opts.description
470
+ return schema
471
+ }
472
+
473
+ /**
474
+ * JSON schema for a numeric property (`type: 'number'` — accepts integers and reals).
241
475
  * @param {number} [defaultValue] - Default value, if any.
242
- * @returns {Object} `{ type: 'number', default? }`.
476
+ * @param {Object} [opts] - `min`, `max`, `exclusiveMin`, `exclusiveMax`, `multipleOf`,
477
+ * `description` (see {@link numeric}).
478
+ * @returns {Object} `{ type: 'number', default?, minimum?, maximum?, ... }`.
243
479
  */
244
- export function number(defaultValue) {
480
+ export function number(defaultValue, opts) {
481
+ return numeric('number', defaultValue, opts)
482
+ }
483
+
484
+ /**
485
+ * JSON schema for an integer property (`type: 'integer'` — rejects `3.5`, and with
486
+ * `coerceTypes` rejects `"3.5"`). Prefer this over {@link number} for ports, counts, lengths.
487
+ * @param {number} [defaultValue] - Default value, if any.
488
+ * @param {Object} [opts] - Same as {@link number}.
489
+ * @returns {Object} `{ type: 'integer', default?, minimum?, maximum?, ... }`.
490
+ */
491
+ export function integer(defaultValue, opts) {
492
+ return numeric('integer', defaultValue, opts)
493
+ }
494
+
495
+ /**
496
+ * JSON schema for a constant value (`const`). Useful as a discriminator tag or a pinned
497
+ * config value.
498
+ * @param {*} value - The only accepted value (compared structurally).
499
+ * @param {Object} [opts] - `description`.
500
+ * @returns {Object} `{ const: value, description? }`.
501
+ */
502
+ export function constant(value, opts = {}) {
503
+ const schema = { const: value }
504
+ if (defined(opts.description)) schema.description = opts.description
505
+ return schema
506
+ }
507
+
508
+ /**
509
+ * Wraps a schema so `null` is also accepted. A schema with a single string `type` gets
510
+ * `type: [t, 'null']`; anything else (an `enum`, a `$ref`, a composed schema) is wrapped as
511
+ * `{ anyOf: [schema, { type: 'null' }] }`.
512
+ * @param {Object} schema - The schema to make nullable.
513
+ * @returns {Object}
514
+ */
515
+ export function nullable(schema) {
516
+ if (schema && typeof schema.type === 'string') {
517
+ return { ...schema, type: [schema.type, 'null'] }
518
+ }
519
+ return { anyOf: [schema, { type: 'null' }] }
520
+ }
521
+
522
+ /**
523
+ * JSON schema for an array property.
524
+ * @param {Object} [items] - Schema every element must satisfy. Omit for "any element".
525
+ * @param {Object} [opts] - `minItems`, `maxItems`, `uniqueItems` (boolean), `default`,
526
+ * `description`.
527
+ * @returns {Object} `{ type: 'array', items?, minItems?, maxItems?, uniqueItems?, default?, description? }`.
528
+ */
529
+ export function array(items, opts = {}) {
530
+ const schema = { type: 'array' }
531
+ if (defined(items)) schema.items = items
532
+ if (defined(opts.minItems)) schema.minItems = opts.minItems
533
+ if (defined(opts.maxItems)) schema.maxItems = opts.maxItems
534
+ if (opts.uniqueItems) schema.uniqueItems = true
535
+ if (defined(opts.default)) schema.default = opts.default
536
+ if (defined(opts.description)) schema.description = opts.description
537
+ return schema
538
+ }
539
+
540
+ /**
541
+ * JSON Schema v1 `propertyDependencies`: an object that takes a different shape depending on
542
+ * the value of a discriminator property. Replaces the `oneOf` + `if`/`then`/`const` pattern.
543
+ * Validates on every `ata-validator` engine (supported since 1.5.0); pair with
544
+ * `defineSchema`'s `dialect: 'v1'` — or `setDefaultDialect('v1')` — for a spec-legitimate
545
+ * `$schema`.
546
+ * The result is open (`additionalProperties` unset): `additionalProperties: false` cannot be
547
+ * used alongside `propertyDependencies` — it does not see through the branches and would
548
+ * reject every branch key. For a closed union, give each branch schema its own
549
+ * `unevaluatedProperties: false` and add one at this level too.
550
+ * @param {string} property - The discriminator property name.
551
+ * @param {Record<string, Object>} mapping - Discriminator value → the schema the rest of the
552
+ * object must then satisfy.
553
+ * @param {Object} [opts]
554
+ * @param {string} [opts.description]
555
+ * @returns {Object} `{ type: 'object', properties: { [property]: { enum } }, required, propertyDependencies }`.
556
+ */
557
+ export function discriminated(property, mapping, opts = {}) {
245
558
  const schema = {
246
- type: 'number',
559
+ type: 'object',
560
+ properties: { [property]: { enum: Object.keys(mapping) } },
561
+ required: [property],
562
+ propertyDependencies: { [property]: mapping },
247
563
  }
248
- if (defined(defaultValue)) schema.default = Number(defaultValue)
564
+ if (defined(opts.description)) schema.description = opts.description
249
565
  return schema
250
566
  }
251
567
 
568
+ const stringFormat = (format, description) => ({ type: 'string', format, description })
569
+
570
+ /** JSON schema for an RFC 3339 date-time string (`format: 'date-time'`). */
571
+ export function isoDateTime() {
572
+ return stringFormat('date-time', 'ISO 8601 / RFC 3339 date-time')
573
+ }
574
+
575
+ /** JSON schema for an RFC 3339 full-date string (`format: 'date'`). */
576
+ export function isoDate() {
577
+ return stringFormat('date', 'ISO 8601 / RFC 3339 date')
578
+ }
579
+
580
+ /** JSON schema for an RFC 3339 full-time string (`format: 'time'`). */
581
+ export function isoTime() {
582
+ return stringFormat('time', 'ISO 8601 / RFC 3339 time')
583
+ }
584
+
585
+ /** JSON schema for an ISO 8601 duration string (`format: 'duration'`, e.g. `P1DT2H`). */
586
+ export function duration() {
587
+ return stringFormat('duration', 'ISO 8601 duration')
588
+ }
589
+
590
+ /** JSON schema for a URI string (`format: 'uri'`). */
591
+ export function uri() {
592
+ return stringFormat('uri', 'URI')
593
+ }
594
+
595
+ /** JSON schema for a UUID string (`format: 'uuid'`). */
596
+ export function uuid() {
597
+ return stringFormat('uuid', 'UUID')
598
+ }
599
+
252
600
  /**
253
- * JSON schema for an email-address string, pattern-matched against {@link P_EMAIL}.
254
- * @returns {Object} String schema with `pattern` and `description` set.
601
+ * JSON schema for an email-address string, checked against the `email` format
602
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_EMAIL}).
603
+ * @returns {Object} String schema with `format` and `description` set.
255
604
  */
256
605
  export function email() {
257
606
  return {
258
607
  type: 'string',
259
- pattern: P_EMAIL,
608
+ format: 'empyria-email',
260
609
  description: 'Email address',
261
610
  }
262
611
  }
263
612
 
264
613
  /**
265
- * JSON schema for a password string, pattern-matched against {@link P_PASS}
266
- * (requires lower-case, upper-case, a digit, and at least 8 characters).
267
- * @returns {Object} String schema with `pattern` and `description` set.
614
+ * JSON schema for a password string, checked against the `password` format
615
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_PASS}: lower-case, upper-case, a digit,
616
+ * and at least 8 characters).
617
+ * @returns {Object} String schema with `format` and `description` set.
268
618
  */
269
619
  export function password() {
270
620
  return {
271
621
  type: 'string',
272
- pattern: P_PASS,
622
+ format: 'empyria-password',
273
623
  description: 'password',
274
624
  }
275
625
  }
276
626
 
277
627
  /**
278
- * JSON schema for an IPv4-address string, pattern-matched against {@link P_IPv4}.
279
- * @returns {Object} String schema with `pattern` and `description` set.
628
+ * JSON schema for an IPv4-address string, checked against the `ipv4` format
629
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv4}).
630
+ * @returns {Object} String schema with `format` and `description` set.
280
631
  */
281
632
  export function ip4() {
282
633
  return {
283
634
  type: 'string',
284
- pattern: P_IPv4,
635
+ format: 'empyria-ipv4',
285
636
  description: 'IP address v4',
286
637
  }
287
638
  }
288
639
 
289
640
  /**
290
- * JSON schema for an IPv6-address string, pattern-matched against {@link P_IPv6}.
291
- * @returns {Object} String schema with `pattern` and `description` set.
641
+ * JSON schema for an IPv6-address string, checked against the `ipv6` format
642
+ * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_IPv6}).
643
+ * @returns {Object} String schema with `format` and `description` set.
292
644
  */
293
645
  export function ip6() {
294
646
  return {
295
647
  type: 'string',
296
- pattern: P_IPv6,
648
+ format: 'empyria-ipv6',
297
649
  description: 'IP address v6',
298
650
  }
299
651
  }
package/lib/Env.js CHANGED
@@ -27,8 +27,17 @@ const DEFAULT_SERVICE_SCHEMA = {
27
27
  * Builds a JSON schema for a microservice's environment variables, merging the
28
28
  * Empyria-wide defaults with any service-specific additions/overrides.
29
29
  * @param {Object} [specificSchema] - Additional or overriding property schemas, keyed by env var name.
30
+ * @param {Object} [options={}]
31
+ * @param {boolean} [options.closed] - When true, the schema rejects the whole of
32
+ * `process.env` beyond its declared keys and binds `{ coerceTypes: true, removeAdditional:
33
+ * true }` to itself, so `validate`/`parse` return an object holding *only* the declared
34
+ * vars — no undeclared environment riding along into `settings`. Off by default:
35
+ * `additionalProperties: true`, every extra var passes through unchanged.
30
36
  * @returns {Object} A JSON schema object (see `defineSchema` in `./Ata.js`).
31
37
  */
32
- export function createEnv(specificSchema) {
33
- return defineSchema(Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema))
38
+ export function createEnv(specificSchema, { closed = false } = {}) {
39
+ return defineSchema(
40
+ Object.assign({}, DEFAULT_SERVICE_SCHEMA, specificSchema),
41
+ closed ? { strict: true, validation: { coerceTypes: true, removeAdditional: true } } : {},
42
+ )
34
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/common",
3
- "version": "0.1.9",
3
+ "version": "0.3.0",
4
4
  "description": "Shared backend goodies for Empyria",
5
5
  "license": "MIT",
6
6
  "author": "Imre Fazekas <imre.fazekas@icloud.com>",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@empyria/classification": "0.1.6",
23
- "ata-validator": "1.13.2",
23
+ "ata-validator": "1.14.1",
24
24
  "clerobee": "1.0.8",
25
25
  "ms": "2.1.3"
26
26
  },
@@ -29,7 +29,7 @@
29
29
  "oxlint": "1.82.0"
30
30
  },
31
31
  "engines": {
32
- "bun": ">=1.4.0",
32
+ "bun": ">=1.4.2",
33
33
  "node": ">=26"
34
34
  },
35
35
  "packageManager": "bun@1.4.2"
package/test/Ata.test.js CHANGED
@@ -7,8 +7,18 @@ import {
7
7
  cloneBySchema,
8
8
  defineSchema,
9
9
  validate,
10
+ parse,
11
+ createParser,
12
+ createValidator,
10
13
  createChecker,
11
14
  checkSchema,
15
+ schemaOptions,
16
+ setAtaDefaults,
17
+ getAtaDefaults,
18
+ setDefaultDialect,
19
+ getDefaultDialect,
20
+ DIALECTS,
21
+ EMPYRIA_FORMATS,
12
22
  bool,
13
23
  string,
14
24
  enumType,
@@ -17,6 +27,17 @@ import {
17
27
  logFile,
18
28
  nodeEnv,
19
29
  number,
30
+ integer,
31
+ constant,
32
+ nullable,
33
+ array,
34
+ discriminated,
35
+ isoDateTime,
36
+ isoDate,
37
+ isoTime,
38
+ duration,
39
+ uri,
40
+ uuid,
20
41
  email,
21
42
  password,
22
43
  ip4,
@@ -89,6 +110,31 @@ describe('defineSchema', () => {
89
110
  test('disallows extra properties when strict', () => {
90
111
  expect(defineSchema({ a: string() }, { strict: true }).additionalProperties).toBe(false)
91
112
  })
113
+
114
+ test('an explicit additionalProperties overrides strict', () => {
115
+ expect(
116
+ defineSchema({ a: string() }, { strict: true, additionalProperties: true })
117
+ .additionalProperties,
118
+ ).toBe(true)
119
+ expect(
120
+ defineSchema({ a: string() }, { additionalProperties: false }).additionalProperties,
121
+ ).toBe(false)
122
+ })
123
+
124
+ test('binds validation options on a non-enumerable key, invisible to JSON/toEqual', () => {
125
+ const schema = defineSchema(
126
+ { a: string() },
127
+ { validation: { coerceTypes: true, removeAdditional: true } },
128
+ )
129
+ expect(schema).toEqual({
130
+ type: 'object',
131
+ properties: { a: { type: 'string' } },
132
+ required: ['a'],
133
+ additionalProperties: true,
134
+ })
135
+ expect(JSON.stringify(schema)).not.toContain('coerceTypes')
136
+ expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
137
+ })
92
138
  })
93
139
 
94
140
  describe('cloneBySchema', () => {
@@ -171,11 +217,11 @@ describe('schema builders', () => {
171
217
  })
172
218
  })
173
219
 
174
- test('email/password/ip4/ip6 reference their patterns', () => {
175
- expect(email().pattern).toBe(P_EMAIL)
176
- expect(password().pattern).toBe(P_PASS)
177
- expect(ip4().pattern).toBe(P_IPv4)
178
- expect(ip6().pattern).toBe(P_IPv6)
220
+ test('email/password/ip4/ip6 reference their named formats', () => {
221
+ expect(email().format).toBe('empyria-email')
222
+ expect(password().format).toBe('empyria-password')
223
+ expect(ip4().format).toBe('empyria-ipv4')
224
+ expect(ip6().format).toBe('empyria-ipv6')
179
225
  })
180
226
  })
181
227
 
@@ -189,8 +235,16 @@ describe('renderCompact / renderJSON re-exports', () => {
189
235
  describe('createChecker / checkSchema', () => {
190
236
  const schema = defineSchema({ name: string() })
191
237
 
192
- test('checkSchema returns valid:true and no errors for good input', () => {
193
- expect(checkSchema(schema, { name: 'Bob' })).toEqual({ valid: true, errors: [] })
238
+ test('checkSchema returns valid:true, the parsed value, and no errors for good input', () => {
239
+ expect(checkSchema(schema, { name: 'Bob' })).toEqual({
240
+ valid: true,
241
+ data: { name: 'Bob' },
242
+ errors: [],
243
+ })
244
+ })
245
+
246
+ test('checkSchema reports data:undefined for bad input', () => {
247
+ expect(checkSchema(schema, {}).data).toBeUndefined()
194
248
  })
195
249
 
196
250
  test('checkSchema returns valid:false and a non-empty error list for bad input, without throwing', () => {
@@ -205,8 +259,229 @@ describe('createChecker / checkSchema', () => {
205
259
  expect(renderCompact(errors)).toContain('name')
206
260
  })
207
261
 
208
- test('createChecker builds a reusable checker and forwards options (coerceTypes)', () => {
262
+ test('createChecker builds a reusable checker, forwards options, and returns the parsed value', () => {
209
263
  const check = createChecker(defineSchema({ port: number() }), { coerceTypes: true })
210
- expect(check({ port: '4040' })).toEqual({ valid: true, errors: [] })
264
+ expect(check({ port: '4040' })).toEqual({ valid: true, data: { port: 4040 }, errors: [] })
265
+ })
266
+ })
267
+
268
+ describe('named formats', () => {
269
+ test('EMPYRIA_FORMATS enforce their patterns', () => {
270
+ expect(EMPYRIA_FORMATS['empyria-email']('user@example.com')).toBe(true)
271
+ expect(EMPYRIA_FORMATS['empyria-email']('user@example!com')).toBe(false)
272
+ expect(EMPYRIA_FORMATS['empyria-password']('Abcdefg1')).toBe(true)
273
+ expect(EMPYRIA_FORMATS['empyria-password']('Abcdefgh')).toBe(false)
274
+ expect(EMPYRIA_FORMATS['empyria-ipv4']('192.168.0.1')).toBe(true)
275
+ expect(EMPYRIA_FORMATS['empyria-ipv4']('999.0.0.1')).toBe(false)
276
+ })
277
+
278
+ test('a schema from email() is enforced through this module without per-call wiring', () => {
279
+ const schema = defineSchema({ addr: email() })
280
+ expect(validate(schema, { addr: 'user@example.com' })).toEqual({ addr: 'user@example.com' })
281
+ expect(() => validate(schema, { addr: 'nope' })).toThrow(EmpyriaError)
282
+ })
283
+
284
+ test('a schema from password() is enforced (no ata-validator built-in for it)', () => {
285
+ const schema = defineSchema({ pw: password() })
286
+ expect(() => validate(schema, { pw: 'weak' })).toThrow(EmpyriaError)
287
+ expect(validate(schema, { pw: 'Abcdefg1' })).toEqual({ pw: 'Abcdefg1' })
288
+ })
289
+
290
+ test('a per-call formats entry overrides the Empyria default', () => {
291
+ const schema = defineSchema({ addr: email() })
292
+ const only = { formats: { 'empyria-email': (s) => s === 'ok@ok.io' } }
293
+ expect(validate(schema, { addr: 'ok@ok.io' }, only)).toEqual({ addr: 'ok@ok.io' })
294
+ expect(() => validate(schema, { addr: 'user@example.com' }, only)).toThrow(EmpyriaError)
295
+ })
296
+ })
297
+
298
+ describe('option layering: setAtaDefaults / schema-bound / per-call', () => {
299
+ test('getAtaDefaults starts empty', () => {
300
+ expect(getAtaDefaults()).toEqual({})
301
+ })
302
+
303
+ test('a library default is applied when no per-call option is given', () => {
304
+ const schema = defineSchema({ port: number() })
305
+ expect(() => validate(schema, { port: '8080' })).toThrow(EmpyriaError)
306
+ setAtaDefaults({ coerceTypes: true })
307
+ try {
308
+ expect(validate(schema, { port: '8080' })).toEqual({ port: 8080 })
309
+ } finally {
310
+ setAtaDefaults()
311
+ }
312
+ expect(getAtaDefaults()).toEqual({})
313
+ })
314
+
315
+ test('schema-bound validation options beat library defaults, per-call beats both', () => {
316
+ setAtaDefaults({ coerceTypes: false })
317
+ try {
318
+ const bound = defineSchema({ port: number() }, { validation: { coerceTypes: true } })
319
+ // schema-bound coerceTypes:true wins over the library default
320
+ expect(validate(bound, { port: '80' })).toEqual({ port: 80 })
321
+ // per-call coerceTypes:false wins over the schema-bound value
322
+ expect(() => validate(bound, { port: '80' }, { coerceTypes: false })).toThrow(
323
+ EmpyriaError,
324
+ )
325
+ } finally {
326
+ setAtaDefaults()
327
+ }
328
+ })
329
+ })
330
+
331
+ describe('parse / createParser', () => {
332
+ test('parse coerces and strips against a closed schema', () => {
333
+ const schema = defineSchema(
334
+ { port: number() },
335
+ { strict: true, validation: { coerceTypes: true, removeAdditional: true } },
336
+ )
337
+ expect(parse(schema, { port: '3000', stray: 'x' })).toEqual({ port: 3000 })
338
+ })
339
+
340
+ test('createParser is a reusable createValidator under an intent-revealing name', () => {
341
+ const p = createParser(defineSchema({ n: number() }), { coerceTypes: true })
342
+ expect(p({ n: '7' })).toEqual({ n: 7 })
343
+ expect(typeof createValidator).toBe('function')
344
+ })
345
+ })
346
+
347
+ describe('scalar builders — constraints', () => {
348
+ test('string accepts a number shorthand or an options object', () => {
349
+ expect(string('x', 3)).toEqual({ type: 'string', default: 'x', minLength: 3 })
350
+ expect(
351
+ string(undefined, { minLength: 0, maxLength: 8, pattern: '^a', format: 'uri' }),
352
+ ).toEqual({
353
+ type: 'string',
354
+ minLength: 0,
355
+ maxLength: 8,
356
+ pattern: '^a',
357
+ format: 'uri',
358
+ })
359
+ })
360
+
361
+ test('number/integer carry bounds; integer types as integer', () => {
362
+ expect(number(5)).toEqual({ type: 'number', default: 5 })
363
+ expect(integer(8100, { min: 1, max: 65535 })).toEqual({
364
+ type: 'integer',
365
+ default: 8100,
366
+ minimum: 1,
367
+ maximum: 65535,
368
+ })
369
+ expect(integer(undefined, { exclusiveMin: 0, multipleOf: 2 })).toEqual({
370
+ type: 'integer',
371
+ exclusiveMinimum: 0,
372
+ multipleOf: 2,
373
+ })
374
+ })
375
+
376
+ test('an integer schema rejects a real (and, coerced, a non-integer string)', () => {
377
+ const schema = defineSchema({ port: integer() })
378
+ expect(() => validate(schema, { port: 3.5 })).toThrow(EmpyriaError)
379
+ expect(() => validate(schema, { port: '3.5' }, { coerceTypes: true })).toThrow(EmpyriaError)
380
+ expect(validate(schema, { port: '3000' }, { coerceTypes: true })).toEqual({ port: 3000 })
381
+ })
382
+
383
+ test('enumType can carry a type alongside enum', () => {
384
+ expect(enumType(['a', 'b'], 'a')).toEqual({ enum: ['a', 'b'], default: 'a' })
385
+ expect(enumType(['a', 'b'], undefined, { type: 'string' })).toEqual({
386
+ type: 'string',
387
+ enum: ['a', 'b'],
388
+ })
389
+ })
390
+
391
+ test('constant / nullable', () => {
392
+ expect(constant('circle')).toEqual({ const: 'circle' })
393
+ expect(nullable(string('x'))).toEqual({ type: ['string', 'null'], default: 'x' })
394
+ expect(nullable(enumType(['a']))).toEqual({ anyOf: [{ enum: ['a'] }, { type: 'null' }] })
395
+ const schema = defineSchema({ note: nullable(string()) })
396
+ expect(validate(schema, { note: null })).toEqual({ note: null })
397
+ expect(validate(schema, { note: 'hi' })).toEqual({ note: 'hi' })
398
+ expect(() => validate(schema, { note: 3 })).toThrow(EmpyriaError)
399
+ })
400
+
401
+ test('array builder', () => {
402
+ expect(array(string(), { minItems: 1, uniqueItems: true })).toEqual({
403
+ type: 'array',
404
+ items: { type: 'string' },
405
+ minItems: 1,
406
+ uniqueItems: true,
407
+ })
408
+ const schema = defineSchema({ tags: array(string()) })
409
+ expect(validate(schema, { tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] })
410
+ expect(() => validate(schema, { tags: ['a', 2] })).toThrow(EmpyriaError)
411
+ })
412
+
413
+ test('format wrappers', () => {
414
+ expect(isoDateTime()).toEqual({
415
+ type: 'string',
416
+ format: 'date-time',
417
+ description: expect.any(String),
418
+ })
419
+ expect([isoDate(), isoTime(), duration(), uri(), uuid()].map((s) => s.format)).toEqual([
420
+ 'date',
421
+ 'time',
422
+ 'duration',
423
+ 'uri',
424
+ 'uuid',
425
+ ])
426
+ const schema = defineSchema({ at: isoDateTime() })
427
+ expect(validate(schema, { at: '2026-09-10T09:28:09Z' })).toEqual({
428
+ at: '2026-09-10T09:28:09Z',
429
+ })
430
+ expect(() => validate(schema, { at: 'not-a-date' })).toThrow(EmpyriaError)
431
+ })
432
+ })
433
+
434
+ describe('defineSchema — dialect & unevaluated strict', () => {
435
+ test('dialect stamps $schema from a short name or a raw URI', () => {
436
+ expect(defineSchema({ a: string() }, { dialect: 'v1' }).$schema).toBe(
437
+ 'https://json-schema.org/v1',
438
+ )
439
+ expect(defineSchema({ a: string() }, { dialect: 'https://example.test/s' }).$schema).toBe(
440
+ 'https://example.test/s',
441
+ )
442
+ expect(defineSchema({ a: string() }).$schema).toBeUndefined()
443
+ })
444
+
445
+ test('setDefaultDialect applies when a call passes no dialect', () => {
446
+ expect(getDefaultDialect()).toBeNull()
447
+ setDefaultDialect('v1')
448
+ try {
449
+ expect(defineSchema({ a: string() }).$schema).toBe('https://json-schema.org/v1')
450
+ expect(defineSchema({ a: string() }, { dialect: '2020-12' }).$schema).toBe(
451
+ 'https://json-schema.org/draft/2020-12/schema',
452
+ )
453
+ } finally {
454
+ setDefaultDialect()
455
+ }
456
+ expect(getDefaultDialect()).toBeNull()
457
+ expect(defineSchema({ a: string() }).$schema).toBeUndefined()
458
+ })
459
+
460
+ test("strict: 'unevaluated' keeps additionalProperties open but sets unevaluatedProperties:false", () => {
461
+ const schema = defineSchema({ a: string() }, { strict: 'unevaluated' })
462
+ expect(schema.additionalProperties).toBe(true)
463
+ expect(schema.unevaluatedProperties).toBe(false)
464
+ })
465
+ })
466
+
467
+ describe('discriminated (propertyDependencies)', () => {
468
+ const shape = discriminated('kind', {
469
+ circle: defineSchema({ radius: number() }),
470
+ square: defineSchema({ side: number() }),
471
+ })
472
+
473
+ test('selects the branch by the discriminator value', () => {
474
+ expect(validate(shape, { kind: 'circle', radius: 2 })).toEqual({
475
+ kind: 'circle',
476
+ radius: 2,
477
+ })
478
+ expect(validate(shape, { kind: 'square', side: 3 })).toEqual({ kind: 'square', side: 3 })
479
+ expect(() => validate(shape, { kind: 'circle', side: 3 })).toThrow(EmpyriaError)
480
+ expect(() => validate(shape, { kind: 'triangle' })).toThrow(EmpyriaError)
481
+ })
482
+
483
+ test('pairs with a v1 $schema via dialect', () => {
484
+ const v1shape = { ...shape, $schema: DIALECTS.v1 }
485
+ expect(validate(v1shape, { kind: 'square', side: 3 })).toEqual({ kind: 'square', side: 3 })
211
486
  })
212
487
  })
package/test/Env.test.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test'
2
2
  import { createEnv } from '../lib/Env.js'
3
+ import { validate, schemaOptions } from '../lib/Ata.js'
3
4
 
4
5
  describe('createEnv', () => {
5
6
  test('includes the shared service defaults plus service-specific additions', () => {
@@ -15,4 +16,33 @@ describe('createEnv', () => {
15
16
  const schema = createEnv()
16
17
  expect(schema.properties.EMPYRIA_MONGO_URI).toBeUndefined()
17
18
  })
19
+
20
+ test('is open by default: undeclared vars pass through untouched', () => {
21
+ const schema = createEnv()
22
+ expect(schema.additionalProperties).toBe(true)
23
+ expect(schemaOptions(schema)).toEqual({})
24
+ const out = validate(
25
+ schema,
26
+ { NODE_ENV: 'test', UNDECLARED: 'ride-along' },
27
+ { coerceTypes: true },
28
+ )
29
+ expect(out.UNDECLARED).toBe('ride-along')
30
+ })
31
+
32
+ test('closed: rejects the wider environment and returns only the declared keys', () => {
33
+ const schema = createEnv({}, { closed: true })
34
+ expect(schema.additionalProperties).toBe(false)
35
+ expect(schemaOptions(schema)).toEqual({ coerceTypes: true, removeAdditional: true })
36
+
37
+ const out = validate(schema, {
38
+ NODE_ENV: 'test',
39
+ EMPYRIA_ID_LENGTH: '48',
40
+ PATH: '/usr/bin',
41
+ HOME: '/root',
42
+ })
43
+ expect(out.EMPYRIA_ID_LENGTH).toBe(48)
44
+ expect(out.NODE_ENV).toBe('test')
45
+ expect(out.PATH).toBeUndefined()
46
+ expect(out.HOME).toBeUndefined()
47
+ })
18
48
  })