@naturalcycles/nodejs-lib 15.83.0 → 15.85.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.
@@ -24,9 +24,9 @@ export interface ReadableTyped<T = unknown> extends Readable {
24
24
  take: (limit: number, opt?: ReadableSignalOptions) => ReadableTyped<T>;
25
25
  drop: (limit: number, opt?: ReadableSignalOptions) => ReadableTyped<T>;
26
26
  }
27
- export interface WritableTyped<T> extends Writable {
27
+ export interface WritableTyped<_T> extends Writable {
28
28
  }
29
- export interface TransformTyped<IN = unknown, OUT = unknown> extends Transform {
29
+ export interface TransformTyped<_IN = unknown, _OUT = unknown> extends Transform {
30
30
  }
31
31
  export interface TransformOptions {
32
32
  /**
@@ -1,4 +1,5 @@
1
1
  import { _isBetween, _lazyValue } from '@naturalcycles/js-lib';
2
+ import { _assert } from '@naturalcycles/js-lib/error';
2
3
  import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object';
3
4
  import { _substringAfterLast } from '@naturalcycles/js-lib/string';
4
5
  import { Ajv2020 } from 'ajv/dist/2020.js';
@@ -165,7 +166,7 @@ export function createAjv(opt) {
165
166
  }
166
167
  idx++;
167
168
  }
168
- if (ctx?.parentData && ctx.parentDataProperty) {
169
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
169
170
  ctx.parentData[ctx.parentDataProperty] = set;
170
171
  }
171
172
  return true;
@@ -215,7 +216,7 @@ export function createAjv(opt) {
215
216
  ];
216
217
  return false;
217
218
  }
218
- if (ctx?.parentData && ctx.parentDataProperty) {
219
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
219
220
  ctx.parentData[ctx.parentDataProperty] = buffer;
220
221
  }
221
222
  return true;
@@ -258,7 +259,7 @@ export function createAjv(opt) {
258
259
  return false;
259
260
  }
260
261
  }
261
- if (ctx?.parentData && ctx.parentDataProperty) {
262
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
262
263
  ctx.parentData[ctx.parentDataProperty] = cleanData;
263
264
  }
264
265
  return true;
@@ -381,18 +382,17 @@ export function createAjv(opt) {
381
382
  });
382
383
  ajv.addKeyword({
383
384
  keyword: 'optionalValues',
384
- type: ['string', 'number', 'boolean'],
385
+ type: ['string', 'number', 'boolean', 'null'],
385
386
  modifying: true,
386
387
  errors: false,
387
388
  schemaType: 'array',
388
389
  validate: function validate(optionalValues, data, _schema, ctx) {
389
390
  if (!optionalValues)
390
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.');
391
393
  if (!optionalValues.includes(data))
392
394
  return true;
393
- if (ctx?.parentData && ctx.parentDataProperty) {
394
- delete ctx.parentData[ctx.parentDataProperty];
395
- }
395
+ ctx.parentData[ctx.parentDataProperty] = undefined;
396
396
  return true;
397
397
  },
398
398
  });
@@ -417,7 +417,7 @@ export function createAjv(opt) {
417
417
  return validate;
418
418
  },
419
419
  });
420
- // This and `maxProperties2` are added because Ajv validates the `min/maxProperties` before validating the properties.
420
+ // This is added because Ajv validates the `min/maxProperties` before validating the properties.
421
421
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
422
422
  // if the property validation would strip `foo` from the data.
423
423
  // And Ajv would return `{}` as a successful validation.
@@ -434,7 +434,7 @@ export function createAjv(opt) {
434
434
  validate: function validate(minProperties, data, _schema, ctx) {
435
435
  if (typeof data !== 'object')
436
436
  return true;
437
- const numberOfProperties = Object.getOwnPropertyNames(data).length;
437
+ const numberOfProperties = Object.entries(data).filter(([, v]) => v !== undefined).length;
438
438
  const isValid = numberOfProperties >= minProperties;
439
439
  if (!isValid) {
440
440
  ;
@@ -533,7 +533,7 @@ export function createAjv(opt) {
533
533
  break;
534
534
  }
535
535
  }
536
- if (result && ctx?.parentData && ctx.parentDataProperty) {
536
+ if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
537
537
  // If we found a validator and the data is valid and we are validating a property inside an object,
538
538
  // then we can inject our result and be done with it.
539
539
  ctx.parentData[ctx.parentDataProperty] = clonedData;
@@ -169,8 +169,13 @@ export declare class JsonSchemaStringBuilder<IN extends string | undefined = str
169
169
  *
170
170
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
171
171
  * due to how mutability works in Ajv.
172
+ *
173
+ * Make sure this `optional()` call is at the end of your call chain.
174
+ *
175
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
176
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
172
177
  */
173
- optional(optionalValues?: string[]): JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true>;
178
+ optional<T extends readonly (string | null)[] | undefined = undefined>(optionalValues?: T): T extends readonly (infer U)[] ? null extends U ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true> : JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true> : JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true>;
174
179
  regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this;
175
180
  pattern(pattern: string, opt?: JsonBuilderRuleOpt): this;
176
181
  minLength(minLength: number): this;
@@ -244,8 +249,13 @@ export declare class JsonSchemaNumberBuilder<IN extends number | undefined = num
244
249
  *
245
250
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
246
251
  * due to how mutability works in Ajv.
252
+ *
253
+ * Make sure this `optional()` call is at the end of your call chain.
254
+ *
255
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
256
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
247
257
  */
248
- optional(optionalValues?: number[]): JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true>;
258
+ optional<T extends readonly (number | null)[] | undefined = undefined>(optionalValues?: T): T extends readonly (infer U)[] ? null extends U ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true> : JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true> : JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true>;
249
259
  integer(): this;
250
260
  branded<B extends number>(): JsonSchemaNumberBuilder<B, B, Opt>;
251
261
  multipleOf(multipleOf: number): this;
@@ -283,6 +293,16 @@ export declare class JsonSchemaBooleanBuilder<IN extends boolean | undefined = b
283
293
  export declare class JsonSchemaObjectBuilder<IN extends AnyObject, OUT extends AnyObject, Opt extends boolean = false> extends JsonSchemaAnyBuilder<IN, OUT, Opt> {
284
294
  constructor(props?: AnyObject, opt?: JsonSchemaObjectBuilderOpts);
285
295
  addProperties(props: AnyObject): this;
296
+ /**
297
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
298
+ *
299
+ * This `null` feature only works when the current schema is nested in an object or array schema,
300
+ * due to how mutability works in Ajv.
301
+ *
302
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
303
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
304
+ */
305
+ optional<N extends null | undefined = undefined>(nullValue?: N): N extends null ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true> : JsonSchemaAnyBuilder<IN | undefined, OUT | undefined, true>;
286
306
  /**
287
307
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
288
308
  */
@@ -345,6 +365,32 @@ export declare class JsonSchemaObjectInferringBuilder<PROPS extends Record<strin
345
365
  }>, Opt> {
346
366
  constructor(props?: PROPS);
347
367
  addProperties(props: PROPS): this;
368
+ /**
369
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
370
+ *
371
+ * This `null` feature only works when the current schema is nested in an object or array schema,
372
+ * due to how mutability works in Ajv.
373
+ *
374
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
375
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
376
+ */
377
+ optional<N extends null | undefined = undefined>(nullValue?: N): N extends null ? JsonSchemaTerminal<Expand<{
378
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? never : K : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never;
379
+ } & {
380
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never;
381
+ }> | undefined, Expand<{
382
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? never : K : never]: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never;
383
+ } & {
384
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never;
385
+ }> | undefined, true> : JsonSchemaAnyBuilder<Expand<{
386
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? never : K : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never;
387
+ } & {
388
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never;
389
+ }> | undefined, Expand<{
390
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? never : K : never]: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never;
391
+ } & {
392
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never;
393
+ }> | undefined, true>;
348
394
  /**
349
395
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
350
396
  */
@@ -457,7 +503,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
457
503
  truncate?: number;
458
504
  };
459
505
  errorMessages?: StringMap<string>;
460
- optionalValues?: (string | number | boolean)[];
506
+ optionalValues?: (string | number | boolean | null)[];
461
507
  keySchema?: JsonSchema;
462
508
  minProperties2?: number;
463
509
  exclusiveProperties?: (readonly string[])[];
@@ -469,7 +515,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
469
515
  }
