@naturalcycles/nodejs-lib 15.82.1 → 15.84.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,5 +1,6 @@
1
1
  import { _isBetween, _lazyValue } from '@naturalcycles/js-lib';
2
- import { _mapObject, Set2 } from '@naturalcycles/js-lib/object';
2
+ import { _assert } from '@naturalcycles/js-lib/error';
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';
5
6
  import { validTLDs } from '../tlds.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
  ;
@@ -513,6 +513,53 @@ export function createAjv(opt) {
513
513
  return validate;
514
514
  },
515
515
  });
516
+ ajv.addKeyword({
517
+ keyword: 'anyOfThese',
518
+ modifying: true,
519
+ errors: true,
520
+ schemaType: 'array',
521
+ compile(schemas, _parentSchema, _it) {
522
+ const validators = schemas.map(schema => ajv.compile(schema));
523
+ function validate(data, ctx) {
524
+ let correctValidator;
525
+ let result = false;
526
+ let clonedData;
527
+ // Try each validator until we find one that works!
528
+ for (const validator of validators) {
529
+ clonedData = isPrimitive(data) ? _deepCopy(data) : data;
530
+ result = validator(clonedData);
531
+ if (result) {
532
+ correctValidator = validator;
533
+ break;
534
+ }
535
+ }
536
+ if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
537
+ // If we found a validator and the data is valid and we are validating a property inside an object,
538
+ // then we can inject our result and be done with it.
539
+ ctx.parentData[ctx.parentDataProperty] = clonedData;
540
+ }
541
+ else if (result) {
542
+ // If we found a validator but we are not validating a property inside an object,
543
+ // then we must re-run the validation so that the mutations caused by Ajv
544
+ // will be done on the input data, not only on the clone.
545
+ result = correctValidator(data);
546
+ }
547
+ else {
548
+ // If we didn't find a fitting schema,
549
+ // we add our own error.
550
+ ;
551
+ validate.errors = [
552
+ {
553
+ instancePath: ctx?.instancePath ?? '',
554
+ message: `could not find a suitable schema to validate against`,
555
+ },
556
+ ];
557
+ }
558
+ return result;
559
+ }
560
+ return validate;
561
+ },
562
+ });
516
563
  return ajv;
517
564
  }
518
565
  const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
@@ -652,3 +699,6 @@ function isIsoMonthValid(s) {
652
699
  const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE);
653
700
  return _isBetween(year, 1900, 2500, '[]') && _isBetween(month, 1, 12, '[]');
654
701
  }
702
+ function isPrimitive(data) {
703
+ return data !== null && typeof data === 'object';
704
+ }
@@ -68,7 +68,7 @@ export declare const j: {
68
68
  oneOf<B extends readonly JsonSchemaAnyBuilder<any, any, boolean>[], IN = BuilderInUnion<B>, OUT = BuilderOutUnion<B>>(items: [...B]): JsonSchemaAnyBuilder<IN, OUT, false>;
69
69
  /**
70
70
  * Use only with primitive values, otherwise this function will throw to avoid bugs.
71
- * To validate objects, use `anyOfBy`.
71
+ * To validate objects, use `anyOfBy` or `anyOfThese`.
72
72
  *
73
73
  * Our Ajv is configured to strip unexpected properties from objects,
74
74
  * and since Ajv is mutating the input, this means that it cannot
@@ -78,7 +78,28 @@ export declare const j: {
78
78
  * Use `oneOf` when schemas are mutually exclusive.
79
79
  */
80
80
  anyOf<B extends readonly JsonSchemaAnyBuilder<any, any, boolean>[], IN = BuilderInUnion<B>, OUT = BuilderOutUnion<B>>(items: [...B]): JsonSchemaAnyBuilder<IN, OUT, false>;
81
+ /**
82
+ * Pick validation schema for an object based on the value of a specific property.
83
+ *
84
+ * ```
85
+ * const schemaMap = {
86
+ * true: successSchema,
87
+ * false: errorSchema
88
+ * }
89
+ *
90
+ * const schema = j.anyOfBy('success', schemaMap)
91
+ * ```
92
+ */
81
93
  anyOfBy<P extends string, D extends Record<PropertyKey, JsonSchemaTerminal<any, any, any>>, IN = AnyOfByIn<D>, OUT = AnyOfByOut<D>>(propertyName: P, schemaDictionary: D): JsonSchemaAnyOfByBuilder<IN, OUT, P>;
94
+ /**
95
+ * Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
96
+ * This comes with a performance penalty, so do not use it where performance matters.
97
+ *
98
+ * ```
99
+ * const schema = j.anyOfThese([successSchema, errorSchema])
100
+ * ```
101
+ */
102
+ anyOfThese<B extends readonly JsonSchemaAnyBuilder<any, any, boolean>[], IN = BuilderInUnion<B>, OUT = BuilderOutUnion<B>>(items: [...B]): JsonSchemaAnyBuilder<IN, OUT, false>;
82
103
  and(): {
83
104
  silentBob: () => never;
84
105
  };
@@ -148,8 +169,13 @@ export declare class JsonSchemaStringBuilder<IN extends string | undefined = str
148
169
  *
149
170
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
150
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.
151
177
  */
152
- 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>;
153
179
  regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this;
154
180
  pattern(pattern: string, opt?: JsonBuilderRuleOpt): this;
155
181
  minLength(minLength: number): this;
@@ -223,8 +249,13 @@ export declare class JsonSchemaNumberBuilder<IN extends number | undefined = num
223
249
  *
224
250
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
225
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.
226
257
  */
227
- 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>;
228
259
  integer(): this;
229
260
  branded<B extends number>(): JsonSchemaNumberBuilder<B, B, Opt>;
230
261
  multipleOf(multipleOf: number): this;
@@ -369,6 +400,10 @@ export declare class JsonSchemaAnyOfByBuilder<IN, OUT, _P extends string = strin
369
400
  in: IN;
370
401
  constructor(propertyName: string, schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any, any>>);
371
402
  }
