@naturalcycles/nodejs-lib 15.90.2 → 15.92.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.
@@ -1,24 +1,69 @@
1
- import { _isObject, _lazyValue } from '@naturalcycles/js-lib'
1
+ /* eslint-disable id-denylist */
2
+ // oxlint-disable max-lines
3
+
2
4
  import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycles/js-lib'
3
- import { _assert } from '@naturalcycles/js-lib/error'
4
- import { _deepCopy, _filterNullishValues } from '@naturalcycles/js-lib/object'
5
+ import {
6
+ _isObject,
7
+ _isUndefined,
8
+ _lazyValue,
9
+ _numberEnumValues,
10
+ _stringEnumValues,
11
+ getEnumType,
12
+ } from '@naturalcycles/js-lib'
13
+ import { _uniq } from '@naturalcycles/js-lib/array'
14
+ import { _assert, _try } from '@naturalcycles/js-lib/error'
15
+ import type { Set2 } from '@naturalcycles/js-lib/object'
16
+ import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object'
5
17
  import { _substringBefore } from '@naturalcycles/js-lib/string'
6
- import { _typeCast } from '@naturalcycles/js-lib/types'
7
- import type { AnyObject } from '@naturalcycles/js-lib/types'
18
+ import type {
19
+ AnyObject,
20
+ BaseDBEntity,
21
+ IANATimezone,
22
+ Inclusiveness,
23
+ IsoDate,
24
+ IsoDateTime,
25
+ IsoMonth,
26
+ NumberEnum,
27
+ StringEnum,
28
+ StringMap,
29
+ UnixTimestamp,
30
+ UnixTimestampMillis,
31
+ } from '@naturalcycles/js-lib/types'
32
+ import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types'
8
33
  import type { Ajv, ErrorObject } from 'ajv'
9
34
  import { _inspect } from '../../string/inspect.js'
35
+ import {
36
+ BASE64URL_REGEX,
37
+ COUNTRY_CODE_REGEX,
38
+ CURRENCY_REGEX,
39
+ IPV4_REGEX,
40
+ IPV6_REGEX,
41
+ LANGUAGE_TAG_REGEX,
42
+ SEMVER_REGEX,
43
+ SLUG_REGEX,
44
+ URL_REGEX,
45
+ UUID_REGEX,
46
+ } from '../regexes.js'
47
+ import { TIMEZONES } from '../timezones.js'
10
48
  import { AjvValidationError } from './ajvValidationError.js'
11
49
  import { getAjv } from './getAjv.js'
12
- import { JsonSchemaTerminal } from './jsonSchemaBuilder.js'
13
- import type { JsonSchema } from './jsonSchemaBuilder.js'
50
+ import {
51
+ isEveryItemNumber,
52
+ isEveryItemPrimitive,
53
+ isEveryItemString,
54
+ JSON_SCHEMA_ORDER,
55
+ mergeJsonSchemaObjects,
56
+ } from './jsonSchemaBuilder.util.js'
57
+
58
+ // ==== AJV =====
14
59
 
15
60
  /**
16
61
  * On creation - compiles ajv validation function.
17
62
  * Provides convenient methods, error reporting, etc.
18
63
  */
