@empyria/common 0.2.0 → 0.3.1

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; 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`, `bool`, `enumType`, `email`, `password`, `ip4`, `ip6`, ...) 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 _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. |
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
@@ -90,6 +90,37 @@ copy when the caller must keep its input pristine (as the `env.js` files do with
90
90
  intent is to transform rather than merely assert. `createChecker` / `checkSchema` now also
91
91
  return `data` (the parsed value on success, `undefined` on failure).
92
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
+
93
124
  ## Scripts
94
125
 
95
126
  ```bash
package/lib/Ata.js CHANGED
@@ -63,6 +63,42 @@ export const EMPYRIA_FORMATS = {
63
63
  /** Non-enumerable key under which {@link defineSchema} stashes a schema's bound options. */
64
64
  const SCHEMA_OPTIONS = Symbol('ataOptions')
65
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
+
66
102
  /** Process-wide default `ata-validator` options; the base layer for every call here. */
67
103
  let libraryDefaults = {}
68
104
 
@@ -137,12 +173,17 @@ export function cloneBySchema(schema) {
137
173
  * Builds a JSON schema object from a flat map of property schemas.
138
174
  * @param {Object} schemaDef - Map of property name to its JSON schema.
139
175
  * @param {Object} [options={}]
140
- * @param {boolean} [options.strict] - If true, disallows properties not listed in `schemaDef`
141
- * (`additionalProperties: false`). Combine with `validation: { removeAdditional: true }` to
142
- * *strip* unknown keys instead of rejecting them. Ignored when `options.additionalProperties`
143
- * is given.
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.
144
182
  * @param {boolean|Object} [options.additionalProperties] - Explicit `additionalProperties`
145
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.
146
187
  * @param {boolean} [options.optional] - If true, a property whose own schema carries
147
188
  * `optional: true` is excluded from `required` (that marker is then stripped from the
148
189
  * property itself — never leaked into the resulting schema, since it isn't a real JSON
@@ -168,14 +209,19 @@ export function defineSchema(schemaDef, options = {}) {
168
209
  required.push(key)
169
210
  }
170
211
  }
212
+ const unevaluated = options.strict === 'unevaluated'
171
213
  const schema = {
172
214
  type: 'object',
173
215
  properties,
174
216
  required,
175
217
  additionalProperties: defined(options.additionalProperties)
176
218
  ? options.additionalProperties
177
- : !options.strict,
219
+ : !options.strict || unevaluated,
178
220
  }
221
+ if (unevaluated) schema.unevaluatedProperties = false
222
+
223
+ const dialect = options.dialect ?? defaultDialect
224
+ if (dialect) schema.$schema = DIALECTS[dialect] ?? dialect
179
225
 
180
226
  if (options.validation) {
181
227
  Object.defineProperty(schema, SCHEMA_OPTIONS, {
@@ -327,15 +373,26 @@ export function bool(defaultValue) {
327
373
  /**
328
374
  * JSON schema for a string property.
329
375
  * @param {string} [defaultValue] - Default value, if any.
330
- * @param {number} [minLength] - Minimum string length, if greater than 0.
331
- * @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? }`.
332
386
  */
333
- export function string(defaultValue, minLength) {
334
- const schema = {
335
- type: 'string',
336
- }
387
+ export function string(defaultValue, opts) {
388
+ const o = typeof opts === 'number' ? { minLength: opts } : (opts ?? {})
389
+ const schema = { type: 'string' }
337
390
  if (defined(defaultValue)) schema.default = `${defaultValue}`
338
- 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
339
396
  return schema
340
397
  }
341
398
 
@@ -343,13 +400,19 @@ export function string(defaultValue, minLength) {
343
400
  * JSON schema for an enumerated property.
344
401
  * @param {Array} list - Allowed values.
345
402
  * @param {*} [defaultValue] - Default value, if any.
346
- * @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? }`.
347
408
  */
348
- export function enumType(list, defaultValue) {
409
+ export function enumType(list, defaultValue, opts = {}) {
349
410
  const schema = {
350
411
  enum: list,
351
412
  }
413
+ if (defined(opts.type)) schema.type = opts.type
352
414
  if (defined(defaultValue)) schema.default = defaultValue
415
+ if (defined(opts.description)) schema.description = opts.description
353
416
  return schema
354
417
  }
355
418
 
@@ -389,18 +452,151 @@ export function nodeEnv() {
389
452
  }
390
453
 
391
454
  /**
392
- * 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).
393
475
  * @param {number} [defaultValue] - Default value, if any.
394
- * @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?, ... }`.
395
479
  */
396
- 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 = {}) {
397
558
  const schema = {
398
- type: 'number',
559
+ type: 'object',
560
+ properties: { [property]: { enum: Object.keys(mapping) } },
561
+ required: [property],
562
+ propertyDependencies: { [property]: mapping },
399
563
  }
400
- if (defined(defaultValue)) schema.default = Number(defaultValue)
564
+ if (defined(opts.description)) schema.description = opts.description
401
565
  return schema
402
566
  }
403
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
+
404
600
  /**
405
601
  * JSON schema for an email-address string, checked against the `email` format
406
602
  * ({@link EMPYRIA_FORMATS}, which mirrors {@link P_EMAIL}).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empyria/common",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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.14.1",
23
+ "ata-validator": "1.18.0",
24
24
  "clerobee": "1.0.8",
25
25
  "ms": "2.1.3"
26
26
  },
package/test/Ata.test.js CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  schemaOptions,
16
16
  setAtaDefaults,
17
17
  getAtaDefaults,
18
+ setDefaultDialect,
19
+ getDefaultDialect,
20
+ DIALECTS,
18
21
  EMPYRIA_FORMATS,
19
22
  bool,
20
23
  string,
@@ -24,6 +27,17 @@ import {
24
27
  logFile,
25
28
  nodeEnv,
26
29
  number,
30
+ integer,
31
+ constant,
32
+ nullable,
33
+ array,
34
+ discriminated,
35
+ isoDateTime,
36
+ isoDate,
37
+ isoTime,
38
+ duration,
39
+ uri,
40
+ uuid,
27
41
  email,
28
42
  password,
29
43
  ip4,
@@ -329,3 +343,145 @@ describe('parse / createParser', () => {
329
343
  expect(typeof createValidator).toBe('function')
330
344
  })
331
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 })
486
+ })
487
+ })