@naturalcycles/nodejs-lib 15.86.0 → 15.88.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.
@@ -389,7 +389,7 @@ export function createAjv(opt) {
389
389
  validate: function validate(optionalValues, data, _schema, ctx) {
390
390
  if (!optionalValues)
391
391
  return true;
392
- _assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `optional([x, y, z]) on a property of an object, or on an element of an array due to Ajv mutation issues.');
392
+ _assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `optional([x, y, z])` on a property of an object, or on an element of an array due to Ajv mutation issues.');
393
393
  if (!optionalValues.includes(data))
394
394
  return true;
395
395
  ctx.parentData[ctx.parentDataProperty] = undefined;
@@ -417,6 +417,15 @@ export function createAjv(opt) {
417
417
  return validate;
418
418
  },
419
419
  });
420
+ // Validates that the value is undefined. Used in record/stringMap with optional value schemas
421
+ // to allow undefined values in patternProperties via anyOf.
422
+ ajv.addKeyword({
423
+ keyword: 'isUndefined',
424
+ modifying: false,
425
+ errors: false,
426
+ schemaType: 'boolean',
427
+ validate: (_schema, data) => data === undefined,
428
+ });
420
429
  // This is added because Ajv validates the `min/maxProperties` before validating the properties.
421
430
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
422
431
  // if the property validation would strip `foo` from the data.
@@ -569,11 +578,51 @@ export function createAjv(opt) {
569
578
  validate: function validate(numberOfDigits, data, _schema, ctx) {
570
579
  if (!numberOfDigits)
571
580
  return true;
572
- _assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `precision(n) on a property of an object, or on an element of an array due to Ajv mutation issues.');
581
+ _assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `precision(n)` on a property of an object, or on an element of an array due to Ajv mutation issues.');
573
582
  ctx.parentData[ctx.parentDataProperty] = _round(data, 10 ** (-1 * numberOfDigits));
574
583
  return true;
575
584
  },
576
585
  });
586
+ ajv.addKeyword({
587
+ keyword: 'customValidations',
588
+ modifying: false,
589
+ errors: true,
590
+ schemaType: 'array',
591
+ validate: function validate(customValidations, data, _schema, ctx) {
592
+ if (!customValidations?.length)
593
+ return true;
594
+ for (const validator of customValidations) {
595
+ const error = validator(data);
596
+ if (error) {
597
+ ;
598
+ validate.errors = [
599
+ {
600
+ instancePath: ctx?.instancePath ?? '',
601
+ message: error,
602
+ },
603
+ ];
604
+ return false;
605
+ }
606
+ }
607
+ return true;
608
+ },
609
+ });
610
+ ajv.addKeyword({
611
+ keyword: 'customConversions',
612
+ modifying: true,
613
+ errors: false,
614
+ schemaType: 'array',
615
+ validate: function validate(customConversions, data, _schema, ctx) {
616
+ if (!customConversions?.length)
617
+ return true;
618
+ _assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `convert()` on a property of an object, or on an element of an array due to Ajv mutation issues.');
619
+ for (const converter of customConversions) {
620
+ data = converter(data);
621
+ }
622
+ ctx.parentData[ctx.parentDataProperty] = data;
623
+ return true;
624
+ },
625
+ });
577
626
  return ajv;
578
627
  }
579
628
  const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
@@ -161,6 +161,24 @@ export declare class JsonSchemaAnyBuilder<IN, OUT, Opt> extends JsonSchemaTermin
161
161
  * Locks the given schema chain and no other modification can be done to it.
162
162
  */
163
163
  final(): JsonSchemaTerminal<IN, OUT, Opt>;
164
+ /**
165
+ *
166
+ * @param validator A validator function that returns an error message or undefined.
167
+ *
168
+ * You may add multiple custom validators and they will be executed in the order you added them.
169
+ */
170
+ custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<IN, OUT2, Opt>;
171
+ /**
172
+ *
173
+ * @param converter A converter function that returns a new value.
174
+ *
175
+ * You may add multiple converters and they will be executed in the order you added them,
176
+ * each converter receiving the result from the previous one.
177
+ *
178
+ * This feature only works when the current schema is nested in an object or array schema,
179
+ * due to how mutability works in Ajv.
180
+ */
181
+ convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<IN, OUT2, Opt>;
164
182
  }