470
516
  declare function object(props: AnyObject): never;
471
517
  declare function object<IN extends AnyObject>(props: {
472
- [K in keyof Required<IN>]-?: JsonSchemaAnyBuilder<any, IN[K], any>;
518
+ [K in keyof Required<IN>]-?: JsonSchemaTerminal<any, IN[K], any>;
473
519
  }): JsonSchemaObjectBuilder<IN, IN, false>;
474
520
  declare function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any, any>>>(props: P): JsonSchemaObjectInferringBuilder<P, false>;
475
521
  declare function objectDbEntity(props: AnyObject): never;
@@ -4,7 +4,7 @@ import { _isUndefined, _numberEnumValues, _stringEnumValues, getEnumType, } from
4
4
  import { _uniq } from '@naturalcycles/js-lib/array';
5
5
  import { _assert } from '@naturalcycles/js-lib/error';
6
6
  import { _deepCopy, _sortObject } from '@naturalcycles/js-lib/object';
7
- import { _objectAssign, JWT_REGEX, } from '@naturalcycles/js-lib/types';
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';
10
10
  import { isEveryItemNumber, isEveryItemPrimitive, isEveryItemString, JSON_SCHEMA_ORDER, mergeJsonSchemaObjects, } from './jsonSchemaBuilder.util.js';