403
+ export declare class JsonSchemaAnyOfTheseBuilder<IN, OUT, _P extends string = string> extends JsonSchemaAnyBuilder<AnyOfByInput<IN, _P> | IN, OUT, false> {
404
+ in: IN;
405
+ constructor(propertyName: string, schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any, any>>);
406
+ }
372
407
  type EnumBaseType = 'string' | 'number' | 'other';
373
408
  export interface JsonSchema<IN = unknown, OUT = IN> {
374
409
  readonly in?: IN;
@@ -432,7 +467,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
432
467
  truncate?: number;
433
468
  };
434
469
  errorMessages?: StringMap<string>;
435
- optionalValues?: (string | number | boolean)[];
470
+ optionalValues?: (string | number | boolean | null)[];
436
471
  keySchema?: JsonSchema;
437
472
  minProperties2?: number;
438
473
  exclusiveProperties?: (readonly string[])[];
@@ -440,10 +475,11 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
440
475
  propertyName: string;
441
476
  schemaDictionary: Record<string, JsonSchema>;
442
477
  };
478
+ anyOfThese?: JsonSchema[];
443
479
  }
444
480
  declare function object(props: AnyObject): never;
445
481
  declare function object<IN extends AnyObject>(props: {
446
- [K in keyof Required<IN>]-?: JsonSchemaAnyBuilder<any, IN[K], any>;
482
+ [K in keyof Required<IN>]-?: JsonSchemaTerminal<any, IN[K], any>;
447
483
  }): JsonSchemaObjectBuilder<IN, IN, false>;
448
484
  declare function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any, any>>>(props: P): JsonSchemaObjectInferringBuilder<P, false>;
449
485
  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';
@@ -133,7 +133,7 @@ export const j = {
133
133
  },
134
134
  /**
135
135
  * Use only with primitive values, otherwise this function will throw to avoid bugs.
136
- * To validate objects, use `anyOfBy`.
136
+ * To validate objects, use `anyOfBy` or `anyOfThese`.
137
137
  *
138
138
  * Our Ajv is configured to strip unexpected properties from objects,
139
139
  * and since Ajv is mutating the input, this means that it cannot
@@ -149,9 +149,34 @@ export const j = {
149
149
  anyOf: schemas,
150
150
  });
151
151
  },
152
+ /**
153
+ * Pick validation schema for an object based on the value of a specific property.
154
+ *
155
+ * ```
156
+ * const schemaMap = {
157
+ * true: successSchema,
158
+ * false: errorSchema
159
+ * }
160
+ *
161
+ * const schema = j.anyOfBy('success', schemaMap)
162
+ * ```
163
+ */
152
164
  anyOfBy(propertyName, schemaDictionary) {
153
165
  return new JsonSchemaAnyOfByBuilder(propertyName, schemaDictionary);
154
166
  },