165
183
  export declare class JsonSchemaStringBuilder<IN extends string | undefined = string, OUT = IN, Opt extends boolean = false> extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
166
184
  constructor();
@@ -511,6 +529,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
511
529
  errorMessages?: StringMap<string>;
512
530
  optionalValues?: (string | number | boolean | null)[];
513
531
  keySchema?: JsonSchema;
532
+ isUndefined?: true;
514
533
  minProperties2?: number;
515
534
  exclusiveProperties?: (readonly string[])[];
516
535
  anyOfBy?: {
@@ -519,6 +538,8 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
519
538
  };
520
539
  anyOfThese?: JsonSchema[];
521
540
  precision?: number;
541
+ customValidations?: CustomValidatorFn[];
542
+ customConversions?: CustomConverterFn<any>[];
522
543
  }
523
544
  declare function object(props: AnyObject): never;
524
545
  declare function object<IN extends AnyObject>(props: {
@@ -617,4 +638,6 @@ type TupleIn<T extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
617
638
  type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
618
639
  [K in keyof T]: T[K] extends JsonSchemaAnyBuilder<any, infer O, any> ? O : never;
619
640
  };
641
+ export type CustomValidatorFn = (v: any) => string | undefined;
642
+ export type CustomConverterFn<OUT> = (v: any) => OUT;
620
643
  export {};
@@ -3,7 +3,7 @@
3
3
  import { _isUndefined, _numberEnumValues, _stringEnumValues, getEnumType, } from '@naturalcycles/js-lib';
4
4
  import { _uniq } from '@naturalcycles/js-lib/array';
5
5
  import { _assert } from '@naturalcycles/js-lib/error';
6
- import { _deepCopy, _sortObject } from '@naturalcycles/js-lib/object';
6
+ import { _sortObject } from '@naturalcycles/js-lib/object';
7
7
  import { _objectAssign, _typeCast, JWT_REGEX, } from '@naturalcycles/js-lib/types';
8
8
  import { BASE64URL_REGEX, COUNTRY_CODE_REGEX, CURRENCY_REGEX, IPV4_REGEX, IPV6_REGEX, LANGUAGE_TAG_REGEX, SEMVER_REGEX, SLUG_REGEX, URL_REGEX, UUID_REGEX, } from '../regexes.js';
9
9
  import { TIMEZONES } from '../timezones.js';
@@ -32,11 +32,15 @@ export const j = {
32
32
  return j.object({}).allowAdditionalProperties();
33
33
  },
34
34
  stringMap(schema) {
35
+ const isValueOptional = schema.getSchema().optionalField;
35
36
  const builtSchema = schema.build();
37
+ const finalValueSchema = isValueOptional
38
+ ? { anyOf: [{ isUndefined: true }, builtSchema] }
39
+ : builtSchema;
36
40
  return new JsonSchemaObjectBuilder({}, {
37
41
  hasIsOfTypeCheck: false,
38
42
  patternProperties: {
39
- '^.+$': builtSchema,
43
+ '^.+$': finalValueSchema,
40
44
  },
41
45
  });
42
46
  },
@@ -219,13 +223,13 @@ export class JsonSchemaTerminal {
219
223
  */
220
224
  build() {
221
225
  _assert(!(this.schema.optionalField && this.schema.default !== undefined), '.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference');
222
- const jsonSchema = _sortObject(JSON.parse(JSON.stringify(this.schema)), JSON_SCHEMA_ORDER);
226
+ const jsonSchema = _sortObject(deepCopyPreservingFunctions(this.schema), JSON_SCHEMA_ORDER);
223
227
  delete jsonSchema.optionalField;
224
228
  return jsonSchema;
225
229
  }
226
230
  clone() {
227
231
  const cloned = Object.create(Object.getPrototypeOf(this));
228
- cloned.schema = _deepCopy(this.schema);
232
+ cloned.schema = deepCopyPreservingFunctions(this.schema);
229
233
  return cloned;
230
234
  }
231
235
  cloneAndUpdateSchema(schema) {
@@ -314,6 +318,34 @@ export class JsonSchemaAnyBuilder extends JsonSchemaTerminal {
314
318
  final() {
315
319
  return new JsonSchemaTerminal(this.schema);
316
320
  }
321
+ /**
322
+ *
323
+ * @param validator A validator function that returns an error message or undefined.
324
+ *
325
+ * You may add multiple custom validators and they will be executed in the order you added them.
326
+ */
327
+ custom(validator) {
328
+ const { customValidations = [] } = this.schema;
329
+ return this.cloneAndUpdateSchema({
330
+ customValidations: [...customValidations, validator],
331
+ });
332
+ }
333
+ /**
334
+ *
335
+ * @param converter A converter function that returns a new value.
336
+ *
337
+ * You may add multiple converters and they will be executed in the order you added them,
338
+ * each converter receiving the result from the previous one.
339
+ *
340
+ * This feature only works when the current schema is nested in an object or array schema,
341
+ * due to how mutability works in Ajv.
342
+ */
343
+ convert(converter) {
344
+ const { customConversions = [] } = this.schema;
345
+ return this.cloneAndUpdateSchema({
346
+ customConversions: [...customConversions, converter],
347
+ });
348
+ }
317
349
  }
318
350
  export class JsonSchemaStringBuilder extends JsonSchemaAnyBuilder {
319
351
  constructor() {
@@ -725,7 +757,7 @@ export class JsonSchemaObjectBuilder extends JsonSchemaAnyBuilder {
725
757
  }
726
758
  extend(props) {
727
759
  const newBuilder = new JsonSchemaObjectBuilder();
728
- _objectAssign(newBuilder.schema, _deepCopy(this.schema));
760
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema));
729
761
  const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props);
730
762
  mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema);
731
763
  _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false });