@@ -326,17 +326,33 @@ export class JsonSchemaStringBuilder extends JsonSchemaAnyBuilder {
326
326
  *
327
327
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
328
328
  * due to how mutability works in Ajv.
329
+ *
330
+ * Make sure this `optional()` call is at the end of your call chain.
331
+ *
332
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
333
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
329
334
  */
330
335
  optional(optionalValues) {
331
336
  if (!optionalValues) {
332
337
  return super.optional();
333
338
  }
334
- const newBuilder = new JsonSchemaStringBuilder().optional();
339
+ _typeCast(optionalValues);
340
+ let newBuilder = new JsonSchemaStringBuilder().optional();
335
341
  const alternativesSchema = j.enum(optionalValues);
336
342
  Object.assign(newBuilder.getSchema(), {
337
343
  anyOf: [this.build(), alternativesSchema.build()],
338
344
  optionalValues,
339
345
  });
346
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
347
+ // so we must allow `null` values to be parsed by Ajv,
348
+ // but the typing should not reflect that.
349
+ // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
350
+ if (optionalValues.includes(null)) {
351
+ newBuilder = new JsonSchemaTerminal({
352
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
353
+ optionalField: true,
354
+ });
355
+ }
340
356
  return newBuilder;
341
357
  }
342
358
  regex(pattern, opt) {
@@ -502,17 +518,33 @@ export class JsonSchemaNumberBuilder extends JsonSchemaAnyBuilder {
502
518
  *
503
519
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
504
520
  * due to how mutability works in Ajv.
521
+ *
522
+ * Make sure this `optional()` call is at the end of your call chain.
523
+ *
524
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
525
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
505
526
  */
506
527
  optional(optionalValues) {
507
528
  if (!optionalValues) {
508
529
  return super.optional();
509
530
  }
510
- const newBuilder = new JsonSchemaNumberBuilder().optional();
531
+ _typeCast(optionalValues);
532
+ let newBuilder = new JsonSchemaNumberBuilder().optional();
511
533
  const alternativesSchema = j.enum(optionalValues);
512
534
  Object.assign(newBuilder.getSchema(), {
513
535
  anyOf: [this.build(), alternativesSchema.build()],
514
536
  optionalValues,
515
537
  });
538
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
539
+ // so we must allow `null` values to be parsed by Ajv,
540
+ // but the typing should not reflect that.
541
+ // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
542
+ if (optionalValues.includes(null)) {
543
+ newBuilder = new JsonSchemaTerminal({
544
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
545
+ optionalField: true,
546
+ });
547
+ }
516
548
  return newBuilder;
517
549
  }
518
550
  integer() {
@@ -655,6 +687,28 @@ export class JsonSchemaObjectBuilder extends JsonSchemaAnyBuilder {
655
687
  this.schema.required = _uniq(required).sort();
656
688
  return this;
657
689
  }
690
+ /**
691
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
692
+ *
693
+ * This `null` feature only works when the current schema is nested in an object or array schema,
694
+ * due to how mutability works in Ajv.
695
+ *
696
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
697
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
698
+ */
699
+ optional(nullValue) {
700
+ if (typeof nullValue === 'undefined') {
701
+ return super.optional();
702
+ }
703
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
704
+ // so we must allow `null` values to be parsed by Ajv,
705
+ // but the typing should not reflect that.
706
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
707
+ return new JsonSchemaTerminal({
708
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
709
+ optionalField: true,
710
+ });
711
+ }
658
712
  /**
659
713
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
660
714
  */
@@ -741,6 +795,29 @@ export class JsonSchemaObjectInferringBuilder extends JsonSchemaAnyBuilder {
741
795
  this.schema.required = _uniq(required).sort();
742
796
  return this;
743
797
  }
798
+ /**
799
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
800
+ *
801
+ * This `null` feature only works when the current schema is nested in an object or array schema,
802
+ * due to how mutability works in Ajv.
803
+ *
804
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
805
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
806
+ */
807
+ // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
808
+ optional(nullValue) {
809
+ if (nullValue === undefined) {
810
+ return super.optional();
811
+ }
812
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
813
+ // so we must allow `null` values to be parsed by Ajv,
814
+ // but the typing should not reflect that.
815
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
816
+ return new JsonSchemaTerminal({
817
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
818
+ optionalField: true,
819
+ });
820
+ }
744
821
  /**
745
822
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
746
823
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.83.0",
4
+ "version": "15.85.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@types/js-yaml": "^4",
@@ -50,9 +50,11 @@ export interface ReadableTyped<T = unknown> extends Readable {
50
50
  }
51
51
 
52
52
  // oxlint-disable no-unused-vars
53
- export interface WritableTyped<T> extends Writable {}
53
+ // eslint-disable-next-line @typescript-eslint/naming-convention
54
+ export interface WritableTyped<_T> extends Writable {}
54
55
 
55
- export interface TransformTyped<IN = unknown, OUT = unknown> extends Transform {}
56
+ // eslint-disable-next-line @typescript-eslint/naming-convention
57
+ export interface TransformTyped<_IN = unknown, _OUT = unknown> extends Transform {}
56
58
  // oxlint-enable
57
59
 
58
60
  export interface TransformOptions {
@@ -1,4 +1,5 @@
1
1
  import { _isBetween, _lazyValue } from '@naturalcycles/js-lib'
2
+ import { _assert } from '@naturalcycles/js-lib/error'
2
3
  import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object'
3
4
  import { _substringAfterLast } from '@naturalcycles/js-lib/string'
4
5
  import type { AnyObject } from '@naturalcycles/js-lib/types'
@@ -195,7 +196,7 @@ export function createAjv(opt?: Options): Ajv2020 {
195
196
  idx++
196
197
  }
197
198
 
198
- if (ctx?.parentData && ctx.parentDataProperty) {
199
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
199
200
  ctx.parentData[ctx.parentDataProperty] = set
200
201
  }
201
202
 
@@ -248,7 +249,7 @@ export function createAjv(opt?: Options): Ajv2020 {
248
249
  return false
249
250
  }
250
251
 
251
- if (ctx?.parentData && ctx.parentDataProperty) {
252
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
252
253
  ctx.parentData[ctx.parentDataProperty] = buffer
253
254
  }
254
255
 
@@ -297,7 +298,7 @@ export function createAjv(opt?: Options): Ajv2020 {
297
298
  }
298
299
  }
299
300
 
300
- if (ctx?.parentData && ctx.parentDataProperty) {
301
+ if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
301
302
  ctx.parentData[ctx.parentDataProperty] = cleanData
302
303
  }
303
304
 
@@ -435,7 +436,7 @@ export function createAjv(opt?: Options): Ajv2020 {
435
436
 
436
437
  ajv.addKeyword({
437
438
  keyword: 'optionalValues',
438
- type: ['string', 'number', 'boolean'],
439
+ type: ['string', 'number', 'boolean', 'null'],
439
440
  modifying: true,
440
441
  errors: false,
441
442
  schemaType: 'array',
@@ -447,11 +448,14 @@ export function createAjv(opt?: Options): Ajv2020 {
447
448
  ) {
448
449
  if (!optionalValues) return true
449
450
 
451
+ _assert(
452
+ 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.',
454
+ )
455
+
450
456
  if (!optionalValues.includes(data)) return true
451
457
 
452
- if (ctx?.parentData && ctx.parentDataProperty) {
453
- delete ctx.parentData[ctx.parentDataProperty]
454
- }
458
+ ctx.parentData[ctx.parentDataProperty] = undefined
455
459
 
456
460
  return true
457
461
  },
@@ -482,7 +486,7 @@ export function createAjv(opt?: Options): Ajv2020 {
482
486
  },
483
487
  })
484
488
 
485
- // This and `maxProperties2` are added because Ajv validates the `min/maxProperties` before validating the properties.
489
+ // This is added because Ajv validates the `min/maxProperties` before validating the properties.
486
490
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
487
491
  // if the property validation would strip `foo` from the data.
488
492
  // And Ajv would return `{}` as a successful validation.
@@ -499,7 +503,7 @@ export function createAjv(opt?: Options): Ajv2020 {
499
503
  validate: function validate(minProperties: number, data: AnyObject, _schema, ctx) {
500
504
  if (typeof data !== 'object') return true
501
505
 
502
- const numberOfProperties = Object.getOwnPropertyNames(data).length
506
+ const numberOfProperties = Object.entries(data).filter(([, v]) => v !== undefined).length
503
507
  const isValid = numberOfProperties >= minProperties
504
508
  if (!isValid) {
505
509
  ;(validate as any).errors = [
@@ -608,7 +612,7 @@ export function createAjv(opt?: Options): Ajv2020 {
608
612
  }
609
613
  }
610
614
 
611
- if (result && ctx?.parentData && ctx.parentDataProperty) {
615
+ if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
612
616
  // If we found a validator and the data is valid and we are validating a property inside an object,
613
617
  // then we can inject our result and be done with it.
614
618
  ctx.parentData[ctx.parentDataProperty] = clonedData
@@ -13,6 +13,7 @@ import type { Set2 } from '@naturalcycles/js-lib/object'
13
13
  import { _deepCopy, _sortObject } from '@naturalcycles/js-lib/object'
14
14
  import {
15
15
  _objectAssign,
16
+ _typeCast,
16
17
  type AnyObject,
17
18
  type BaseDBEntity,
18
19
  type IANATimezone,
@@ -468,26 +469,45 @@ export class JsonSchemaStringBuilder<
468
469
  *
469
470
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
470
471
  * due to how mutability works in Ajv.
472
+ *
473
+ * Make sure this `optional()` call is at the end of your call chain.
474
+ *
475
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
476
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
471
477
  */
472
- override optional(
473
- optionalValues?: string[],
474
- ): JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true> {
478
+ override optional<T extends readonly (string | null)[] | undefined = undefined>(
479
+ optionalValues?: T,
480
+ ): T extends readonly (infer U)[]
481
+ ? null extends U
482
+ ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true>
483
+ : JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true>
484
+ : JsonSchemaStringBuilder<IN | undefined, OUT | undefined, true> {
475
485
  if (!optionalValues) {
476
- return super.optional() as unknown as JsonSchemaStringBuilder<
477
- IN | undefined,
478
- OUT | undefined,
479
- true
480
- >
486
+ return super.optional() as any
481
487
  }
482
488
 
483
- const newBuilder = new JsonSchemaStringBuilder<IN, OUT, Opt>().optional()
489
+ _typeCast<(string | null)[]>(optionalValues)
490
+
491
+ let newBuilder: JsonSchemaTerminal<IN | undefined, OUT | undefined, true> =
492
+ new JsonSchemaStringBuilder<IN, OUT, Opt>().optional()
484
493
  const alternativesSchema = j.enum(optionalValues)
485
494
  Object.assign(newBuilder.getSchema(), {
486
495
  anyOf: [this.build(), alternativesSchema.build()],
487
496
  optionalValues,
488
497
  })
489
498
 
490
- return newBuilder
499
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
500
+ // so we must allow `null` values to be parsed by Ajv,
501
+ // but the typing should not reflect that.
502
+ // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
503
+ if (optionalValues.includes(null)) {
504
+ newBuilder = new JsonSchemaTerminal({
505
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
506
+ optionalField: true,
507
+ })
508
+ }
509
+
510
+ return newBuilder as any
491
511
  }
492
512
 
493
513
  regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
@@ -716,27 +736,45 @@ export class JsonSchemaNumberBuilder<
716
736
  *
717
737
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
718
738
  * due to how mutability works in Ajv.
739
+ *
740
+ * Make sure this `optional()` call is at the end of your call chain.
741
+ *
742
+ * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
743
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
719
744
  */
720
-
721
- override optional(
722
- optionalValues?: number[],
723
- ): JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true> {
745
+ override optional<T extends readonly (number | null)[] | undefined = undefined>(
746
+ optionalValues?: T,
747
+ ): T extends readonly (infer U)[]
748
+ ? null extends U
749
+ ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true>
750
+ : JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true>
751
+ : JsonSchemaNumberBuilder<IN | undefined, OUT | undefined, true> {
724
752
  if (!optionalValues) {
725
- return super.optional() as unknown as JsonSchemaNumberBuilder<
726
- IN | undefined,
727
- OUT | undefined,
728
- true
729
- >
753
+ return super.optional() as any
730
754
  }
731
755
 
732
- const newBuilder = new JsonSchemaNumberBuilder<IN, OUT, Opt>().optional()
756
+ _typeCast<(number | null)[]>(optionalValues)
757
+
758
+ let newBuilder: JsonSchemaTerminal<IN | undefined, OUT | undefined, true> =
759
+ new JsonSchemaNumberBuilder<IN, OUT, Opt>().optional()
733
760
  const alternativesSchema = j.enum(optionalValues)
734
761
  Object.assign(newBuilder.getSchema(), {
735
762
  anyOf: [this.build(), alternativesSchema.build()],
736
763
  optionalValues,
737
764
  })
738
765
 
739
- return newBuilder
766
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
767
+ // so we must allow `null` values to be parsed by Ajv,
768
+ // but the typing should not reflect that.
769
+ // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
770
+ if (optionalValues.includes(null)) {
771
+ newBuilder = new JsonSchemaTerminal({
772
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
773
+ optionalField: true,
774
+ })
775
+ }
776
+
777
+ return newBuilder as any
740
778
  }
741
779
 
742
780
  integer(): this {
@@ -930,6 +968,34 @@ export class JsonSchemaObjectBuilder<
930
968
  return this
931
969
  }
932
970
 
971
+ /**
972
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
973
+ *
974
+ * This `null` feature only works when the current schema is nested in an object or array schema,
975
+ * due to how mutability works in Ajv.
976
+ *
977
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
978
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
979
+ */
980
+ override optional<N extends null | undefined = undefined>(
981
+ nullValue?: N,
982
+ ): N extends null
983
+ ? JsonSchemaTerminal<IN | undefined, OUT | undefined, true>
984
+ : JsonSchemaAnyBuilder<IN | undefined, OUT | undefined, true> {
985
+ if (typeof nullValue === 'undefined') {
986
+ return super.optional()
987
+ }
988
+
989
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
990
+ // so we must allow `null` values to be parsed by Ajv,
991
+ // but the typing should not reflect that.
992
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
993
+ return new JsonSchemaTerminal({
994
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
995
+ optionalField: true,
996
+ }) as any
997
+ }
998
+
933
999
  /**
934
1000
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
935
1001
  */
@@ -1108,6 +1174,103 @@ export class JsonSchemaObjectInferringBuilder<
1108
1174
  return this
1109
1175
  }
1110
1176
 
1177
+ /**
1178
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1179
+ *
1180
+ * This `null` 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
+ * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1184
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1185
+ */
1186
+ // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1187
+ override optional<N extends null | undefined = undefined>(
1188
+ nullValue?: N,
1189
+ ): N extends null
1190
+ ? JsonSchemaTerminal<
1191
+ | Expand<
1192
+ {
1193
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1194
+ ? IsOpt extends true
1195
+ ? never
1196
+ : K
1197
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
1198
+ } & {
1199
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1200
+ ? IsOpt extends true
1201
+ ? K
1202
+ : never
1203
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
1204
+ }
1205
+ >
1206
+ | undefined,
1207
+ | Expand<
1208
+ {
1209
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1210
+ ? IsOpt extends true
1211
+ ? never
1212
+ : K
1213
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
1214
+ } & {
1215
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1216
+ ? IsOpt extends true
1217
+ ? K
1218
+ : never
1219
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
1220
+ }
1221
+ >
1222
+ | undefined,
1223
+ true
1224
+ >
1225
+ : JsonSchemaAnyBuilder<
1226
+ | Expand<
1227
+ {
1228
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1229
+ ? IsOpt extends true
1230
+ ? never
1231
+ : K
1232
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
1233
+ } & {
1234
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1235
+ ? IsOpt extends true
1236
+ ? K
1237
+ : never
1238
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer IN, any, any> ? IN : never
1239
+ }
1240
+ >
1241
+ | undefined,
1242
+ | Expand<
1243
+ {
1244
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1245
+ ? IsOpt extends true
1246
+ ? never
1247
+ : K
1248
+ : never]: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
1249
+ } & {
1250
+ [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, any, infer IsOpt>
1251
+ ? IsOpt extends true
1252
+ ? K
1253
+ : never
1254
+ : never]?: PROPS[K] extends JsonSchemaAnyBuilder<any, infer OUT, any> ? OUT : never
1255
+ }
1256
+ >
1257
+ | undefined,
1258
+ true
1259
+ > {
1260
+ if (nullValue === undefined) {
1261
+ return super.optional() as any
1262
+ }
1263
+
1264
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1265
+ // so we must allow `null` values to be parsed by Ajv,
1266
+ // but the typing should not reflect that.
1267
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1268
+ return new JsonSchemaTerminal({
1269
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1270
+ optionalField: true,
1271
+ }) as any
1272
+ }
1273
+
1111
1274
  /**
1112
1275
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1113
1276
  */
@@ -1400,7 +1563,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1400
1563
  instanceof?: string | string[]
1401
1564
  transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }
1402
1565
  errorMessages?: StringMap<string>
1403
- optionalValues?: (string | number | boolean)[]
1566
+ optionalValues?: (string | number | boolean | null)[]
1404
1567
  keySchema?: JsonSchema
1405
1568
  minProperties2?: number
1406
1569
  exclusiveProperties?: (readonly string[])[]
@@ -1413,11 +1576,11 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1413
1576
 
1414
1577
  function object(props: AnyObject): never
1415
1578
  function object<IN extends AnyObject>(props: {
1416
- [K in keyof Required<IN>]-?: JsonSchemaAnyBuilder<any, IN[K], any>
1579
+ [K in keyof Required<IN>]-?: JsonSchemaTerminal<any, IN[K], any>
1417
1580
  }): JsonSchemaObjectBuilder<IN, IN, false>
1418
1581
 
1419
1582
  function object<IN extends AnyObject>(props: {
1420
- [key in keyof IN]: JsonSchemaAnyBuilder<any, IN[key], any>
1583
+ [key in keyof IN]: JsonSchemaTerminal<any, IN[key], any>
1421
1584
  }): JsonSchemaObjectBuilder<IN, IN, false> {
1422
1585
  return new JsonSchemaObjectBuilder<IN, IN, false>(props)
1423
1586
  }