167
+ /**
168
+ * Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
169
+ * This comes with a performance penalty, so do not use it where performance matters.
170
+ *
171
+ * ```
172
+ * const schema = j.anyOfThese([successSchema, errorSchema])
173
+ * ```
174
+ */
175
+ anyOfThese(items) {
176
+ return new JsonSchemaAnyBuilder({
177
+ anyOfThese: items.map(b => b.build()),
178
+ });
179
+ },
155
180
  and() {
156
181
  return {
157
182
  silentBob: () => {
@@ -301,17 +326,33 @@ export class JsonSchemaStringBuilder extends JsonSchemaAnyBuilder {
301
326
  *
302
327
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
303
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.
304
334
  */
305
335
  optional(optionalValues) {
306
336
  if (!optionalValues) {
307
337
  return super.optional();
308
338
  }
309
- const newBuilder = new JsonSchemaStringBuilder().optional();
339
+ _typeCast(optionalValues);
340
+ let newBuilder = new JsonSchemaStringBuilder().optional();
310
341
  const alternativesSchema = j.enum(optionalValues);
311
342
  Object.assign(newBuilder.getSchema(), {
312
343
  anyOf: [this.build(), alternativesSchema.build()],
313
344
  optionalValues,
314
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
+ }
315
356
  return newBuilder;
316
357
  }
317
358
  regex(pattern, opt) {
@@ -477,17 +518,33 @@ export class JsonSchemaNumberBuilder extends JsonSchemaAnyBuilder {
477
518
  *
478
519
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
479
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.
480
526
  */
481
527
  optional(optionalValues) {
482
528
  if (!optionalValues) {
483
529
  return super.optional();
484
530
  }
485
- const newBuilder = new JsonSchemaNumberBuilder().optional();
531
+ _typeCast(optionalValues);
532
+ let newBuilder = new JsonSchemaNumberBuilder().optional();
486
533
  const alternativesSchema = j.enum(optionalValues);
487
534
  Object.assign(newBuilder.getSchema(), {
488
535
  anyOf: [this.build(), alternativesSchema.build()],
489
536
  optionalValues,
490
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
+ }
491
548
  return newBuilder;
492
549
  }
493
550
  integer() {
@@ -837,6 +894,22 @@ export class JsonSchemaAnyOfByBuilder extends JsonSchemaAnyBuilder {
837
894
  });
838
895
  }
839
896
  }
897
+ export class JsonSchemaAnyOfTheseBuilder extends JsonSchemaAnyBuilder {
898
+ constructor(propertyName, schemaDictionary) {
899
+ const builtSchemaDictionary = {};
900
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
901
+ builtSchemaDictionary[key] = schema.build();
902
+ }
903
+ super({
904
+ type: 'object',
905
+ hasIsOfTypeCheck: true,
906
+ anyOfBy: {
907
+ propertyName,
908
+ schemaDictionary: builtSchemaDictionary,
909
+ },
910
+ });
911
+ }
912
+ }
840
913
  function object(props) {
841
914
  return new JsonSchemaObjectBuilder(props);
842
915
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.82.1",
4
+ "version": "15.84.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,5 +1,6 @@
1
1
  import { _isBetween, _lazyValue } from '@naturalcycles/js-lib'
2
- import { _mapObject, Set2 } from '@naturalcycles/js-lib/object'
2
+ import { _assert } from '@naturalcycles/js-lib/error'
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'
5
6
  import { Ajv2020, type Options, type ValidateFunction } from 'ajv/dist/2020.js'
@@ -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 = [
@@ -585,6 +589,56 @@ export function createAjv(opt?: Options): Ajv2020 {
585
589
  },
586
590
  })
587
591
 
592
+ ajv.addKeyword({
593
+ keyword: 'anyOfThese',
594
+ modifying: true,
595
+ errors: true,
596
+ schemaType: 'array',
597
+ compile(schemas: JsonSchemaTerminal<any, any, any>[], _parentSchema, _it) {
598
+ const validators = schemas.map(schema => ajv.compile(schema))
599
+
600
+ function validate(data: AnyObject, ctx: any): boolean {
601
+ let correctValidator: ValidateFunction<unknown> | undefined
602
+ let result = false
603
+ let clonedData: any
604
+
605
+ // Try each validator until we find one that works!
606
+ for (const validator of validators) {
607
+ clonedData = isPrimitive(data) ? _deepCopy(data) : data
608
+ result = validator(clonedData)
609
+ if (result) {
610
+ correctValidator = validator
611
+ break
612
+ }
613
+ }
614
+
615
+ if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
616
+ // If we found a validator and the data is valid and we are validating a property inside an object,
617
+ // then we can inject our result and be done with it.
618
+ ctx.parentData[ctx.parentDataProperty] = clonedData
619
+ } else if (result) {
620
+ // If we found a validator but we are not validating a property inside an object,
621
+ // then we must re-run the validation so that the mutations caused by Ajv
622
+ // will be done on the input data, not only on the clone.
623
+ result = correctValidator!(data)
624
+ } else {
625
+ // If we didn't find a fitting schema,
626
+ // we add our own error.
627
+ ;(validate as any).errors = [
628
+ {
629
+ instancePath: ctx?.instancePath ?? '',
630
+ message: `could not find a suitable schema to validate against`,
631
+ },
632
+ ]
633
+ }
634
+
635
+ return result
636
+ }
637
+
638
+ return validate
639
+ },
640
+ })
641
+
588
642
  return ajv
589
643
  }
590
644
 
@@ -728,3 +782,7 @@ function isIsoMonthValid(s: string): boolean {
728
782
 
729
783
  return _isBetween(year, 1900, 2500, '[]') && _isBetween(month, 1, 12, '[]')
730
784
  }
785
+
786
+ function isPrimitive(data: any): boolean {
787
+ return data !== null && typeof data === 'object'
788
+ }
@@ -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,
@@ -217,7 +218,7 @@ export const j = {
217
218
 
218
219
  /**
219
220
  * Use only with primitive values, otherwise this function will throw to avoid bugs.
220
- * To validate objects, use `anyOfBy`.
221
+ * To validate objects, use `anyOfBy` or `anyOfThese`.
221
222
  *
222
223
  * Our Ajv is configured to strip unexpected properties from objects,
223
224
  * and since Ajv is mutating the input, this means that it cannot
@@ -242,6 +243,18 @@ export const j = {
242
243
  })
243
244
  },
244
245
 
246
+ /**
247
+ * Pick validation schema for an object based on the value of a specific property.
248
+ *
249
+ * ```
250
+ * const schemaMap = {
251
+ * true: successSchema,
252
+ * false: errorSchema
253
+ * }
254
+ *
255
+ * const schema = j.anyOfBy('success', schemaMap)
256
+ * ```
257
+ */
245
258
  anyOfBy<
246
259
  P extends string,
247
260
  D extends Record<PropertyKey, JsonSchemaTerminal<any, any, any>>,
@@ -251,6 +264,24 @@ export const j = {
251
264
  return new JsonSchemaAnyOfByBuilder<IN, OUT, P>(propertyName, schemaDictionary)
252
265
  },
253
266
 
267
+ /**
268
+ * Custom version of `anyOf` which - in contrast to the original function - does not mutate the input.
269
+ * This comes with a performance penalty, so do not use it where performance matters.
270
+ *
271
+ * ```
272
+ * const schema = j.anyOfThese([successSchema, errorSchema])
273
+ * ```
274
+ */
275
+ anyOfThese<
276
+ B extends readonly JsonSchemaAnyBuilder<any, any, boolean>[],
277
+ IN = BuilderInUnion<B>,
278
+ OUT = BuilderOutUnion<B>,
279
+ >(items: [...B]): JsonSchemaAnyBuilder<IN, OUT, false> {
280
+ return new JsonSchemaAnyBuilder<IN, OUT, false>({
281
+ anyOfThese: items.map(b => b.build()),
282
+ })
283
+ },
284
+
254
285
  and() {
255
286
  return {
256
287
  silentBob: () => {
@@ -438,26 +469,45 @@ export class JsonSchemaStringBuilder<
438
469
  *
439
470
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
440
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.
441
477
  */
442
- override optional(
443
- optionalValues?: string[],
444
- ): 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> {
445
485
  if (!optionalValues) {
446
- return super.optional() as unknown as JsonSchemaStringBuilder<
447
- IN | undefined,
448
- OUT | undefined,
449
- true
450
- >
486
+ return super.optional() as any
451
487
  }
452
488
 
453
- 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()
454
493
  const alternativesSchema = j.enum(optionalValues)
455
494
  Object.assign(newBuilder.getSchema(), {
456
495
  anyOf: [this.build(), alternativesSchema.build()],
457
496
  optionalValues,
458
497
  })
459
498
 
460
- 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
461
511
  }
462
512
 
463
513
  regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
@@ -686,27 +736,45 @@ export class JsonSchemaNumberBuilder<
686
736
  *
687
737
  * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
688
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.
689
744
  */
690
-
691
- override optional(
692
- optionalValues?: number[],
693
- ): 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> {
694
752
  if (!optionalValues) {
695
- return super.optional() as unknown as JsonSchemaNumberBuilder<
696
- IN | undefined,
697
- OUT | undefined,
698
- true
699
- >
753
+ return super.optional() as any
700
754
  }
701
755
 
702
- 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()
703
760
  const alternativesSchema = j.enum(optionalValues)
704
761
  Object.assign(newBuilder.getSchema(), {
705
762
  anyOf: [this.build(), alternativesSchema.build()],
706
763
  optionalValues,
707
764
  })
708
765
 
709
- 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
710
778
  }
711
779
 
712
780
  integer(): this {
@@ -1267,6 +1335,34 @@ export class JsonSchemaAnyOfByBuilder<
1267
1335
  }
1268
1336
  }
1269
1337
 
1338
+ export class JsonSchemaAnyOfTheseBuilder<
1339
+ IN,
1340
+ OUT,
1341
+ // eslint-disable-next-line @typescript-eslint/naming-convention
1342
+ _P extends string = string,
1343
+ > extends JsonSchemaAnyBuilder<AnyOfByInput<IN, _P> | IN, OUT, false> {
1344
+ declare in: IN
1345
+
1346
+ constructor(
1347
+ propertyName: string,
1348
+ schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any, any>>,
1349
+ ) {
1350
+ const builtSchemaDictionary: Record<string, JsonSchema> = {}
1351
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
1352
+ builtSchemaDictionary[key] = schema.build()
1353
+ }
1354
+
1355
+ super({
1356
+ type: 'object',
1357
+ hasIsOfTypeCheck: true,
1358
+ anyOfBy: {
1359
+ propertyName,
1360
+ schemaDictionary: builtSchemaDictionary,
1361
+ },
1362
+ })
1363
+ }
1364
+ }
1365
+
1270
1366
  type EnumBaseType = 'string' | 'number' | 'other'
1271
1367
 
1272
1368
  export interface JsonSchema<IN = unknown, OUT = IN> {
@@ -1342,7 +1438,7 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1342
1438
  instanceof?: string | string[]
1343
1439
  transform?: { trim?: true; toLowerCase?: true; toUpperCase?: true; truncate?: number }
1344
1440
  errorMessages?: StringMap<string>
1345
- optionalValues?: (string | number | boolean)[]
1441
+ optionalValues?: (string | number | boolean | null)[]
1346
1442
  keySchema?: JsonSchema
1347
1443
  minProperties2?: number
1348
1444
  exclusiveProperties?: (readonly string[])[]
@@ -1350,15 +1446,16 @@ export interface JsonSchema<IN = unknown, OUT = IN> {
1350
1446
  propertyName: string
1351
1447
  schemaDictionary: Record<string, JsonSchema>
1352
1448
  }
1449
+ anyOfThese?: JsonSchema[]
1353
1450
  }
1354
1451
 
1355
1452
  function object(props: AnyObject): never
1356
1453
  function object<IN extends AnyObject>(props: {
1357
- [K in keyof Required<IN>]-?: JsonSchemaAnyBuilder<any, IN[K], any>
1454
+ [K in keyof Required<IN>]-?: JsonSchemaTerminal<any, IN[K], any>
1358
1455
  }): JsonSchemaObjectBuilder<IN, IN, false>
1359
1456
 
1360
1457
  function object<IN extends AnyObject>(props: {
1361
- [key in keyof IN]: JsonSchemaAnyBuilder<any, IN[key], any>
1458
+ [key in keyof IN]: JsonSchemaTerminal<any, IN[key], any>
1362
1459
  }): JsonSchemaObjectBuilder<IN, IN, false> {
1363
1460
  return new JsonSchemaObjectBuilder<IN, IN, false>(props)
1364
1461
  }