@@ -834,7 +866,7 @@ export class JsonSchemaObjectInferringBuilder extends JsonSchemaAnyBuilder {
834
866
  }
835
867
  extend(props) {
836
868
  const newBuilder = new JsonSchemaObjectInferringBuilder();
837
- _objectAssign(newBuilder.schema, _deepCopy(this.schema));
869
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema));
838
870
  const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder(props);
839
871
  mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema);
840
872
  // This extend function is not type-safe as it is inferring,
@@ -979,12 +1011,19 @@ function objectDbEntity(props) {
979
1011
  }
980
1012
  function record(keySchema, valueSchema) {
981
1013
  const keyJsonSchema = keySchema.build();
1014
+ // Check if value schema is optional before build() strips the optionalField flag
1015
+ const isValueOptional = valueSchema.getSchema()
1016
+ .optionalField;
982
1017
  const valueJsonSchema = valueSchema.build();
1018
+ // When value schema is optional, wrap in anyOf to allow undefined values
1019
+ const finalValueSchema = isValueOptional
1020
+ ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1021
+ : valueJsonSchema;
983
1022
  return new JsonSchemaObjectBuilder([], {
984
1023
  hasIsOfTypeCheck: false,
985
1024
  keySchema: keyJsonSchema,
986
1025
  patternProperties: {
987
- ['^.*$']: valueJsonSchema,
1026
+ ['^.*$']: finalValueSchema,
988
1027
  },
989
1028
  });
990
1029
  }
@@ -1046,3 +1085,22 @@ function hasNoObjectSchemas(schema) {
1046
1085
  }
1047
1086
  return false;
1048
1087
  }