19
- export class AjvSchema<IN = unknown, OUT = IN> {
64
+ export class AjvSchema<OUT> {
20
65
  private constructor(
21
- public schema: JsonSchema<IN, OUT>,
66
+ public schema: JsonSchema<OUT>,
22
67
  cfg: Partial<AjvSchemaCfg> = {},
23
68
  ) {
24
69
  this.cfg = {
@@ -37,10 +82,10 @@ export class AjvSchema<IN = unknown, OUT = IN> {
37
82
  /**
38
83
  * Shortcut for AjvSchema.create(schema, { lazy: true })
39
84
  */
40
- static createLazy<IN, OUT>(
41
- schema: SchemaHandledByAjv<IN, OUT>,
85
+ static createLazy<OUT>(
86
+ schema: SchemaHandledByAjv<OUT>,
42
87
  cfg?: Partial<AjvSchemaCfg>,
43
- ): AjvSchema<IN, OUT> {
88
+ ): AjvSchema<OUT> {
44
89
  return AjvSchema.create(schema, {
45
90
  lazy: true,
46
91
  ...cfg,
@@ -56,21 +101,18 @@ export class AjvSchema<IN = unknown, OUT = IN> {
56
101
  * Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
57
102
  * correctly for some reason.
58
103
  */
59
- static create<IN, OUT = IN>(
60
- schema: SchemaHandledByAjv<IN, OUT>,
61
- cfg?: Partial<AjvSchemaCfg>,
62
- ): AjvSchema<IN, OUT> {
104
+ static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
63
105
  if (schema instanceof AjvSchema) return schema
64
106
 
65
- if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, IN, OUT>(schema)) {
66
- return AjvSchema.requireCachedAjvSchema<typeof schema, IN, OUT>(schema)
107
+ if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
108
+ return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
67
109
  }
68
110
 
69
- let jsonSchema: JsonSchema<IN, OUT>
111
+ let jsonSchema: JsonSchema<OUT>
70
112
 
71
113
  if (AjvSchema.isJsonSchemaBuilder(schema)) {
72
114
  // oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
73
- jsonSchema = (schema as JsonSchemaTerminal<IN, OUT, any>).build()
115
+ jsonSchema = (schema as JsonSchemaTerminal<OUT, any>).build()
74
116
  AjvSchema.requireValidJsonSchema(jsonSchema)
75
117
  } else {
76
118
  jsonSchema = schema
@@ -83,13 +125,13 @@ export class AjvSchema<IN = unknown, OUT = IN> {
83
125
  // really upsets Ajv.
84
126
  delete jsonSchema.optionalField
85
127
 
86
- const ajvSchema = new AjvSchema<IN, OUT>(jsonSchema, cfg)
128
+ const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
87
129
  AjvSchema.cacheAjvSchema(schema, ajvSchema)
88
130
 
89
131
  return ajvSchema
90
132
  }
91
133
 
92
- static isJsonSchemaBuilder<IN, OUT>(schema: unknown): schema is JsonSchemaTerminal<IN, OUT, any> {
134
+ static isJsonSchemaBuilder<OUT>(schema: unknown): schema is JsonSchemaTerminal<OUT, any> {
93
135
  return schema instanceof JsonSchemaTerminal
94
136
  }
95
137
 
@@ -103,13 +145,13 @@ export class AjvSchema<IN = unknown, OUT = IN> {
103
145
  *
104
146
  * Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
105
147
  */
106
- validate(input: IN, opt: AjvValidationOptions<IN> = {}): OUT {
148
+ validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
107
149
  const [err, output] = this.getValidationResult(input, opt)
108
150
  if (err) throw err
109
151
  return output
110
152
  }
111
153
 
112
- isValid(input: IN, opt?: AjvValidationOptions<IN>): boolean {
154
+ isValid(input: unknown, opt?: AjvValidationOptions): boolean {
113
155
  // todo: we can make it both fast and non-mutating by using Ajv
114
156
  // with "removeAdditional" and "useDefaults" disabled.
115
157
  const [err] = this.getValidationResult(input, opt)
@@ -117,8 +159,8 @@ export class AjvSchema<IN = unknown, OUT = IN> {
117
159
  }
118
160
 
119
161
  getValidationResult(
120
- input: IN,
121
- opt: AjvValidationOptions<IN> = {},
162
+ input: unknown,
163
+ opt: AjvValidationOptions = {},
122
164
  ): ValidationFunctionResult<OUT, AjvValidationError> {
123
165
  const fn = this.getAJVValidateFunction()
124
166
 
@@ -127,14 +169,31 @@ export class AjvSchema<IN = unknown, OUT = IN> {
127
169
  ? input // mutate
128
170
  : _deepCopy(input) // not mutate
129
171
 
130
- const valid = fn(item) // mutates item, but not input
172
+ let valid = fn(item) // mutates item, but not input
131
173
  _typeCast<OUT>(item)
132
- if (valid) return [null, item]
174
+
175
+ let output: OUT = item
176
+ if (valid && this.schema.postValidation) {
177
+ const [err, result] = _try(() => this.schema.postValidation!(output))
178
+ if (err) {
179
+ valid = false
180
+ ;(fn as any).errors = [
181
+ {
182
+ instancePath: '',
183
+ message: err.message,
184
+ },
185
+ ]
186
+ } else {
187
+ output = result
188
+ }
189
+ }
190
+
191
+ if (valid) return [null, output]
133
192
 
134
193
  const errors = fn.errors!
135
194
 
136
195
  const {
137
- inputId = _isObject(input) ? input['id' as keyof IN] : undefined,
196
+ inputId = _isObject(input) ? (input as any)['id'] : undefined,
138
197
  inputName = this.cfg.inputName || 'Object',
139
198
  } = opt
140
199
  const dataVar = [inputName, inputId].filter(Boolean).join('.')
@@ -160,10 +219,10 @@ export class AjvSchema<IN = unknown, OUT = IN> {
160
219
  inputId,
161
220
  }),
162
221
  )
163
- return [err, item]
222
+ return [err, output]
164
223
  }
165
224
 
166
- getValidationFunction(): ValidationFunction<IN, OUT, AjvValidationError> {
225
+ getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
167
226
  return (input, opt) => {
168
227
  return this.getValidationResult(input, {
169
228
  mutateInput: opt?.mutateInput,
@@ -173,22 +232,20 @@ export class AjvSchema<IN = unknown, OUT = IN> {
173
232
  }
174
233
  }
175
234
 
176
- static isSchemaWithCachedAjvSchema<Base, IN, OUT>(
235
+ static isSchemaWithCachedAjvSchema<Base, OUT>(
177
236
  schema: Base,
178
- ): schema is WithCachedAjvSchema<Base, IN, OUT> {
237
+ ): schema is WithCachedAjvSchema<Base, OUT> {
179
238
  return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
180
239
  }
181
240
 
182
- static cacheAjvSchema<Base extends AnyObject, IN, OUT>(
241
+ static cacheAjvSchema<Base extends AnyObject, OUT>(
183
242
  schema: Base,
184
- ajvSchema: AjvSchema<IN, OUT>,
185
- ): WithCachedAjvSchema<Base, IN, OUT> {
243
+ ajvSchema: AjvSchema<OUT>,
244
+ ): WithCachedAjvSchema<Base, OUT> {
186
245
  return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
187
246
  }
188
247
 
189
- static requireCachedAjvSchema<Base, IN, OUT>(
190
- schema: WithCachedAjvSchema<Base, IN, OUT>,
191
- ): AjvSchema<IN, OUT> {
248
+ static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
192
249
  return schema[HIDDEN_AJV_SCHEMA]
193
250
  }
194
251
 
@@ -229,7 +286,7 @@ export class AjvSchema<IN = unknown, OUT = IN> {
229
286
  }
230
287
 
231
288
  private getErrorMessageForInstancePath(
232
- schema: JsonSchema<IN, OUT> | undefined,
289
+ schema: JsonSchema<OUT> | undefined,
233
290
  instancePath: string,
234
291
  keyword: string,
235
292
  ): string | undefined {
@@ -239,8 +296,8 @@ export class AjvSchema<IN = unknown, OUT = IN> {
239
296
  return this.traverseSchemaPath(schema, segments, keyword)
240
297
  }
241
298
 
242
- private traverseSchemaPath<IN = unknown, OUT = IN>(
243
- schema: JsonSchema<IN, OUT>,
299
+ private traverseSchemaPath<T>(
300
+ schema: JsonSchema<T>,
244
301
  segments: string[],
245
302
  keyword: string,
246
303
  ): string | undefined {
@@ -290,11 +347,11 @@ const separator = '\n'
290
347
 
291
348
  export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
292
349
 
293
- export type WithCachedAjvSchema<Base, IN, OUT> = Base & {
294
- [HIDDEN_AJV_SCHEMA]: AjvSchema<IN, OUT>
350
+ export type WithCachedAjvSchema<Base, OUT> = Base & {
351
+ [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
295
352
  }
296
353
 
297
- export interface AjvValidationOptions<IN> {
354
+ export interface AjvValidationOptions {
298
355
  /**
299
356
  * Defaults to true,
300
357
  * because that's how AJV works by default,
@@ -322,7 +379,7 @@ export interface AjvValidationOptions<IN> {
322
379
  * can include it "how it was" in an error message. So, for that reason we'll use
323
380
  * `getOriginalInput()`, if it's provided.
324
381
  */
325
- getOriginalInput?: () => IN
382
+ getOriginalInput?: () => unknown
326
383
  }
327
384
 
328
385
  export interface AjvSchemaCfg {
@@ -350,7 +407,1830 @@ export interface AjvSchemaCfg {
350
407
  lazy?: boolean
351
408
  }
352
409
 
353
- export type SchemaHandledByAjv<IN, OUT = IN> =
354
- | JsonSchemaTerminal<IN, OUT, any>
355
- | JsonSchema<IN, OUT>
356
- | AjvSchema<IN, OUT>
410
+ export type SchemaHandledByAjv<OUT> =
411
+ | JsonSchemaTerminal<OUT, any>
412
+ | JsonSchema<OUT>
413
+ | AjvSchema<OUT>
414
+
415
+ // ===== JsonSchemaBuilders ===== //
416
+
417
+ export const j = {
418
+ /**
419
+ * Matches literally any value - equivalent to TypeScript's `any` type.
420
+ * Use sparingly, as it bypasses type validation entirely.
421
+ */
422
+ any(): JsonSchemaAnyBuilder<any, false> {
423
+ return new JsonSchemaAnyBuilder({})
424
+ },
425
+
426
+ string(): JsonSchemaStringBuilder<string, false> {
427
+ return new JsonSchemaStringBuilder()
428
+ },
429
+
430
+ number(): JsonSchemaNumberBuilder<number, false> {
431
+ return new JsonSchemaNumberBuilder()
432
+ },
433
+
434
+ boolean(): JsonSchemaBooleanBuilder<boolean, false> {
435
+ return new JsonSchemaBooleanBuilder()
436
+ },
437
+
438
+ object: Object.assign(object, {
439
+ dbEntity: objectDbEntity,
440
+ infer: objectInfer,
441
+ any() {
442
+ return j.object<AnyObject>({}).allowAdditionalProperties()
443
+ },
444
+
445
+ stringMap<S extends JsonSchemaTerminal<any, any>>(
446
+ schema: S,
447
+ ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>> {
448
+ const isValueOptional = schema.getSchema().optionalField
449
+ const builtSchema = schema.build()
450
+ const finalValueSchema: JsonSchema = isValueOptional
451
+ ? { anyOf: [{ isUndefined: true }, builtSchema] }
452
+ : builtSchema
453
+
454
+ return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>>(
455
+ {},
456
+ {
457
+ hasIsOfTypeCheck: false,
458
+ patternProperties: {
459
+ '^.+$': finalValueSchema,
460
+ },
461
+ },
462
+ )
463
+ },
464
+
465
+ /**
466
+ * @experimental Look around, maybe you find a rule that is better for your use-case.
467
+ *
468
+ * For Record<K, V> type of validations.
469
+ * ```ts
470
+ * const schema = j.object
471
+ * .record(
472
+ * j
473
+ * .string()
474
+ * .regex(/^\d{3,4}$/)
475
+ * .branded<B>(),
476
+ * j.number().nullable(),
477
+ * )
478
+ * .isOfType<Record<B, number | null>>()
479
+ * ```
480
+ *
481
+ * When the keys of the Record are values from an Enum, prefer `j.object.withEnumKeys`!
482
+ *
483
+ * Non-matching keys will be stripped from the object, i.e. they will not cause an error.
484
+ *
485
+ * Caveat: This rule first validates values of every properties of the object, and only then validates the keys.
486
+ * A consequence of that is that the validation will throw when there is an unexpected property with a value not matching the value schema.
487
+ */
488
+ record,
489
+
490
+ /**
491
+ * For Record<ENUM, V> type of validations.
492
+ *
493
+ * When the keys of the Record are values from an Enum,
494
+ * this helper is more performant and behaves in a more conventional manner than `j.object.record` would.
495
+ *
496
+ *
497
+ */
498
+ withEnumKeys,
499
+ withRegexKeys,
500
+ }),
501
+
502
+ array<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaArrayBuilder<OUT, Opt> {
503
+ return new JsonSchemaArrayBuilder(itemSchema)
504
+ },
505
+
506
+ tuple<const S extends JsonSchemaAnyBuilder<any, any>[]>(items: S): JsonSchemaTupleBuilder<S> {
507
+ return new JsonSchemaTupleBuilder<S>(items)
508
+ },
509
+
510
+ set<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaSet2Builder<OUT, Opt> {
511
+ return new JsonSchemaSet2Builder(itemSchema)
512
+ },
513
+
514
+ buffer(): JsonSchemaBufferBuilder {
515
+ return new JsonSchemaBufferBuilder()
516
+ },
517
+
518
+ enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
519
+ input: T,
520
+ opt?: JsonBuilderRuleOpt,
521
+ ): JsonSchemaEnumBuilder<
522
+ T extends readonly (infer U)[]
523
+ ? U
524
+ : T extends StringEnum
525
+ ? T[keyof T]
526
+ : T extends NumberEnum
527
+ ? T[keyof T]
528
+ : never
529
+ > {
530
+ let enumValues: readonly (string | number | boolean | null)[] | undefined
531
+ let baseType: EnumBaseType = 'other'
532
+
533
+ if (Array.isArray(input)) {
534
+ enumValues = input
535
+ if (isEveryItemNumber(input)) {
536
+ baseType = 'number'
537
+ } else if (isEveryItemString(input)) {
538
+ baseType = 'string'
539
+ }
540
+ } else if (typeof input === 'object') {
541
+ const enumType = getEnumType(input)
542
+ if (enumType === 'NumberEnum') {
543
+ enumValues = _numberEnumValues(input as NumberEnum)
544
+ baseType = 'number'
545
+ } else if (enumType === 'StringEnum') {
546
+ enumValues = _stringEnumValues(input as StringEnum)
547
+ baseType = 'string'
548
+ }
549
+ }
550
+
551
+ _assert(enumValues, 'Unsupported enum input')
552
+ return new JsonSchemaEnumBuilder(enumValues as any, baseType, opt)
553
+ },
554
+
555
+ /**
556
+ * Use only with primitive values, otherwise this function will throw to avoid bugs.
557
+ * To validate objects, use `anyOfBy`.
558
+ *
559
+ * Our Ajv is configured to strip unexpected properties from objects,
560
+ * and since Ajv is mutating the input, this means that it cannot
561
+ * properly validate the same data over multiple schemas.
562
+ *
563
+ * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
564
+ * Use `oneOf` when schemas are mutually exclusive.
565
+ */
566
+ oneOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
567
+ items: [...B],
568
+ ): JsonSchemaAnyBuilder<OUT, false> {
569
+ const schemas = items.map(b => b.build())
570
+ _assert(
571
+ schemas.every(hasNoObjectSchemas),
572
+ 'Do not use `oneOf` validation with non-primitive types!',
573
+ )
574
+
575
+ return new JsonSchemaAnyBuilder<OUT, false>({
576
+ oneOf: schemas,
577
+ })
578
+ },
579
+
580
+ /**
581
+ * Use only with primitive values, otherwise this function will throw to avoid bugs.
582
+ * To validate objects, use `anyOfBy` or `anyOfThese`.
583
+ *
584
+ * Our Ajv is configured to strip unexpected properties from objects,
585
+ * and since Ajv is mutating the input, this means that it cannot
586
+ * properly validate the same data over multiple schemas.
587
+ *
588
+ * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
589
+ * Use `oneOf` when schemas are mutually exclusive.
590
+ */
591
+ anyOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
592
+ items: [...B],
593
+ ): JsonSchemaAnyBuilder<OUT, false> {
594
+ const schemas = items.map(b => b.build())
595
+ _assert(
596
+ schemas.every(hasNoObjectSchemas),
597
+ 'Do not use `anyOf` validation with non-primitive types!',
598
+ )
599
+
600
+ return new JsonSchemaAnyBuilder<OUT, false>({
601
+ anyOf: schemas,
602
+ })
603
+ },
604
+
605
+ /**
606
+ * Pick validation schema for an object based on the value of a specific property.
607
+ *
608
+ * ```
609
+ * const schemaMap = {
610
+ * true: successSchema,
611
+ * false: errorSchema
612
+ * }
613
+ *
614
+ * const schema = j.anyOfBy('success', schemaMap)
615
+ * ```
616
+ */
617
+ anyOfBy<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>, OUT = AnyOfByOut<D>>(
618
+ propertyName: string,
619
+ schemaDictionary: D,
620
+ ): JsonSchemaAnyOfByBuilder<OUT> {
621
+ return new JsonSchemaAnyOfByBuilder<OUT>(propertyName, schemaDictionary)
622
+ },
623
+
624
+ /**
625
+ * Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
626
+ * This comes with a performance penalty, so do not use it where performance matters.
627
+ *
628
+ * ```
629
+ * const schema = j.anyOfThese([successSchema, errorSchema])
630
+ * ```
631
+ */
632
+ anyOfThese<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
633
+ items: [...B],
634
+ ): JsonSchemaAnyBuilder<OUT, false> {
635
+ return new JsonSchemaAnyBuilder<OUT, false>({
636
+ anyOfThese: items.map(b => b.build()),
637
+ })
638
+ },
639
+
640
+ and() {
641
+ return {
642
+ silentBob: () => {
643
+ throw new Error('...strike back!')
644
+ },
645
+ }
646
+ },
647
+
648
+ literal<const V extends string | number | boolean | null>(v: V) {
649
+ let baseType: EnumBaseType = 'other'
650
+ if (typeof v === 'string') baseType = 'string'
651
+ if (typeof v === 'number') baseType = 'number'
652
+ return new JsonSchemaEnumBuilder<V>([v], baseType)
653
+ },
654
+ }
655
+
656
+ const TS_2500 = 16725225600 // 2500-01-01
657
+ const TS_2500_MILLIS = TS_2500 * 1000
658
+ const TS_2000 = 946684800 // 2000-01-01
659
+ const TS_2000_MILLIS = TS_2000 * 1000
660
+
661
+ /*
662
+ Notes for future reference
663
+
664
+ Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`?
665
+ A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`,
666
+ which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well.
667
+ With `Opt`, we can infer it as `{ foo?: string | undefined }`.
668
+ */
669
+
670
+ export class JsonSchemaTerminal<OUT, Opt> {
671
+ protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
672
+ protected schema: JsonSchema
673
+
674
+ constructor(schema: JsonSchema) {
675
+ this.schema = schema
676
+ }
677
+
678
+ get ajvSchema(): AjvSchema<any> {
679
+ if (!this[HIDDEN_AJV_SCHEMA]) {
680
+ this[HIDDEN_AJV_SCHEMA] = AjvSchema.create<OUT>(this)
681
+ }
682
+
683
+ return this[HIDDEN_AJV_SCHEMA]
684
+ }
685
+
686
+ getSchema(): JsonSchema {
687
+ return this.schema
688
+ }
689
+
690
+ /**
691
+ * Produces a "clean schema object" without methods.
692
+ * Same as if it would be JSON.stringified.
693
+ */
694
+ build(): JsonSchema<OUT> {
695
+ _assert(
696
+ !(this.schema.optionalField && this.schema.default !== undefined),
697
+ '.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference',
698
+ )
699
+
700
+ const jsonSchema = _sortObject(
701
+ deepCopyPreservingFunctions(this.schema) as AnyObject,
702
+ JSON_SCHEMA_ORDER,
703
+ ) as JsonSchema<OUT>
704
+
705
+ delete jsonSchema.optionalField
706
+
707
+ return jsonSchema
708
+ }
709
+
710
+ clone(): this {
711
+ const cloned = Object.create(Object.getPrototypeOf(this))
712
+ cloned.schema = deepCopyPreservingFunctions(this.schema)
713
+ return cloned
714
+ }
715
+
716
+ cloneAndUpdateSchema(schema: Partial<JsonSchema>): this {
717
+ const clone = this.clone()
718
+ _objectAssign(clone.schema, schema)
719
+ return clone
720
+ }
721
+
722
+ validate(input: unknown, opt?: AjvValidationOptions): OUT {
723
+ return this.ajvSchema.validate(input, opt)
724
+ }
725
+
726
+ isValid(input: unknown, opt?: AjvValidationOptions): boolean {
727
+ return this.ajvSchema.isValid(input, opt)
728
+ }
729
+
730
+ getValidationResult(
731
+ input: unknown,
732
+ opt: AjvValidationOptions = {},
733
+ ): ValidationFunctionResult<OUT, AjvValidationError> {
734
+ return this.ajvSchema.getValidationResult(input, opt)
735
+ }
736
+
737
+ getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
738
+ return this.ajvSchema.getValidationFunction()
739
+ }
740
+
741
+ /**
742
+ * Specify a function to be called after the normal validation is finished.
743
+ *
744
+ * This function will receive the validated, type-safe data, and you can use it
745
+ * to do further validations, e.g. conditional validations based on certain property values,
746
+ * or to do data modifications either by mutating the input or returning a new value.
747
+ *
748
+ * If you throw an error from this function, it will show up as an error in the validation.
749
+ */
750
+ postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JsonSchemaTerminal<OUT2, Opt> {
751
+ const clone = this.cloneAndUpdateSchema({
752
+ postValidation: fn,
753
+ })
754
+ return clone as unknown as JsonSchemaTerminal<OUT2, Opt>
755
+ }
756
+
757
+ /**
758
+ * @experimental
759
+ */
760
+ out!: OUT
761
+ opt!: Opt
762
+ }
763
+
764
+ export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt> {
765
+ protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
766
+ if (_isUndefined(errorMessage)) return
767
+
768
+ this.schema.errorMessages ||= {}
769
+ this.schema.errorMessages[ruleName] = errorMessage
770
+ }
771
+
772
+ /**
773
+ * A helper function that takes a type parameter and compares it with the type inferred from the schema.
774
+ *
775
+ * When the type inferred from the schema differs from the passed-in type,
776
+ * the schema becomes unusable, by turning its type into `never`.
777
+ *
778
+ * ```ts
779
+ * const schemaGood = j.string().isOfType<string>() // ✅
780
+ *
781
+ * const schemaBad = j.string().isOfType<number>() // ❌
782
+ * schemaBad.build() // TypeError: property "build" does not exist on type "never"
783
+ *
784
+ * const result = ajvValidateRequest.body(req, schemaBad) // result will have `unknown` type
785
+ * ```
786
+ */
787
+ isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
788
+ return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any
789
+ }
790
+
791
+ $schema($schema: string): this {
792
+ return this.cloneAndUpdateSchema({ $schema })
793
+ }
794
+
795
+ $schemaDraft7(): this {
796
+ return this.$schema('http://json-schema.org/draft-07/schema#')
797
+ }
798
+
799
+ $id($id: string): this {
800
+ return this.cloneAndUpdateSchema({ $id })
801
+ }
802
+
803
+ title(title: string): this {
804
+ return this.cloneAndUpdateSchema({ title })
805
+ }
806
+
807
+ description(description: string): this {
808
+ return this.cloneAndUpdateSchema({ description })
809
+ }
810
+
811
+ deprecated(deprecated = true): this {
812
+ return this.cloneAndUpdateSchema({ deprecated })
813
+ }
814
+
815
+ type(type: string): this {
816
+ return this.cloneAndUpdateSchema({ type })
817
+ }
818
+
819
+ default(v: any): this {
820
+ return this.cloneAndUpdateSchema({ default: v })
821
+ }
822
+
823
+ instanceof(of: string): this {
824
+ return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
825
+ }
826
+
827
+ optional(): JsonSchemaAnyBuilder<OUT | undefined, true> {
828
+ const clone = this.cloneAndUpdateSchema({ optionalField: true })
829
+ return clone as unknown as JsonSchemaAnyBuilder<OUT | undefined, true>
830
+ }
831
+
832
+ nullable(): JsonSchemaAnyBuilder<OUT | null, Opt> {
833
+ return new JsonSchemaAnyBuilder({
834
+ anyOf: [this.build(), { type: 'null' }],
835
+ })
836
+ }
837
+
838
+ /**
839
+ * @deprecated
840
+ * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
841
+ */
842
+ castAs<T>(): JsonSchemaAnyBuilder<T, Opt> {
843
+ return this as unknown as JsonSchemaAnyBuilder<T, Opt>
844
+ }
845
+
846
+ /**
847
+ * Locks the given schema chain and no other modification can be done to it.
848
+ */
849
+ final(): JsonSchemaTerminal<OUT, Opt> {
850
+ return new JsonSchemaTerminal<OUT, Opt>(this.schema)
851
+ }
852
+
853
+ /**
854
+ *
855
+ * @param validator A validator function that returns an error message or undefined.
856
+ *
857
+ * You may add multiple custom validators and they will be executed in the order you added them.
858
+ */
859
+ custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<OUT2, Opt> {
860
+ const { customValidations = [] } = this.schema
861
+ return this.cloneAndUpdateSchema({
862
+ customValidations: [...customValidations, validator],
863
+ }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
864
+ }
865
+
866
+ /**
867
+ *
868
+ * @param converter A converter function that returns a new value.
869
+ *
870
+ * You may add multiple converters and they will be executed in the order you added them,
871
+ * each converter receiving the result from the previous one.
872
+ *
873
+ * This feature only works when the current schema is nested in an object or array schema,
874
+ * due to how mutability works in Ajv.
875
+ */
876
+ convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<OUT2, Opt> {
877
+ const { customConversions = [] } = this.schema
878
+ return this.cloneAndUpdateSchema({
879
+ customConversions: [...customConversions, converter],
880
+ }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
881
+ }
882
+ }
883
+
884
+ export class JsonSchemaStringBuilder<
885
+ OUT extends string | undefined = string,
886
+ Opt extends boolean = false,
887
+ > extends JsonSchemaAnyBuilder<OUT, Opt> {
888
+ constructor() {
889
+ super({
890
+ type: 'string',
891
+ })
892
+ }
893
+
894
+ /**
895
+ * @param optionalValues List of values that should be considered/converted as `undefined`.
896
+ *
897
+ * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
898
+ * due to how mutability works in Ajv.
899
+ *
900
+ * Make sure this `optional()` call is at the end of your call chain.
901
+ *
902
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
903
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
904
+ */
905
+ override optional<T extends readonly (string | null)[] | undefined = undefined>(
906
+ optionalValues?: T,
907
+ ): T extends readonly (infer U)[]
908
+ ? null extends U
909
+ ? JsonSchemaTerminal<OUT | undefined, true>
910
+ : JsonSchemaStringBuilder<OUT | undefined, true>
911
+ : JsonSchemaStringBuilder<OUT | undefined, true> {
912
+ if (!optionalValues) {
913
+ return super.optional() as any
914
+ }
915
+
916
+ _typeCast<(string | null)[]>(optionalValues)
917
+
918
+ let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaStringBuilder<
919
+ OUT,
920
+ Opt
921
+ >().optional()
922
+ const alternativesSchema = j.enum(optionalValues)
923
+ Object.assign(newBuilder.getSchema(), {
924
+ anyOf: [this.build(), alternativesSchema.build()],
925
+ optionalValues,
926
+ })
927
+
928
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
929
+ // so we must allow `null` values to be parsed by Ajv,
930
+ // but the typing should not reflect that.
931
+ // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
932
+ if (optionalValues.includes(null)) {
933
+ newBuilder = new JsonSchemaTerminal({
934
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
935
+ optionalField: true,
936
+ })
937
+ }
938
+
939
+ return newBuilder as any
940
+ }
941
+
942
+ regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
943
+ _assert(
944
+ !pattern.flags,
945
+ `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
946
+ )
947
+ return this.pattern(pattern.source, opt)
948
+ }
949
+
950
+ pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
951
+ const clone = this.cloneAndUpdateSchema({ pattern })
952
+ if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
953
+ if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
954
+ return clone
955
+ }
956
+
957
+ minLength(minLength: number): this {
958
+ return this.cloneAndUpdateSchema({ minLength })
959
+ }
960
+
961
+ maxLength(maxLength: number): this {
962
+ return this.cloneAndUpdateSchema({ maxLength })
963
+ }
964
+
965
+ length(exactLength: number): this
966
+ length(minLength: number, maxLength: number): this
967
+ length(minLengthOrExactLength: number, maxLength?: number): this {
968
+ const maxLengthActual = maxLength ?? minLengthOrExactLength
969
+ return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
970
+ }
971
+
972
+ email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
973
+ const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
974
+ return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
975
+ .trim()
976
+ .toLowerCase()
977
+ }
978
+
979
+ trim(): this {
980
+ return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
981
+ }
982
+
983
+ toLowerCase(): this {
984
+ return this.cloneAndUpdateSchema({
985
+ transform: { ...this.schema.transform, toLowerCase: true },
986
+ })
987
+ }
988
+
989
+ toUpperCase(): this {
990
+ return this.cloneAndUpdateSchema({
991
+ transform: { ...this.schema.transform, toUpperCase: true },
992
+ })
993
+ }
994
+
995
+ truncate(toLength: number): this {
996
+ return this.cloneAndUpdateSchema({
997
+ transform: { ...this.schema.transform, truncate: toLength },
998
+ })
999
+ }
1000
+
1001
+ branded<B extends string>(): JsonSchemaStringBuilder<B, Opt> {
1002
+ return this as unknown as JsonSchemaStringBuilder<B, Opt>
1003
+ }
1004
+
1005
+ /**
1006
+ * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
1007
+ *
1008
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
1009
+ * because this call effectively starts a new schema chain.
1010
+ */
1011
+ isoDate(): JsonSchemaIsoDateBuilder {
1012
+ return new JsonSchemaIsoDateBuilder()
1013
+ }
1014
+
1015
+ isoDateTime(): JsonSchemaStringBuilder<IsoDateTime, Opt> {
1016
+ return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
1017
+ }
1018
+
1019
+ isoMonth(): JsonSchemaIsoMonthBuilder {
1020
+ return new JsonSchemaIsoMonthBuilder()
1021
+ }
1022
+
1023
+ /**
1024
+ * Validates the string format to be JWT.
1025
+ * Expects the JWT to be signed!
1026
+ */
1027
+ jwt(): this {
1028
+ return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
1029
+ }
1030
+
1031
+ url(): this {
1032
+ return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
1033
+ }
1034
+
1035
+ ipv4(): this {
1036
+ return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
1037
+ }
1038
+
1039
+ ipv6(): this {
1040
+ return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
1041
+ }
1042
+
1043
+ slug(): this {
1044
+ return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
1045
+ }
1046
+
1047
+ semVer(): this {
1048
+ return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
1049
+ }
1050
+
1051
+ languageTag(): this {
1052
+ return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
1053
+ }
1054
+
1055
+ countryCode(): this {
1056
+ return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
1057
+ }
1058
+
1059
+ currency(): this {
1060
+ return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
1061
+ }
1062
+
1063
+ /**
1064
+ * Validates that the input is a valid IANATimzone value.
1065
+ *
1066
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
1067
+ * because this call effectively starts a new schema chain as an `enum` validation.
1068
+ */
1069
+ ianaTimezone(): JsonSchemaEnumBuilder<IANATimezone, false> {
1070
+ // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
1071
+ return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
1072
+ }
1073
+
1074
+ base64Url(): this {
1075
+ return this.regex(BASE64URL_REGEX, {
1076
+ msg: 'contains characters not allowed in Base64 URL characterset',
1077
+ })
1078
+ }
1079
+
1080
+ uuid(): this {
1081
+ return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
1082
+ }
1083
+ }
1084
+
1085
+ export interface JsonSchemaStringEmailOptions {
1086
+ checkTLD: boolean
1087
+ }
1088
+
1089
+ export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1090
+ IsoDate,
1091
+ Opt
1092
+ > {
1093
+ constructor() {
1094
+ super({
1095
+ type: 'string',
1096
+ IsoDate: {},
1097
+ })
1098
+ }
1099
+
1100
+ /**
1101
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1102
+ *
1103
+ * This `null` feature only works when the current schema is nested in an object or array schema,
1104
+ * due to how mutability works in Ajv.
1105
+ *
1106
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1107
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1108
+ */
1109
+ override optional<N extends null | undefined = undefined>(
1110
+ nullValue?: N,
1111
+ ): N extends null
1112
+ ? JsonSchemaTerminal<IsoDate | undefined, true>
1113
+ : JsonSchemaAnyBuilder<IsoDate | undefined, true> {
1114
+ if (nullValue === undefined) {
1115
+ return super.optional() as any
1116
+ }
1117
+
1118
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1119
+ // so we must allow `null` values to be parsed by Ajv,
1120
+ // but the typing should not reflect that.
1121
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1122
+ return new JsonSchemaTerminal({
1123
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1124
+ optionalField: true,
1125
+ }) as any
1126
+ }
1127
+
1128
+ before(date: string): this {
1129
+ return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
1130
+ }
1131
+
1132
+ sameOrBefore(date: string): this {
1133
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
1134
+ }
1135
+
1136
+ after(date: string): this {
1137
+ return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
1138
+ }
1139
+
1140
+ sameOrAfter(date: string): this {
1141
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
1142
+ }
1143
+
1144
+ between(fromDate: string, toDate: string, incl: Inclusiveness): this {
1145
+ let schemaPatch: Partial<JsonSchema> = {}
1146
+
1147
+ if (incl === '[)') {
1148
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
1149
+ } else if (incl === '[]') {
1150
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
1151
+ }
1152
+
1153
+ return this.cloneAndUpdateSchema(schemaPatch)
1154
+ }
1155
+ }
1156
+
1157
+ export interface JsonSchemaIsoDateOptions {
1158
+ before?: string
1159
+ sameOrBefore?: string
1160
+ after?: string
1161
+ sameOrAfter?: string
1162
+ }
1163
+
1164
+ export class JsonSchemaIsoMonthBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1165
+ IsoMonth,
1166
+ Opt
1167
+ > {
1168
+ constructor() {
1169
+ super({
1170
+ type: 'string',
1171
+ IsoMonth: {},
1172
+ })
1173
+ }
1174
+ }
1175
+
1176
+ export interface JsonSchemaIsoMonthOptions {}
1177
+
1178
+ export class JsonSchemaNumberBuilder<
1179
+ OUT extends number | undefined = number,
1180
+ Opt extends boolean = false,
1181
+ > extends JsonSchemaAnyBuilder<OUT, Opt> {
1182
+ constructor() {
1183
+ super({
1184
+ type: 'number',
1185
+ })
1186
+ }
1187
+
1188
+ /**
1189
+ * @param optionalValues List of values that should be considered/converted as `undefined`.
1190
+ *
1191
+ * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
1192
+ * due to how mutability works in Ajv.
1193
+ *
1194
+ * Make sure this `optional()` call is at the end of your call chain.
1195
+ *
1196
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
1197
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1198
+ */
1199
+ override optional<T extends readonly (number | null)[] | undefined = undefined>(
1200
+ optionalValues?: T,
1201
+ ): T extends readonly (infer U)[]
1202
+ ? null extends U
1203
+ ? JsonSchemaTerminal<OUT | undefined, true>
1204
+ : JsonSchemaNumberBuilder<OUT | undefined, true>
1205
+ : JsonSchemaNumberBuilder<OUT | undefined, true> {
1206
+ if (!optionalValues) {
1207
+ return super.optional() as any
1208
+ }
1209
+
1210
+ _typeCast<(number | null)[]>(optionalValues)
1211
+
1212
+ let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaNumberBuilder<
1213
+ OUT,
1214
+ Opt
1215
+ >().optional()
1216
+ const alternativesSchema = j.enum(optionalValues)
1217
+ Object.assign(newBuilder.getSchema(), {
1218
+ anyOf: [this.build(), alternativesSchema.build()],
1219
+ optionalValues,
1220
+ })
1221
+
1222
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1223
+ // so we must allow `null` values to be parsed by Ajv,
1224
+ // but the typing should not reflect that.
1225
+ // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
1226
+ if (optionalValues.includes(null)) {
1227
+ newBuilder = new JsonSchemaTerminal({
1228
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
1229
+ optionalField: true,
1230
+ })
1231
+ }
1232
+
1233
+ return newBuilder as any
1234
+ }
1235
+
1236
+ integer(): this {
1237
+ return this.cloneAndUpdateSchema({ type: 'integer' })
1238
+ }
1239
+
1240
+ branded<B extends number>(): JsonSchemaNumberBuilder<B, Opt> {
1241
+ return this as unknown as JsonSchemaNumberBuilder<B, Opt>
1242
+ }
1243
+
1244
+ multipleOf(multipleOf: number): this {
1245
+ return this.cloneAndUpdateSchema({ multipleOf })
1246
+ }
1247
+
1248
+ min(minimum: number): this {
1249
+ return this.cloneAndUpdateSchema({ minimum })
1250
+ }
1251
+
1252
+ exclusiveMin(exclusiveMinimum: number): this {
1253
+ return this.cloneAndUpdateSchema({ exclusiveMinimum })
1254
+ }
1255
+
1256
+ max(maximum: number): this {
1257
+ return this.cloneAndUpdateSchema({ maximum })
1258
+ }
1259
+
1260
+ exclusiveMax(exclusiveMaximum: number): this {
1261
+ return this.cloneAndUpdateSchema({ exclusiveMaximum })
1262
+ }
1263
+
1264
+ lessThan(value: number): this {
1265
+ return this.exclusiveMax(value)
1266
+ }
1267
+
1268
+ lessThanOrEqual(value: number): this {
1269
+ return this.max(value)
1270
+ }
1271
+
1272
+ moreThan(value: number): this {
1273
+ return this.exclusiveMin(value)
1274
+ }
1275
+
1276
+ moreThanOrEqual(value: number): this {
1277
+ return this.min(value)
1278
+ }
1279
+
1280
+ equal(value: number): this {
1281
+ return this.min(value).max(value)
1282
+ }
1283
+
1284
+ range(minimum: number, maximum: number, incl: Inclusiveness): this {
1285
+ if (incl === '[)') {
1286
+ return this.moreThanOrEqual(minimum).lessThan(maximum)
1287
+ }
1288
+ return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
1289
+ }
1290
+
1291
+ int32(): this {
1292
+ const MIN_INT32 = -(2 ** 31)
1293
+ const MAX_INT32 = 2 ** 31 - 1
1294
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1295
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1296
+ const newMin = Math.max(MIN_INT32, currentMin)
1297
+ const newMax = Math.min(MAX_INT32, currentMax)
1298
+ return this.integer().min(newMin).max(newMax)
1299
+ }
1300
+
1301
+ int64(): this {
1302
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1303
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1304
+ const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
1305
+ const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
1306
+ return this.integer().min(newMin).max(newMax)
1307
+ }
1308
+
1309
+ float(): this {
1310
+ return this
1311
+ }
1312
+
1313
+ double(): this {
1314
+ return this
1315
+ }
1316
+
1317
+ unixTimestamp(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1318
+ return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
1319
+ }
1320
+
1321
+ unixTimestamp2000(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1322
+ return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
1323
+ }
1324
+
1325
+ unixTimestampMillis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1326
+ return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1327
+ }
1328
+
1329
+ unixTimestamp2000Millis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1330
+ return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1331
+ }
1332
+
1333
+ utcOffset(): this {
1334
+ return this.integer()
1335
+ .multipleOf(15)
1336
+ .min(-12 * 60)
1337
+ .max(14 * 60)
1338
+ }
1339
+
1340
+ utcOffsetHour(): this {
1341
+ return this.integer().min(-12).max(14)
1342
+ }
1343
+
1344
+ /**
1345
+ * Specify the precision of the floating point numbers by the number of digits after the ".".
1346
+ * Excess digits will be cut-off when the current schema is nested in an object or array schema,
1347
+ * due to how mutability works in Ajv.
1348
+ */
1349
+ precision(numberOfDigits: number): this {
1350
+ return this.cloneAndUpdateSchema({ precision: numberOfDigits })
1351
+ }
1352
+ }
1353
+
1354
+ export class JsonSchemaBooleanBuilder<
1355
+ OUT extends boolean | undefined = boolean,
1356
+ Opt extends boolean = false,
1357
+ > extends JsonSchemaAnyBuilder<OUT, Opt> {
1358
+ constructor() {
1359
+ super({
1360
+ type: 'boolean',
1361
+ })
1362
+ }
1363
+
1364
+ /**
1365
+ * @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
1366
+ *
1367
+ * This `optionalValue` feature only works when the current schema is nested in an object or array schema,
1368
+ * due to how mutability works in Ajv.
1369
+ */
1370
+ override optional(optionalValue?: boolean): JsonSchemaBooleanBuilder<OUT | undefined, true> {
1371
+ if (typeof optionalValue === 'undefined') {
1372
+ return super.optional() as unknown as JsonSchemaBooleanBuilder<OUT | undefined, true>
1373
+ }
1374
+
1375
+ const newBuilder = new JsonSchemaBooleanBuilder<OUT, Opt>().optional()
1376
+ const alternativesSchema = j.enum([optionalValue])
1377
+ Object.assign(newBuilder.getSchema(), {
1378
+ anyOf: [this.build(), alternativesSchema.build()],
1379
+ optionalValues: [optionalValue],
1380
+ })
1381
+
1382
+ return newBuilder
1383
+ }
1384
+ }
1385
+
1386
+ export class JsonSchemaObjectBuilder<
1387
+ OUT extends AnyObject,
1388
+ Opt extends boolean = false,
1389
+ > extends JsonSchemaAnyBuilder<OUT, Opt> {
1390
+ constructor(props?: AnyObject, opt?: JsonSchemaObjectBuilderOpts) {
1391
+ super({
1392
+ type: 'object',
1393
+ properties: {},
1394
+ required: [],
1395
+ additionalProperties: false,
1396
+ hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1397
+ patternProperties: opt?.patternProperties ?? undefined,
1398
+ keySchema: opt?.keySchema ?? undefined,
1399
+ })
1400
+
1401
+ if (props) this.addProperties(props)
1402
+ }
1403
+
1404
+ addProperties(props: AnyObject): this {
1405
+ const properties: Record<string, JsonSchema> = {}
1406
+ const required: string[] = []
1407
+
1408
+ for (const [key, builder] of Object.entries(props)) {
1409
+ const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1410
+ if (!isOptional) {
1411
+ required.push(key)
1412
+ }
1413
+
1414
+ const schema = builder.build()
1415
+ properties[key] = schema
1416
+ }
1417
+
1418
+ this.schema.properties = properties
1419
+ this.schema.required = _uniq(required).sort()
1420
+
1421
+ return this
1422
+ }
1423
+
1424
+ /**
1425
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1426
+ *
1427
+ * This `null` feature only works when the current schema is nested in an object or array schema,
1428
+ * due to how mutability works in Ajv.
1429
+ *
1430
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1431
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1432
+ */
1433
+ override optional<N extends null | undefined = undefined>(
1434
+ nullValue?: N,
1435
+ ): N extends null
1436
+ ? JsonSchemaTerminal<OUT | undefined, true>
1437
+ : JsonSchemaAnyBuilder<OUT | undefined, true> {
1438
+ if (nullValue === undefined) {
1439
+ return super.optional() as any
1440
+ }
1441
+
1442
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1443
+ // so we must allow `null` values to be parsed by Ajv,
1444
+ // but the typing should not reflect that.
1445
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1446
+ return new JsonSchemaTerminal({
1447
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1448
+ optionalField: true,
1449
+ }) as any
1450
+ }
1451
+
1452
+ /**
1453
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1454
+ */
1455
+
1456
+ allowAdditionalProperties(): this {
1457
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1458
+ }
1459
+
1460
+ extend<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1461
+ props: P,
1462
+ ): JsonSchemaObjectBuilder<
1463
+ Override<
1464
+ OUT,
1465
+ {
1466
+ // required keys
1467
+ [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1468
+ ? IsOpt extends true
1469
+ ? never
1470
+ : K
1471
+ : never]: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1472
+ } & {
1473
+ // optional keys
1474
+ [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1475
+ ? IsOpt extends true
1476
+ ? K
1477
+ : never
1478
+ : never]?: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1479
+ }
1480
+ >,
1481
+ false
1482
+ > {
1483
+ const newBuilder = new JsonSchemaObjectBuilder()
1484
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1485
+
1486
+ const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props)
1487
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1488
+
1489
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1490
+
1491
+ return newBuilder as any
1492
+ }
1493
+
1494
+ /**
1495
+ * Concatenates another schema to the current schema.
1496
+ *
1497
+ * It expects you to use `isOfType<T>()` in the chain,
1498
+ * otherwise the validation will throw. This is to ensure
1499
+ * that the schemas you concatenated match the intended final type.
1500
+ *
1501
+ * ```ts
1502
+ * interface Foo { foo: string }
1503
+ * const fooSchema = j.object<Foo>({ foo: j.string() })
1504
+ *
1505
+ * interface Bar { bar: number }
1506
+ * const barSchema = j.object<Bar>({ bar: j.number() })
1507
+ *
1508
+ * interface Shu { foo: string, bar: number }
1509
+ * const shuSchema = fooSchema.concat(barSchema).isOfType<Shu>() // important
1510
+ * ```
1511
+ */
1512
+ concat<OUT2 extends AnyObject>(
1513
+ other: JsonSchemaObjectBuilder<OUT2, any>,
1514
+ ): JsonSchemaObjectBuilder<OUT & OUT2, false> {
1515
+ const clone = this.clone()
1516
+ mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
1517
+ _objectAssign(clone.schema, { hasIsOfTypeCheck: false })
1518
+ return clone as unknown as JsonSchemaObjectBuilder<OUT & OUT2, false>
1519
+ }
1520
+
1521
+ /**
1522
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1523
+ */
1524
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1525
+ dbEntity() {
1526
+ return this.extend({
1527
+ id: j.string(),
1528
+ created: j.number().unixTimestamp2000(),
1529
+ updated: j.number().unixTimestamp2000(),
1530
+ })
1531
+ }
1532
+
1533
+ minProperties(minProperties: number): this {
1534
+ return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
1535
+ }
1536
+
1537
+ maxProperties(maxProperties: number): this {
1538
+ return this.cloneAndUpdateSchema({ maxProperties })
1539
+ }
1540
+
1541
+ exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
1542
+ const exclusiveProperties = this.schema.exclusiveProperties ?? []
1543
+ return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
1544
+ }
1545
+ }
1546
+
1547
+ interface JsonSchemaObjectBuilderOpts {
1548
+ hasIsOfTypeCheck?: false
1549
+ patternProperties?: StringMap<JsonSchema<any>>
1550
+ keySchema?: JsonSchema
1551
+ }
1552
+
1553
+ export class JsonSchemaObjectInferringBuilder<
1554
+ PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>,
1555
+ Opt extends boolean = false,
1556
+ > extends JsonSchemaAnyBuilder<
1557
+ Expand<
1558
+ {
1559
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1560
+ ? IsOpt extends true
1561
+ ? never
1562
+ : K
1563
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1564
+ } & {
1565
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1566
+ ? IsOpt extends true
1567
+ ? K
1568
+ : never
1569
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1570
+ }
1571
+ >,
1572
+ Opt
1573
+ > {
1574
+ constructor(props?: PROPS) {
1575
+ super({
1576
+ type: 'object',
1577
+ properties: {},
1578
+ required: [],
1579
+ additionalProperties: false,
1580
+ })
1581
+
1582
+ if (props) this.addProperties(props)
1583
+ }
1584
+
1585
+ addProperties(props: PROPS): this {
1586
+ const properties: Record<string, JsonSchema> = {}
1587
+ const required: string[] = []
1588
+
1589
+ for (const [key, builder] of Object.entries(props)) {
1590
+ const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1591
+ if (!isOptional) {
1592
+ required.push(key)
1593
+ }
1594
+
1595
+ const schema = builder.build()
1596
+ properties[key] = schema
1597
+ }
1598
+
1599
+ this.schema.properties = properties
1600
+ this.schema.required = _uniq(required).sort()
1601
+
1602
+ return this
1603
+ }
1604
+
1605
+ /**
1606
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1607
+ *
1608
+ * This `null` feature only works when the current schema is nested in an object or array schema,
1609
+ * due to how mutability works in Ajv.
1610
+ *
1611
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1612
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1613
+ */
1614
+ // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1615
+ override optional<N extends null | undefined = undefined>(
1616
+ nullValue?: N,
1617
+ ): N extends null
1618
+ ? JsonSchemaTerminal<
1619
+ | Expand<
1620
+ {
1621
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1622
+ ? IsOpt extends true
1623
+ ? never
1624
+ : K
1625
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1626
+ } & {
1627
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1628
+ ? IsOpt extends true
1629
+ ? K
1630
+ : never
1631
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1632
+ }
1633
+ >
1634
+ | undefined,
1635
+ true
1636
+ >
1637
+ : JsonSchemaAnyBuilder<
1638
+ | Expand<
1639
+ {
1640
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1641
+ ? IsOpt extends true
1642
+ ? never
1643
+ : K
1644
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1645
+ } & {
1646
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1647
+ ? IsOpt extends true
1648
+ ? K
1649
+ : never
1650
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1651
+ }
1652
+ >
1653
+ | undefined,
1654
+ true
1655
+ > {
1656
+ if (nullValue === undefined) {
1657
+ return super.optional() as any
1658
+ }
1659
+
1660
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1661
+ // so we must allow `null` values to be parsed by Ajv,
1662
+ // but the typing should not reflect that.
1663
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1664
+ return new JsonSchemaTerminal({
1665
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1666
+ optionalField: true,
1667
+ }) as any
1668
+ }
1669
+
1670
+ /**
1671
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1672
+ */
1673
+
1674
+ allowAdditionalProperties(): this {
1675
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1676
+ }
1677
+
1678
+ extend<NEW_PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1679
+ props: NEW_PROPS,
1680
+ ): JsonSchemaObjectInferringBuilder<
1681
+ {
1682
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1683
+ ? NEW_PROPS[K]
1684
+ : K extends keyof PROPS
1685
+ ? PROPS[K]
1686
+ : never
1687
+ },
1688
+ Opt
1689
+ > {
1690
+ const newBuilder = new JsonSchemaObjectInferringBuilder<PROPS, Opt>()
1691
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1692
+
1693
+ const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder<NEW_PROPS, false>(props)
1694
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1695
+
1696
+ // This extend function is not type-safe as it is inferring,
1697
+ // so even if the base schema was already type-checked,
1698
+ // the new schema loses that quality.
1699
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1700
+
1701
+ return newBuilder as unknown as JsonSchemaObjectInferringBuilder<
1702
+ {
1703
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1704
+ ? NEW_PROPS[K]
1705
+ : K extends keyof PROPS
1706
+ ? PROPS[K]
1707
+ : never
1708
+ },
1709
+ Opt
1710
+ >
1711
+ }
1712
+
1713
+ /**
1714
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1715
+ */
1716
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1717
+ dbEntity() {
1718
+ return this.extend({
1719
+ id: j.string(),
1720
+ created: j.number().unixTimestamp2000(),
1721
+ updated: j.number().unixTimestamp2000(),
1722
+ })
1723
+ }
1724
+ }
1725
+
1726
+ export class JsonSchemaArrayBuilder<OUT, Opt> extends JsonSchemaAnyBuilder<OUT[], Opt> {
1727
+ constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1728
+ super({
1729
+ type: 'array',
1730
+ items: itemsSchema.build(),
1731
+ })
1732
+ }
1733
+
1734
+ minLength(minItems: number): this {
1735
+ return this.cloneAndUpdateSchema({ minItems })
1736
+ }
1737
+
1738
+ maxLength(maxItems: number): this {
1739
+ return this.cloneAndUpdateSchema({ maxItems })
1740
+ }
1741
+
1742
+ length(exactLength: number): this
1743
+ length(minItems: number, maxItems: number): this
1744
+ length(minItemsOrExact: number, maxItems?: number): this {
1745
+ const maxItemsActual = maxItems ?? minItemsOrExact
1746
+ return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
1747
+ }
1748
+
1749
+ exactLength(length: number): this {
1750
+ return this.minLength(length).maxLength(length)
1751
+ }
1752
+
1753
+ unique(): this {
1754
+ return this.cloneAndUpdateSchema({ uniqueItems: true })
1755
+ }
1756
+ }
1757
+
1758
+ export class JsonSchemaSet2Builder<OUT, Opt> extends JsonSchemaAnyBuilder<Set2<OUT>, Opt> {
1759
+ constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1760
+ super({
1761
+ type: ['array', 'object'],
1762
+ Set2: itemsSchema.build(),
1763
+ })
1764
+ }
1765
+
1766
+ min(minItems: number): this {
1767
+ return this.cloneAndUpdateSchema({ minItems })
1768
+ }
1769
+
1770
+ max(maxItems: number): this {
1771
+ return this.cloneAndUpdateSchema({ maxItems })
1772
+ }
1773
+ }
1774
+
1775
+ export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder<Buffer, false> {
1776
+ constructor() {
1777
+ super({
1778
+ Buffer: true,
1779
+ })
1780
+ }
1781
+ }
1782
+
1783
+ export class JsonSchemaEnumBuilder<
1784
+ OUT extends string | number | boolean | null,
1785
+ Opt extends boolean = false,
1786
+ > extends JsonSchemaAnyBuilder<OUT, Opt> {
1787
+ constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
1788
+ const jsonSchema: JsonSchema = { enum: enumValues }
1789
+ // Specifying the base type helps in cases when we ask Ajv to coerce the types.
1790
+ // Having only the `enum` in the schema does not trigger a coercion in Ajv.
1791
+ if (baseType === 'string') jsonSchema.type = 'string'
1792
+ if (baseType === 'number') jsonSchema.type = 'number'
1793
+
1794
+ super(jsonSchema)
1795
+
1796
+ if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1797
+ if (opt?.msg) this.setErrorMessage('enum', opt.msg)
1798
+ }
1799
+
1800
+ branded<B extends OUT>(): JsonSchemaEnumBuilder<B, Opt> {
1801
+ return this as unknown as JsonSchemaEnumBuilder<B, Opt>
1802
+ }
1803
+ }
1804
+
1805
+ export class JsonSchemaTupleBuilder<
1806
+ ITEMS extends JsonSchemaAnyBuilder<any, any>[],
1807
+ > extends JsonSchemaAnyBuilder<TupleOut<ITEMS>, false> {
1808
+ constructor(items: ITEMS) {
1809
+ super({
1810
+ type: 'array',
1811
+ prefixItems: items.map(i => i.build()),
1812
+ minItems: items.length,
1813
+ maxItems: items.length,
1814
+ })
1815
+ }
1816
+ }
1817
+
1818
+ export class JsonSchemaAnyOfByBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1819
+ constructor(
1820
+ propertyName: string,
1821
+ schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1822
+ ) {
1823
+ const builtSchemaDictionary: Record<string, JsonSchema> = {}
1824
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
1825
+ builtSchemaDictionary[key] = schema.build()
1826
+ }
1827
+
1828
+ super({
1829
+ type: 'object',
1830
+ hasIsOfTypeCheck: true,
1831
+ anyOfBy: {
1832
+ propertyName,
1833
+ schemaDictionary: builtSchemaDictionary,
1834
+ },
1835
+ })
1836
+ }
1837
+ }
1838
+
1839
+ export class JsonSchemaAnyOfTheseBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1840
+ constructor(
1841
+ propertyName: string,
1842
+ schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1843
+ ) {
1844
+ const builtSchemaDictionary: Record<string, JsonSchema> = {}
1845
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
1846
+ builtSchemaDictionary[key] = schema.build()
1847
+ }
1848
+
1849
+ super({
1850
+ type: 'object',
1851
+ hasIsOfTypeCheck: true,
1852
+ anyOfBy: {
1853
+ propertyName,
1854
+ schemaDictionary: builtSchemaDictionary,
1855
+ },
1856
+ })
1857
+ }
1858
+ }
1859
+
1860
+ type EnumBaseType = 'string' | 'number' | 'other'
1861
+
1862
+ export interface JsonSchema<OUT = unknown> {
1863
+ readonly out?: OUT
1864
+
1865
+ $schema?: string
1866
+ $id?: string
1867
+ title?: string
1868
+ description?: string
1869
+ // $comment?: string
1870
+ deprecated?: boolean
1871
+ readOnly?: boolean
1872
+ writeOnly?: boolean
1873
+
1874
+ type?: string | string[]
1875
+ items?: JsonSchema
1876
+ prefixItems?: JsonSchema[]
1877
+ properties?: {
1878
+ [K in keyof OUT]: JsonSchema<OUT[K]>
1879
+ }
1880
+ patternProperties?: StringMap<JsonSchema<any>>
1881
+ required?: string[]
1882
+ additionalProperties?: boolean
1883
+ minProperties?: number
1884
+ maxProperties?: number
1885
+
1886
+ default?: OUT
1887
+
1888
+ // https://json-schema.org/understanding-json-schema/reference/conditionals.html#id6
1889
+ if?: JsonSchema
1890
+ then?: JsonSchema
1891
+ else?: JsonSchema
1892
+
1893
+ anyOf?: JsonSchema[]
1894
+ oneOf?: JsonSchema[]
1895
+
1896
+ /**
1897
+ * This is a temporary "intermediate AST" field that is used inside the parser.
1898
+ * In the final schema this field will NOT be present.
1899
+ */
1900
+ optionalField?: true
1901
+
1902
+ pattern?: string
1903
+ minLength?: number
1904
+ maxLength?: number
1905
+ format?: string
1906
+
1907
+ contentMediaType?: string
1908
+ contentEncoding?: string // e.g 'base64'
1909
+
1910
+ multipleOf?: number
1911
+ minimum?: number
1912
+ exclusiveMinimum?: number
1913
+ maximum?: number
1914
+ exclusiveMaximum?: number
1915
+ minItems?: number
1916
+ maxItems?: number
1917
+ uniqueItems?: boolean
1918
+
1919
+ enum?: any
1920
+
1921
+ hasIsOfTypeCheck?: boolean
1922
+
1923
+ // Below we add custom Ajv keywords
1924
+
1925
+ email?: JsonSchemaStringEmailOptions
1926
+ Set2?: JsonSchema
1927
+ Buffer?: true
1928
+ IsoDate?: JsonSchemaIsoDateOptions
1929
+ IsoDateTime?: true
1930
+ IsoMonth?: JsonSchemaIsoMonthOptions
1931
+ instanceof?: string | string[]
1932
+ transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }
1933
+ errorMessages?: StringMap<string>
1934
+ optionalValues?: (string | number | boolean | null)[]
1935
+ keySchema?: JsonSchema
1936
+ isUndefined?: true
1937
+ minProperties2?: number
1938
+ exclusiveProperties?: (readonly string[])[]
1939
+ anyOfBy?: {
1940
+ propertyName: string
1941
+ schemaDictionary: Record<string, JsonSchema>
1942
+ }
1943
+ anyOfThese?: JsonSchema[]
1944
+ precision?: number
1945
+ customValidations?: CustomValidatorFn[]
1946
+ customConversions?: CustomConverterFn<any>[]
1947
+ postValidation?: PostValidatonFn<any, OUT>
1948
+ }
1949
+
1950
+ function object(props: AnyObject): never
1951
+ function object<OUT extends AnyObject>(props: {
1952
+ [K in keyof Required<OUT>]-?: JsonSchemaTerminal<OUT[K], any>
1953
+ }): JsonSchemaObjectBuilder<OUT, false>
1954
+
1955
+ function object<OUT extends AnyObject>(props: {
1956
+ [key in keyof OUT]: JsonSchemaTerminal<OUT[key], any>
1957
+ }): JsonSchemaObjectBuilder<OUT, false> {
1958
+ return new JsonSchemaObjectBuilder<OUT, false>(props)
1959
+ }
1960
+
1961
+ function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1962
+ props: P,
1963
+ ): JsonSchemaObjectInferringBuilder<P, false> {
1964
+ return new JsonSchemaObjectInferringBuilder<P, false>(props)
1965
+ }
1966
+
1967
+ function objectDbEntity(props: AnyObject): never
1968
+ function objectDbEntity<
1969
+ OUT extends BaseDBEntity,
1970
+ EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
1971
+ keyof OUT,
1972
+ keyof BaseDBEntity
1973
+ >,
1974
+ >(
1975
+ props: {
1976
+ // ✅ all non-system fields must be explicitly provided
1977
+ [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
1978
+ } &
1979
+ // ✅ if `id` differs, it's required
1980
+ (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
1981
+ ? { id?: BuilderFor<BaseDBEntity['id']> }
1982
+ : { id: BuilderFor<OUT['id']> }) &
1983
+ (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
1984
+ ? { created?: BuilderFor<BaseDBEntity['created']> }
1985
+ : { created: BuilderFor<OUT['created']> }) &
1986
+ (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
1987
+ ? { updated?: BuilderFor<BaseDBEntity['updated']> }
1988
+ : { updated: BuilderFor<OUT['updated']> }),
1989
+ ): JsonSchemaObjectBuilder<OUT, false>
1990
+
1991
+ function objectDbEntity(props: AnyObject): any {
1992
+ return j.object({
1993
+ id: j.string(),
1994
+ created: j.number().unixTimestamp2000(),
1995
+ updated: j.number().unixTimestamp2000(),
1996
+ ...props,
1997
+ })
1998
+ }
1999
+
2000
+ function record<
2001
+ KS extends JsonSchemaAnyBuilder<any, any>,
2002
+ VS extends JsonSchemaAnyBuilder<any, any>,
2003
+ Opt extends boolean = SchemaOpt<VS>,
2004
+ >(
2005
+ keySchema: KS,
2006
+ valueSchema: VS,
2007
+ ): JsonSchemaObjectBuilder<
2008
+ Opt extends true
2009
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2010
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
2011
+ false
2012
+ > {
2013
+ const keyJsonSchema = keySchema.build()
2014
+ // Check if value schema is optional before build() strips the optionalField flag
2015
+ const isValueOptional = (valueSchema as JsonSchemaTerminal<any, any>).getSchema().optionalField
2016
+ const valueJsonSchema = valueSchema.build()
2017
+
2018
+ // When value schema is optional, wrap in anyOf to allow undefined values
2019
+ const finalValueSchema: JsonSchema = isValueOptional
2020
+ ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
2021
+ : valueJsonSchema
2022
+
2023
+ return new JsonSchemaObjectBuilder<
2024
+ Opt extends true
2025
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2026
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
2027
+ false
2028
+ >([], {
2029
+ hasIsOfTypeCheck: false,
2030
+ keySchema: keyJsonSchema,
2031
+ patternProperties: {
2032
+ ['^.*$']: finalValueSchema,
2033
+ },
2034
+ })
2035
+ }
2036
+
2037
+ function withRegexKeys<S extends JsonSchemaAnyBuilder<any, any>>(
2038
+ keyRegex: RegExp | string,
2039
+ schema: S,
2040
+ ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false> {
2041
+ if (keyRegex instanceof RegExp) {
2042
+ _assert(
2043
+ !keyRegex.flags,
2044
+ `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
2045
+ )
2046
+ }
2047
+ const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
2048
+ const jsonSchema = schema.build()
2049
+
2050
+ return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false>([], {
2051
+ hasIsOfTypeCheck: false,
2052
+ patternProperties: {
2053
+ [pattern]: jsonSchema,
2054
+ },
2055
+ })
2056
+ }
2057
+
2058
+ /**
2059
+ * Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
2060
+ */
2061
+ function withEnumKeys<
2062
+ const T extends readonly (string | number)[] | StringEnum | NumberEnum,
2063
+ S extends JsonSchemaAnyBuilder<any, any>,
2064
+ K extends string | number = EnumKeyUnion<T>,
2065
+ Opt extends boolean = SchemaOpt<S>,
2066
+ >(
2067
+ keys: T,
2068
+ schema: S,
2069
+ ): JsonSchemaObjectBuilder<
2070
+ Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2071
+ false
2072
+ > {
2073
+ let enumValues: readonly (string | number)[] | undefined
2074
+ if (Array.isArray(keys)) {
2075
+ _assert(
2076
+ isEveryItemPrimitive(keys),
2077
+ 'Every item in the key list should be string, number or symbol',
2078
+ )
2079
+ enumValues = keys
2080
+ } else if (typeof keys === 'object') {
2081
+ const enumType = getEnumType(keys)
2082
+ _assert(
2083
+ enumType === 'NumberEnum' || enumType === 'StringEnum',
2084
+ 'The key list should be StringEnum or NumberEnum',
2085
+ )
2086
+ if (enumType === 'NumberEnum') {
2087
+ enumValues = _numberEnumValues(keys as NumberEnum)
2088
+ } else if (enumType === 'StringEnum') {
2089
+ enumValues = _stringEnumValues(keys as StringEnum)
2090
+ }
2091
+ }
2092
+
2093
+ _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
2094
+
2095
+ const typedValues = enumValues as readonly K[]
2096
+ const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
2097
+
2098
+ return new JsonSchemaObjectBuilder<
2099
+ Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2100
+ false
2101
+ >(props, { hasIsOfTypeCheck: false })
2102
+ }
2103
+
2104
+ function hasNoObjectSchemas(schema: JsonSchema): boolean {
2105
+ if (Array.isArray(schema.type)) {
2106
+ schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type))
2107
+ } else if (schema.anyOf) {
2108
+ return schema.anyOf.every(hasNoObjectSchemas)
2109
+ } else if (schema.oneOf) {
2110
+ return schema.oneOf.every(hasNoObjectSchemas)
2111
+ } else if (schema.enum) {
2112
+ return true
2113
+ } else if (schema.type === 'array') {
2114
+ return !schema.items || hasNoObjectSchemas(schema.items)
2115
+ } else {
2116
+ return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
2117
+ }
2118
+
2119
+ return false
2120
+ }
2121
+
2122
+ type Expand<T> = { [K in keyof T]: T[K] }
2123
+
2124
+ type StripIndexSignatureDeep<T> = T extends readonly unknown[]
2125
+ ? T
2126
+ : T extends Record<string, any>
2127
+ ? {
2128
+ [K in keyof T as string extends K
2129
+ ? never
2130
+ : number extends K
2131
+ ? never
2132
+ : symbol extends K
2133
+ ? never
2134
+ : K]: StripIndexSignatureDeep<T[K]>
2135
+ }
2136
+ : T
2137
+
2138
+ type RelaxIndexSignature<T> = T extends readonly unknown[]
2139
+ ? T
2140
+ : T extends AnyObject
2141
+ ? { [K in keyof T]: RelaxIndexSignature<T[K]> }
2142
+ : T
2143
+
2144
+ type Override<T, U> = Omit<T, keyof U> & U
2145
+
2146
+ declare const allowExtraKeysSymbol: unique symbol
2147
+
2148
+ type HasAllowExtraKeys<T> = T extends { readonly [allowExtraKeysSymbol]?: true } ? true : false
2149
+
2150
+ type IsAny<T> = 0 extends 1 & T ? true : false
2151
+
2152
+ type IsAssignableRelaxed<A, B> =
2153
+ IsAny<RelaxIndexSignature<A>> extends true
2154
+ ? true
2155
+ : [RelaxIndexSignature<A>] extends [B]
2156
+ ? true
2157
+ : false
2158
+
2159
+ type ExactMatchBase<A, B> =
2160
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
2161
+ ? (<T>() => T extends B ? 1 : 2) extends <T>() => T extends A ? 1 : 2
2162
+ ? true
2163
+ : false
2164
+ : false
2165
+
2166
+ type ExactMatch<A, B> =
2167
+ HasAllowExtraKeys<B> extends true
2168
+ ? IsAssignableRelaxed<B, A>
2169
+ : ExactMatchBase<Expand<A>, Expand<B>> extends true
2170
+ ? true
2171
+ : ExactMatchBase<Expand<StripIndexSignatureDeep<A>>, Expand<StripIndexSignatureDeep<B>>>
2172
+
2173
+ type BuilderOutUnion<B extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2174
+ [K in keyof B]: B[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2175
+ }[number]
2176
+
2177
+ type AnyOfByOut<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>> = {
2178
+ [K in keyof D]: D[K] extends JsonSchemaTerminal<infer O, any> ? O : never
2179
+ }[keyof D]
2180
+
2181
+ type BuilderFor<T> = JsonSchemaAnyBuilder<T, any>
2182
+
2183
+ interface JsonBuilderRuleOpt {
2184
+ /**
2185
+ * Text of error message to return when the validation fails for the given rule:
2186
+ *
2187
+ * `{ msg: "is not a valid Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"`
2188
+ */
2189
+ msg?: string
2190
+ /**
2191
+ * A friendly name for what we are validating, that will be used in error messages:
2192
+ *
2193
+ * `{ name: "Oompa-loompa" } => "Object.property is not a valid Oompa-loompa"`
2194
+ */
2195
+ name?: string
2196
+ }
2197
+
2198
+ type EnumKeyUnion<T> =
2199
+ // array of literals -> union of its elements
2200
+ T extends readonly (infer U)[]
2201
+ ? U
2202
+ : // enum object -> union of its values
2203
+ T extends StringEnum | NumberEnum
2204
+ ? T[keyof T]
2205
+ : never
2206
+
2207
+ type SchemaOut<S> = S extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
2208
+ type SchemaOpt<S> =
2209
+ S extends JsonSchemaAnyBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
2210
+
2211
+ type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2212
+ [K in keyof T]: T[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2213
+ }
2214
+
2215
+ export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
2216
+ export type CustomValidatorFn = (v: any) => string | undefined
2217
+ export type CustomConverterFn<OUT> = (v: any) => OUT
2218
+
2219
+ /**
2220
+ * Deep copy that preserves functions in customValidations/customConversions.
2221
+ * Unlike structuredClone, this handles function references (which only exist in those two properties).
2222
+ */
2223
+ function deepCopyPreservingFunctions<T>(obj: T): T {
2224
+ if (obj === null || typeof obj !== 'object') return obj
2225
+ if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
2226
+ const copy = {} as T
2227
+ for (const key of Object.keys(obj)) {
2228
+ const value = (obj as any)[key]
2229
+ // customValidations/customConversions are arrays of functions - shallow copy the array
2230
+ ;(copy as any)[key] =
2231
+ (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
2232
+ ? [...value]
2233
+ : deepCopyPreservingFunctions(value)
2234
+ }
2235
+ return copy
2236
+ }