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