1088
+ /**
1089
+ * Deep copy that preserves functions in customValidations/customConversions.
1090
+ * Unlike structuredClone, this handles function references (which only exist in those two properties).
1091
+ */
1092
+ function deepCopyPreservingFunctions(obj) {
1093
+ if (obj === null || typeof obj !== 'object')
1094
+ return obj;
1095
+ if (Array.isArray(obj))
1096
+ return obj.map(deepCopyPreservingFunctions);
1097
+ const copy = {};
1098
+ for (const key of Object.keys(obj)) {
1099
+ const value = obj[key];
1100
+ copy[key] =
1101
+ (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
1102
+ ? [...value]
1103
+ : deepCopyPreservingFunctions(value);
1104
+ }
1105
+ return copy;
1106
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.86.0",
4
+ "version": "15.88.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@types/js-yaml": "^4",
@@ -6,6 +6,8 @@ import type { AnyObject } from '@naturalcycles/js-lib/types'
6
6
  import { Ajv2020, type Options, type ValidateFunction } from 'ajv/dist/2020.js'
7
7
  import { validTLDs } from '../tlds.js'
8
8
  import type {
9
+ CustomConverterFn,
10
+ CustomValidatorFn,
9
11
  JsonSchemaIsoDateOptions,
10
12
  JsonSchemaIsoMonthOptions,
11
13
  JsonSchemaStringEmailOptions,
@@ -450,7 +452,7 @@ export function createAjv(opt?: Options): Ajv2020 {
450
452
 
451
453
  _assert(
452
454
  ctx?.parentData && ctx.parentDataProperty !== undefined,
453
- 'You should only use `optional([x, y, z]) on a property of an object, or on an element of an array due to Ajv mutation issues.',
455
+ 'You should only use `optional([x, y, z])` on a property of an object, or on an element of an array due to Ajv mutation issues.',
454
456
  )
455
457
 
456
458
  if (!optionalValues.includes(data)) return true
@@ -486,6 +488,16 @@ export function createAjv(opt?: Options): Ajv2020 {
486
488
  },
487
489
  })
488
490
 
491
+ // Validates that the value is undefined. Used in record/stringMap with optional value schemas
492
+ // to allow undefined values in patternProperties via anyOf.
493
+ ajv.addKeyword({
494
+ keyword: 'isUndefined',
495
+ modifying: false,
496
+ errors: false,
497
+ schemaType: 'boolean',
498
+ validate: (_schema: boolean, data: unknown) => data === undefined,
499
+ })
500
+
489
501
  // This is added because Ajv validates the `min/maxProperties` before validating the properties.
490
502
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
491
503
  // if the property validation would strip `foo` from the data.
@@ -650,7 +662,7 @@ export function createAjv(opt?: Options): Ajv2020 {
650
662
 
651
663
  _assert(
652
664
  ctx?.parentData && ctx.parentDataProperty !== undefined,
653
- 'You should only use `precision(n) on a property of an object, or on an element of an array due to Ajv mutation issues.',
665
+ 'You should only use `precision(n)` on a property of an object, or on an element of an array due to Ajv mutation issues.',
654
666
  )
655
667
 
656
668
  ctx.parentData[ctx.parentDataProperty] = _round(data, 10 ** (-1 * numberOfDigits))
@@ -659,6 +671,59 @@ export function createAjv(opt?: Options): Ajv2020 {
659
671
  },
660
672
  })
661
673
 
674
+ ajv.addKeyword({
675
+ keyword: 'customValidations',
676
+ modifying: false,
677
+ errors: true,
678
+ schemaType: 'array',
679
+ validate: function validate(customValidations: CustomValidatorFn[], data: any, _schema, ctx) {
680
+ if (!customValidations?.length) return true
681
+
682
+ for (const validator of customValidations) {
683
+ const error = validator(data)
684
+ if (error) {
685
+ ;(validate as any).errors = [
686
+ {
687
+ instancePath: ctx?.instancePath ?? '',
688
+ message: error,
689
+ },
690
+ ]
691
+ return false
692
+ }
693
+ }
694
+
695
+ return true
696
+ },
697
+ })
698
+
699
+ ajv.addKeyword({
700
+ keyword: 'customConversions',
701
+ modifying: true,
702
+ errors: false,
703
+ schemaType: 'array',
704
+ validate: function validate(
705
+ customConversions: CustomConverterFn<any>[],
706
+ data: any,
707
+ _schema,
708
+ ctx,
709
+ ) {
710
+ if (!customConversions?.length) return true
711
+
712
+ _assert(
713
+ ctx?.parentData && ctx.parentDataProperty !== undefined,
714
+ 'You should only use `convert()` on a property of an object, or on an element of an array due to Ajv mutation issues.',
715
+ )
716
+
717
+ for (const converter of customConversions) {
718
+ data = converter(data)
719
+ }
720
+
721
+ ctx.parentData[ctx.parentDataProperty] = data
722
+
723
+ return true
724
+ },
725
+ })
726
+
662
727
  return ajv
663
728
  }
664
729
 
@@ -10,7 +10,7 @@ import {
10
10
  import { _uniq } from '@naturalcycles/js-lib/array'
11
11
  import { _assert } from '@naturalcycles/js-lib/error'
12
12
  import type { Set2 } from '@naturalcycles/js-lib/object'
13
- import { _deepCopy, _sortObject } from '@naturalcycles/js-lib/object'
13
+ import { _sortObject } from '@naturalcycles/js-lib/object'
14
14
  import {
15
15
  _objectAssign,
16
16
  _typeCast,
@@ -80,14 +80,18 @@ export const j = {
80
80
  stringMap<S extends JsonSchemaTerminal<any, any, any>>(
81
81
  schema: S,
82
82
  ): JsonSchemaObjectBuilder<StringMap<SchemaIn<S>>, StringMap<SchemaOut<S>>> {
83
+ const isValueOptional = schema.getSchema().optionalField
83
84
  const builtSchema = schema.build()
85
+ const finalValueSchema: JsonSchema = isValueOptional
86
+ ? { anyOf: [{ isUndefined: true }, builtSchema] }
87
+ : builtSchema
84
88
 
85
89
  return new JsonSchemaObjectBuilder<StringMap<SchemaIn<S>>, StringMap<SchemaOut<S>>>(
86
90
  {},
87
91
  {
88
92
  hasIsOfTypeCheck: false,
89
93
  patternProperties: {
90
- '^.+$': builtSchema,
94
+ '^.+$': finalValueSchema,
91
95
  },
92
96
  },
93
97
  )
@@ -334,7 +338,7 @@ export class JsonSchemaTerminal<IN, OUT, Opt> {
334
338
  )
335
339
 
336
340
  const jsonSchema = _sortObject(
337
- JSON.parse(JSON.stringify(this.schema)),
341
+ deepCopyPreservingFunctions(this.schema) as AnyObject,
338
342
  JSON_SCHEMA_ORDER,
339
343
  ) as JsonSchema<IN, OUT>
340
344
 
@@ -345,7 +349,7 @@ export class JsonSchemaTerminal<IN, OUT, Opt> {
345
349
 
346
350
  clone(): this {
347
351
  const cloned = Object.create(Object.getPrototypeOf(this))
348
- cloned.schema = _deepCopy(this.schema)
352
+ cloned.schema = deepCopyPreservingFunctions(this.schema)
349
353
  return cloned
350
354
  }
351
355
 
@@ -451,6 +455,36 @@ export class JsonSchemaAnyBuilder<IN, OUT, Opt> extends JsonSchemaTerminal<IN, O
451
455
  final(): JsonSchemaTerminal<IN, OUT, Opt> {
452
456
  return new JsonSchemaTerminal<IN, OUT, Opt>(this.schema)
453
457
  }
458
+
459
+ /**
460
+ *
461
+ * @param validator A validator function that returns an error message or undefined.
462
+ *
463
+ * You may add multiple custom validators and they will be executed in the order you added them.
464
+ */
465
+ custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<IN, OUT2, Opt> {
466
+ const { customValidations = [] } = this.schema
467
+ return this.cloneAndUpdateSchema({
468
+ customValidations: [...customValidations, validator],
469
+ }) as unknown as JsonSchemaAnyBuilder<IN, OUT2, Opt>
470
+ }
471
+
472
+ /**
473
+ *
474
+ * @param converter A converter function that returns a new value.
475
+ *
476
+ * You may add multiple converters and they will be executed in the order you added them,
477
+ * each converter receiving the result from the previous one.
478
+ *
479
+ * This feature only works when the current schema is nested in an object or array schema,
480
+ * due to how mutability works in Ajv.
481
+ */
482
+ convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<IN, OUT2, Opt> {
483
+ const { customConversions = [] } = this.schema
484
+ return this.cloneAndUpdateSchema({
485
+ customConversions: [...customConversions, converter],
486
+ }) as unknown as JsonSchemaAnyBuilder<IN, OUT2, Opt>
487
+ }
454
488
  }
455
489
 
456
490
  export class JsonSchemaStringBuilder<
@@ -1047,7 +1081,7 @@ export class JsonSchemaObjectBuilder<
1047
1081
  false
1048
1082
  > {
1049
1083
  const newBuilder = new JsonSchemaObjectBuilder()
1050
- _objectAssign(newBuilder.schema, _deepCopy(this.schema))
1084
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1051
1085
 
1052
1086
  const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props)
1053
1087
  mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
@@ -1301,7 +1335,7 @@ export class JsonSchemaObjectInferringBuilder<
1301
1335
  Opt
1302
1336
  > {
1303
1337
  const newBuilder = new JsonSchemaObjectInferringBuilder<PROPS, Opt>()
1304
- _objectAssign(newBuilder.schema, _deepCopy(this.schema))
1338
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1305
1339
 
1306
1340
  const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder<NEW_PROPS, false>(props)
1307
1341
  mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
@@ -1574,6 +1608,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1574
1608
  errorMessages?: StringMap<string>
1575
1609
  optionalValues?: (string | number | boolean | null)[]
1576
1610
  keySchema?: JsonSchema
1611
+ isUndefined?: true
1577
1612
  minProperties2?: number
1578
1613
  exclusiveProperties?: (readonly string[])[]
1579
1614
  anyOfBy?: {
@@ -1582,6 +1617,8 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1582
1617
  }
1583
1618
  anyOfThese?: JsonSchema[]
1584
1619
  precision?: number
1620
+ customValidations?: CustomValidatorFn[]
1621
+ customConversions?: CustomConverterFn<any>[]
1585
1622
  }
1586
1623
 
1587
1624
  function object(props: AnyObject): never
@@ -1648,8 +1685,16 @@ function record<
1648
1685
  false
1649
1686
  > {
1650
1687
  const keyJsonSchema = keySchema.build()
1688
+ // Check if value schema is optional before build() strips the optionalField flag
1689
+ const isValueOptional = (valueSchema as JsonSchemaTerminal<any, any, any>).getSchema()
1690
+ .optionalField
1651
1691
  const valueJsonSchema = valueSchema.build()
1652
1692
 
1693
+ // When value schema is optional, wrap in anyOf to allow undefined values
1694
+ const finalValueSchema: JsonSchema = isValueOptional
1695
+ ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1696
+ : valueJsonSchema
1697
+
1653
1698
  return new JsonSchemaObjectBuilder<
1654
1699
  Opt extends true
1655
1700
  ? Partial<Record<SchemaIn<KS>, SchemaIn<VS>>>
@@ -1662,7 +1707,7 @@ function record<
1662
1707
  hasIsOfTypeCheck: false,
1663
1708
  keySchema: keyJsonSchema,
1664
1709
  patternProperties: {
1665
- ['^.*$']: valueJsonSchema,
1710
+ ['^.*$']: finalValueSchema,
1666
1711
  },
1667
1712
  })
1668
1713
  }
@@ -1880,3 +1925,25 @@ type TupleIn<T extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
1880
1925
  type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any, any>[]> = {
1881
1926
  [K in keyof T]: T[K] extends JsonSchemaAnyBuilder<any, infer O, any> ? O : never
1882
1927
  }
1928
+
1929
+ export type CustomValidatorFn = (v: any) => string | undefined
1930
+ export type CustomConverterFn<OUT> = (v: any) => OUT
1931
+
1932
+ /**
1933
+ * Deep copy that preserves functions in customValidations/customConversions.
1934
+ * Unlike structuredClone, this handles function references (which only exist in those two properties).
1935
+ */
1936
+ function deepCopyPreservingFunctions<T>(obj: T): T {
1937
+ if (obj === null || typeof obj !== 'object') return obj
1938
+ if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
1939
+ const copy = {} as T
1940
+ for (const key of Object.keys(obj)) {
1941
+ const value = (obj as any)[key]
1942
+ // customValidations/customConversions are arrays of functions - shallow copy the array
1943
+ ;(copy as any)[key] =
1944
+ (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
1945
+ ? [...value]
1946
+ : deepCopyPreservingFunctions(value)
1947
+ }
1948
+ return copy
1949
+ }