@naturalcycles/nodejs-lib 15.140.0 → 15.141.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.
@@ -2,7 +2,7 @@ import { _isBetween, _lazyValue, _round } from '@naturalcycles/js-lib';
2
2
  import { _assert } from '@naturalcycles/js-lib/error';
3
3
  import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object';
4
4
  import { _substringAfterLast } from '@naturalcycles/js-lib/string';
5
- import { Ajv2020 } from 'ajv/dist/2020.js';
5
+ import { _, Ajv2020, str } from 'ajv/dist/2020.js';
6
6
  import { validTLDs } from '../tlds.js';
7
7
  import { jFormats } from './jsonSchemaBuilder.util.js';
8
8
  // oxlint-disable unicorn/prefer-code-point
@@ -265,130 +265,70 @@ export function createAjv(opt) {
265
265
  return true;
266
266
  },
267
267
  });
268
+ // The date/time keywords are `code` keywords, which Ajv inlines into the compiled validator.
269
+ // A `validate` keyword is a function call with a context object, whose instancePath Ajv builds
270
+ // on every call (escaping the key of every map entry), even for valid data.
268
271
  ajv.addKeyword({
269
272
  keyword: 'IsoDate',
270
273
  type: 'string',
271
- modifying: false,
272
- errors: true,
273
274
  schemaType: 'object',
274
- validate: function validate(opt, data, _schema, ctx) {
275
- const hasOptions = Object.keys(opt).length > 0;
276
- const isValid = isIsoDateValid(data);
277
- if (!isValid) {
278
- ;
279
- validate.errors = [
280
- {
281
- instancePath: ctx?.instancePath ?? '',
282
- message: `is an invalid IsoDate`,
283
- },
284
- ];
285
- return false;
286
- }
287
- if (!hasOptions)
288
- return true;
289
- const { before, sameOrBefore, after, sameOrAfter } = opt;
290
- const errors = [];
291
- if (before) {
292
- const isParamValid = isIsoDateValid(before);
293
- const isRuleValid = isParamValid && before > data;
294
- if (!isRuleValid) {
295
- errors.push({
296
- instancePath: ctx?.instancePath ?? '',
297
- message: `is not before ${before}`,
298
- });
299
- }
300
- }
301
- if (sameOrBefore) {
302
- const isParamValid = isIsoDateValid(sameOrBefore);
303
- const isRuleValid = isParamValid && sameOrBefore >= data;
304
- if (!isRuleValid) {
305
- errors.push({
306
- instancePath: ctx?.instancePath ?? '',
307
- message: `is not the same or before ${sameOrBefore}`,
308
- });
309
- }
310
- }
311
- if (after) {
312
- const isParamValid = isIsoDateValid(after);
313
- const isRuleValid = isParamValid && after < data;
314
- if (!isRuleValid) {
315
- errors.push({
316
- instancePath: ctx?.instancePath ?? '',
317
- message: `is not after ${after}`,
318
- });
319
- }
320
- }
321
- if (sameOrAfter) {
322
- const isParamValid = isIsoDateValid(sameOrAfter);
323
- const isRuleValid = isParamValid && sameOrAfter <= data;
324
- if (!isRuleValid) {
325
- errors.push({
326
- instancePath: ctx?.instancePath ?? '',
327
- message: `is not the same or after ${sameOrAfter}`,
328
- });
275
+ error: {
276
+ message: ({ params }) => params['rule']
277
+ ? str `is not ${isoDateRuleLabels[params['rule']]} ${params['limit']}`
278
+ : str `is an invalid IsoDate`,
279
+ params: ({ params }) => (params['rule'] ? _ `{${params['rule']}: ${params['limit']}}` : _ `{}`),
280
+ },
281
+ code(cxt) {
282
+ const { gen, data } = cxt;
283
+ const opt = cxt.schema;
284
+ const isValid = gen.scopeValue('func', { ref: isIsoDateValid });
285
+ gen.if(_ `!${isValid}(${data})`, () => cxt.error(), () => {
286
+ for (const rule of isoDateRules) {
287
+ const limit = opt[rule];
288
+ if (!limit)
289
+ continue;
290
+ const params = { rule, limit };
291
+ if (!isIsoDateValid(limit)) {
292
+ // An invalid limit rejects every date
293
+ cxt.error(false, params);
294
+ continue;
295
+ }
296
+ gen.if(isoDateRuleFails(rule, data, limit), () => cxt.error(false, params));
329
297
  }
330
- }
331
- if (errors.length === 0)
332
- return true;
333
- validate.errors = errors;
334
- return false;
298
+ });
335
299
  },
336
300
  });
337
301
  ajv.addKeyword({
338
302
  keyword: 'IsoDateTime',
339
303
  type: 'string',
340
- modifying: false,
341
- errors: true,
342
304
  schemaType: 'boolean',
343
- validate: function validate(_opt, data, _schema, ctx) {
344
- const isValid = isIsoDateTimeValid(data);
345
- if (isValid)
346
- return true;
347
- validate.errors = [
348
- {
349
- instancePath: ctx?.instancePath ?? '',
350
- message: `is an invalid IsoDateTime`,
351
- },
352
- ];
353
- return false;
305
+ error: { message: 'is an invalid IsoDateTime' },
306
+ code(cxt) {
307
+ const isValid = cxt.gen.scopeValue('func', { ref: isIsoDateTimeValid });
308
+ cxt.fail(_ `!${isValid}(${cxt.data})`);
354
309
  },
355
310
  });
356
311
  ajv.addKeyword({
357
312
  keyword: 'IsoMonth',
358
313
  type: 'string',
359
- modifying: false,
360
- errors: true,
361
314
  schemaType: 'object',
362
- validate: function validate(_opt, data, _schema, ctx) {
363
- const isValid = isIsoMonthValid(data);
364
- if (isValid)
365
- return true;
366
- validate.errors = [
367
- {
368
- instancePath: ctx?.instancePath ?? '',
369
- message: `is an invalid IsoMonth`,
370
- },
371
- ];
372
- return false;
315
+ error: { message: 'is an invalid IsoMonth' },
316
+ code(cxt) {
317
+ const isValid = cxt.gen.scopeValue('func', { ref: isIsoMonthValid });
318
+ cxt.fail(_ `!${isValid}(${cxt.data})`);
373
319
  },
374
320
  });
375
321
  ajv.addKeyword({
376
322
  keyword: 'UtcOffset',
377
323
  type: 'string',
378
- modifying: false,
379
- errors: true,
380
324
  schemaType: 'object',
381
- validate: function validate(opt, data, _schema, ctx) {
382
- const isValid = data === 'Z' ? opt.allowZ : data !== '' && isIsoTimezoneValid(data);
383
- if (isValid)
384
- return true;
385
- validate.errors = [
386
- {
387
- instancePath: ctx?.instancePath ?? '',
388
- message: `is an invalid UtcOffset`,
389
- },
390
- ];
391
- return false;
325
+ error: { message: 'is an invalid UtcOffset' },
326
+ code(cxt) {
327
+ const opt = cxt.schema;
328
+ const isValid = cxt.gen.scopeValue('func', {
329
+ ref: opt.allowZ ? isUtcOffsetOrZValid : isUtcOffsetValid,
330
+ });
331
+ cxt.fail(_ `!${isValid}(${cxt.data})`);
392
332
  },
393
333
  });
394
334
  // Annotation only (the values are validated by `enum`), registered so strict mode doesn't reject it
@@ -425,35 +365,62 @@ export function createAjv(opt) {
425
365
  return true;
426
366
  },
427
367
  });
368
+ // Strips the keys that don't match the key schema; the values are validated by `additionalProperties`.
369
+ // The key schema is inlined per key with error reporting off, and the error count is reset after
370
+ // a mismatch, so a stripped key never surfaces as an error. Keys listed in `properties` are skipped.
428
371
  ajv.addKeyword({
429
372
  keyword: 'keySchema',
430
373
  type: 'object',
431
- modifying: true,
432
- errors: false,
433
374
  schemaType: 'object',
434
- compile(innerSchema, _parentSchema, _it) {
435
- const isValidKeyFn = ajv.compile(innerSchema);
436
- function validate(data, _ctx) {
437
- if (typeof data !== 'object' || data === null)
438
- return true;
439
- for (const key of Object.keys(data)) {
440
- if (!isValidKeyFn(key)) {
441
- delete data[key];
442
- }
375
+ trackErrors: true,
376
+ code(cxt) {
377
+ const { gen, data, parentSchema } = cxt;
378
+ const propertyNames = Object.keys(parentSchema['properties'] || {});
379
+ const valid = gen.name('valid');
380
+ gen.forIn('key', data, key => {
381
+ const checkKey = () => {
382
+ cxt.subschema({
383
+ keyword: 'keySchema',
384
+ data: key,
385
+ dataTypes: ['string'],
386
+ compositeRule: true,
387
+ createErrors: false,
388
+ allErrors: false,
389
+ }, valid);
390
+ gen.if(_ `!${valid}`, () => {
391
+ cxt.reset();
392
+ gen.code(_ `delete ${data}[${key}]`);
393
+ });
394
+ };
395
+ if (!propertyNames.length)
396
+ return checkKey();
397
+ let isNotProperty = _ `${key} !== ${propertyNames[0]}`;
398
+ for (const name of propertyNames.slice(1)) {
399
+ isNotProperty = _ `${isNotProperty} && ${key} !== ${name}`;
443
400
  }
444
- return true;
445
- }
446
- return validate;
401
+ gen.if(isNotProperty, checkKey);
402
+ });
447
403
  },
448
404
  });
449
- // Validates that the value is undefined. Used in record/stringMap with optional value schemas
450
- // to allow undefined values in patternProperties via anyOf.
405
+ // Validates the inner schema only when the value is defined. Used for the optional values of
406
+ // `stringMap`/`record`, whose `additionalProperties` loop visits the keys with undefined values too.
407
+ ajv.addKeyword({
408
+ keyword: 'ifDefined',
409
+ schemaType: ['object', 'boolean'],
410
+ code(cxt) {
411
+ const { gen, data } = cxt;
412
+ gen.if(_ `${data} !== undefined`, () => {
413
+ cxt.subschema({ keyword: 'ifDefined' }, gen.name('valid'));
414
+ });
415
+ },
416
+ });
417
+ // Validates that the value is undefined. Superseded by `ifDefined`, kept for raw JSON schemas.
451
418
  ajv.addKeyword({
452
419
  keyword: 'isUndefined',
453
- modifying: false,
454
- errors: false,
455
420
  schemaType: 'boolean',
456
- validate: (_schema, data) => data === undefined,
421
+ code(cxt) {
422
+ cxt.fail(_ `${cxt.data} !== undefined`);
423
+ },
457
424
  });
458
425
  // This is added because Ajv validates the `min/maxProperties` before validating the properties.
459
426
  // So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
@@ -660,6 +627,25 @@ export function createAjv(opt) {
660
627
  });
661
628
  return ajv;
662
629
  }
630
+ const isoDateRules = ['before', 'sameOrBefore', 'after', 'sameOrAfter'];
631
+ const isoDateRuleLabels = {
632
+ before: 'before',
633
+ sameOrBefore: 'the same or before',
634
+ after: 'after',
635
+ sameOrAfter: 'the same or after',
636
+ };
637
+ /**
638
+ * IsoDate strings compare chronologically as plain strings.
639
+ */
640
+ function isoDateRuleFails(rule, data, limit) {
641
+ if (rule === 'before')
642
+ return _ `${data} >= ${limit}`;
643
+ if (rule === 'sameOrBefore')
644
+ return _ `${data} > ${limit}`;
645
+ if (rule === 'after')
646
+ return _ `${data} <= ${limit}`;
647
+ return _ `${data} < ${limit}`;
648
+ }
663
649
  const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
664
650
  const DASH_CODE = '-'.charCodeAt(0);
665
651
  const ZERO_CODE = '0'.charCodeAt(0);
@@ -788,6 +774,12 @@ function isIsoTimezoneValid(s) {
788
774
  return false; // min is -12:00
789
775
  return true;
790
776
  }
777
+ function isUtcOffsetValid(s) {
778
+ return s !== 'Z' && s !== '' && isIsoTimezoneValid(s);
779
+ }
780
+ function isUtcOffsetOrZValid(s) {
781
+ return s === 'Z' || (s !== '' && isIsoTimezoneValid(s));
782
+ }
791
783
  /**
792
784
  * This is a performance optimized correct validation
793
785
  * for ISO month formatted as "YYYY-MM".
@@ -307,6 +307,7 @@ export declare class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoD
307
307
  after(date: string): this;
308
308
  sameOrAfter(date: string): this;
309
309
  between(fromDate: string, toDate: string, incl: Inclusiveness): this;
310
+ private addRules;
310
311
  }
311
312
  export interface JsonSchemaIsoDateOptions {
312
313
  before?: string;
@@ -387,6 +388,10 @@ export declare class JObject<OUT extends AnyObject, Opt extends boolean = false>
387
388
  }
388
389
  interface JObjectOpts {
389
390
  hasIsOfTypeCheck?: false;
391
+ /**
392
+ * Schema for every property not listed in `properties`, e.g the values of a string map.
393
+ */
394
+ additionalProperties?: JsonSchema<any>;
390
395
  patternProperties?: StringMap<JsonSchema<any>>;
391
396
  keySchema?: JsonSchema;
392
397
  }
@@ -574,7 +579,7 @@ export interface JsonSchema<OUT = unknown> {
574
579
  };
575
580
  patternProperties?: StringMap<JsonSchema<any>>;
576
581
  required?: string[];
577
- additionalProperties?: boolean;
582
+ additionalProperties?: boolean | JsonSchema<any>;
578
583
  minProperties?: number;
579
584
  maxProperties?: number;
580
585
  default?: OUT;
@@ -626,6 +631,10 @@ export interface JsonSchema<OUT = unknown> {
626
631
  errorMessages?: StringMap<string>;
627
632
  optionalValues?: (string | number | boolean | null)[];
628
633
  keySchema?: JsonSchema;
634
+ /**
635
+ * Validates the value against the given schema only when it's defined.
636
+ */
637
+ ifDefined?: JsonSchema;
629
638
  isUndefined?: true;
630
639
  minProperties2?: number;
631
640
  exclusiveProperties?: (readonly string[])[];
@@ -38,13 +38,13 @@ export const j = {
38
38
  const isValueOptional = schema.getSchema().optionalField;
39
39
  const builtSchema = schema.build();
40
40
  const finalValueSchema = isValueOptional
41
- ? { anyOf: [{ isUndefined: true }, builtSchema] }
41
+ ? { ifDefined: builtSchema }
42
42
  : builtSchema;
43
+ // `additionalProperties` instead of `patternProperties: { '^.+$': ... }`, which made Ajv
44
+ // regex-test every key twice (once more for `additionalProperties: false`)
43
45
  return new JObject({}, {
44
46
  hasIsOfTypeCheck: false,
45
- patternProperties: {
46
- '^.+$': finalValueSchema,
47
- },
47
+ additionalProperties: finalValueSchema,
48
48
  });
49
49
  },
50
50
  /**
@@ -695,26 +695,25 @@ export class JIsoDate extends JBuilder {
695
695
  });
696
696
  }
697
697
  before(date) {
698
- return this.cloneAndUpdateSchema({ IsoDate: { before: date } });
698
+ return this.addRules({ before: date });
699
699
  }
700
700
  sameOrBefore(date) {
701
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } });
701
+ return this.addRules({ sameOrBefore: date });
702
702
  }
703
703
  after(date) {
704
- return this.cloneAndUpdateSchema({ IsoDate: { after: date } });
704
+ return this.addRules({ after: date });
705
705
  }
706
706
  sameOrAfter(date) {
707
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } });
707
+ return this.addRules({ sameOrAfter: date });
708
708
  }
709
709
  between(fromDate, toDate, incl) {
710
- let schemaPatch = {};
711
710
  if (incl === '[)') {
712
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } };
713
- }
714
- else if (incl === '[]') {
715
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } };
711
+ return this.addRules({ sameOrAfter: fromDate, before: toDate });
716
712
  }
717
- return this.cloneAndUpdateSchema(schemaPatch);
713
+ return this.addRules({ sameOrAfter: fromDate, sameOrBefore: toDate });
714
+ }
715
+ addRules(rules) {
716
+ return this.cloneAndUpdateSchema({ IsoDate: { ...this.schema.IsoDate, ...rules } });
718
717
  }
719
718
  }
720
719
  export class JNumber extends JBuilder {
@@ -832,7 +831,7 @@ export class JObject extends JBuilder {
832
831
  type: 'object',
833
832
  properties: {},
834
833
  required: [],
835
- additionalProperties: false,
834
+ additionalProperties: opt?.additionalProperties ?? false,
836
835
  hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
837
836
  patternProperties: opt?.patternProperties ?? undefined,
838
837
  keySchema: opt?.keySchema ?? undefined,
@@ -844,6 +843,9 @@ export class JObject extends JBuilder {
844
843
  * When set, the validation will not strip away properties that are not specified explicitly in the schema.
845
844
  */
846
845
  allowAdditionalProperties() {
846
+ // A string map already accepts every property, validated by its value schema
847
+ if (typeof this.schema.additionalProperties === 'object')
848
+ return this.clone();
847
849
  return this.cloneAndUpdateSchema({ additionalProperties: true });
848
850
  }
849
851
  extend(props) {
@@ -1024,16 +1026,13 @@ function record(keySchema, valueSchema) {
1024
1026
  // Check if value schema is optional before build() strips the optionalField flag
1025
1027
  const isValueOptional = valueSchema.getSchema().optionalField;
1026
1028
  const valueJsonSchema = valueSchema.build();
1027
- // When value schema is optional, wrap in anyOf to allow undefined values
1028
1029
  const finalValueSchema = isValueOptional
1029
- ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1030
+ ? { ifDefined: valueJsonSchema }
1030
1031
  : valueJsonSchema;
1031
1032
  return new JObject([], {
1032
1033
  hasIsOfTypeCheck: false,
1033
1034
  keySchema: keyJsonSchema,
1034
- patternProperties: {
1035
- ['^.*$']: finalValueSchema,
1036
- },
1035
+ additionalProperties: finalValueSchema,
1037
1036
  });
1038
1037
  }
1039
1038
  function withRegexKeys(keyRegex, schema) {
@@ -53,16 +53,20 @@ export function mergeJsonSchemaObjects(schema1, schema2) {
53
53
  s1.properties[k] = v;
54
54
  });
55
55
  // Merge `patternProperties`
56
- Object.entries(s2.patternProperties || {}).forEach(([k, v]) => {
57
- s1.patternProperties[k] = v;
58
- });
56
+ if (s2.patternProperties) {
57
+ s1.patternProperties = { ...s1.patternProperties, ...s2.patternProperties };
58
+ }
59
59
  s1.propertyNames = s2.propertyNames || s1.propertyNames;
60
+ s1.keySchema = s2.keySchema || s1.keySchema;
60
61
  s1.minProperties = s2.minProperties ?? s1.minProperties;
61
62
  s1.maxProperties = s2.maxProperties ?? s1.maxProperties;
62
63
  // Merge `required`
63
64
  s1.required.push(...s2.required);
64
65
  s1.required = _uniq(s1.required).sort();
65
- // `additionalProperties` remains the same
66
+ // `additionalProperties` remains the same, unless s2 is a string map (schema-valued)
67
+ if (typeof s2.additionalProperties === 'object') {
68
+ s1.additionalProperties = s2.additionalProperties;
69
+ }
66
70
  return _filterNullishValues(s1, { mutate: true });
67
71
  }
68
72
  export function isEveryItemString(arr) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.140.0",
4
+ "version": "15.141.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -14,7 +14,7 @@
14
14
  "yaml": "^2"
15
15
  },
16
16
  "devDependencies": {
17
- "@naturalcycles/dev-lib": "20.71.0",
17
+ "@naturalcycles/dev-lib": "0.0.0",
18
18
  "typescript": "^7"
19
19
  },
20
20
  "exports": {
@@ -3,15 +3,14 @@ import { _assert } from '@naturalcycles/js-lib/error'
3
3
  import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object'
4
4
  import { _substringAfterLast } from '@naturalcycles/js-lib/string'
5
5
  import type { AnyObject } from '@naturalcycles/js-lib/types'
6
- import type { Options, ValidateFunction } from 'ajv/dist/2020.js'
7
- import { Ajv2020 } from 'ajv/dist/2020.js'
6
+ import type { Code, KeywordCxt, Options, ValidateFunction } from 'ajv/dist/2020.js'
7
+ import { _, Ajv2020, str } from 'ajv/dist/2020.js'
8
8
  import { validTLDs } from '../tlds.js'
9
9
  import type {
10
10
  CustomConverterFn,
11
11
  CustomValidatorFn,
12
12
  JSchema,
13
13
  JsonSchemaIsoDateOptions,
14
- JsonSchemaIsoMonthOptions,
15
14
  JsonSchemaStringEmailOptions,
16
15
  JsonSchemaUtcOffsetOptions,
17
16
  } from './jSchema.js'
@@ -310,140 +309,79 @@ export function createAjv(opt?: Options): Ajv2020 {
310
309
  },
311
310
  })
312
311
 
312
+ // The date/time keywords are `code` keywords, which Ajv inlines into the compiled validator.
313
+ // A `validate` keyword is a function call with a context object, whose instancePath Ajv builds
314
+ // on every call (escaping the key of every map entry), even for valid data.
313
315
  ajv.addKeyword({
314
316
  keyword: 'IsoDate',
315
317
  type: 'string',
316
- modifying: false,
317
- errors: true,
318
318
  schemaType: 'object',
319
- validate: function validate(opt: JsonSchemaIsoDateOptions, data: string, _schema, ctx) {
320
- const hasOptions = Object.keys(opt).length > 0
321
-
322
- const isValid = isIsoDateValid(data)
323
- if (!isValid) {
324
- ;(validate as any).errors = [
325
- {
326
- instancePath: ctx?.instancePath ?? '',
327
- message: `is an invalid IsoDate`,
328
- },
329
- ]
330
-
331
- return false
332
- }
333
-
334
- if (!hasOptions) return true
335
-
336
- const { before, sameOrBefore, after, sameOrAfter } = opt
337
- const errors: any[] = []
338
-
339
- if (before) {
340
- const isParamValid = isIsoDateValid(before)
341
- const isRuleValid = isParamValid && before > data
342
-
343
- if (!isRuleValid) {
344
- errors.push({
345
- instancePath: ctx?.instancePath ?? '',
346
- message: `is not before ${before}`,
347
- })
348
- }
349
- }
350
-
351
- if (sameOrBefore) {
352
- const isParamValid = isIsoDateValid(sameOrBefore)
353
- const isRuleValid = isParamValid && sameOrBefore >= data
354
-
355
- if (!isRuleValid) {
356
- errors.push({
357
- instancePath: ctx?.instancePath ?? '',
358
- message: `is not the same or before ${sameOrBefore}`,
359
- })
360
- }
361
- }
362
-
363
- if (after) {
364
- const isParamValid = isIsoDateValid(after)
365
- const isRuleValid = isParamValid && after < data
366
-
367
- if (!isRuleValid) {
368
- errors.push({
369
- instancePath: ctx?.instancePath ?? '',
370
- message: `is not after ${after}`,
371
- })
372
- }
373
- }
374
-
375
- if (sameOrAfter) {
376
- const isParamValid = isIsoDateValid(sameOrAfter)
377
- const isRuleValid = isParamValid && sameOrAfter <= data
378
-
379
- if (!isRuleValid) {
380
- errors.push({
381
- instancePath: ctx?.instancePath ?? '',
382
- message: `is not the same or after ${sameOrAfter}`,
383
- })
384
- }
385
- }
386
-
387
- if (errors.length === 0) return true
388
- ;(validate as any).errors = errors
389
- return false
319
+ error: {
320
+ message: ({ params }) =>
321
+ params['rule']
322
+ ? str`is not ${isoDateRuleLabels[params['rule'] as IsoDateRule]} ${params['limit']}`
323
+ : str`is an invalid IsoDate`,
324
+ params: ({ params }) => (params['rule'] ? _`{${params['rule']}: ${params['limit']}}` : _`{}`),
325
+ },
326
+ code(cxt: KeywordCxt) {
327
+ const { gen, data } = cxt
328
+ const opt: JsonSchemaIsoDateOptions = cxt.schema
329
+ const isValid = gen.scopeValue('func', { ref: isIsoDateValid })
330
+
331
+ gen.if(
332
+ _`!${isValid}(${data})`,
333
+ () => cxt.error(),
334
+ () => {
335
+ for (const rule of isoDateRules) {
336
+ const limit = opt[rule]
337
+ if (!limit) continue
338
+
339
+ const params = { rule, limit }
340
+ if (!isIsoDateValid(limit)) {
341
+ // An invalid limit rejects every date
342
+ cxt.error(false, params)
343
+ continue
344
+ }
345
+ gen.if(isoDateRuleFails(rule, data, limit), () => cxt.error(false, params))
346
+ }
347
+ },
348
+ )
390
349
  },
391
350
  })
392
351
 
393
352
  ajv.addKeyword({
394
353
  keyword: 'IsoDateTime',
395
354
  type: 'string',
396
- modifying: false,
397
- errors: true,
398
355
  schemaType: 'boolean',
399
- validate: function validate(_opt: JsonSchemaIsoMonthOptions, data: string, _schema, ctx) {
400
- const isValid = isIsoDateTimeValid(data)
401
- if (isValid) return true
402
- ;(validate as any).errors = [
403
- {
404
- instancePath: ctx?.instancePath ?? '',
405
- message: `is an invalid IsoDateTime`,
406
- },
407
- ]
408
- return false
356
+ error: { message: 'is an invalid IsoDateTime' },
357
+ code(cxt: KeywordCxt) {
358
+ const isValid = cxt.gen.scopeValue('func', { ref: isIsoDateTimeValid })
359
+ cxt.fail(_`!${isValid}(${cxt.data})`)
409
360
  },
410
361
  })
411
362
 
412
363
  ajv.addKeyword({
413
364
  keyword: 'IsoMonth',
414
365
  type: 'string',
415
- modifying: false,
416
- errors: true,
417
366
  schemaType: 'object',
418
- validate: function validate(_opt: true, data: string, _schema, ctx) {
419
- const isValid = isIsoMonthValid(data)
420
- if (isValid) return true
421
- ;(validate as any).errors = [
422
- {
423
- instancePath: ctx?.instancePath ?? '',
424
- message: `is an invalid IsoMonth`,
425
- },
426
- ]
427
- return false
367
+ error: { message: 'is an invalid IsoMonth' },
368
+ code(cxt: KeywordCxt) {
369
+ const isValid = cxt.gen.scopeValue('func', { ref: isIsoMonthValid })
370
+ cxt.fail(_`!${isValid}(${cxt.data})`)
428
371
  },
429
372
  })
430
373
 
431
374
  ajv.addKeyword({
432
375
  keyword: 'UtcOffset',
433
376
  type: 'string',
434
- modifying: false,
435
- errors: true,
436
377
  schemaType: 'object',
437
- validate: function validate(opt: JsonSchemaUtcOffsetOptions, data: string, _schema, ctx) {
438
- const isValid = data === 'Z' ? opt.allowZ : data !== '' && isIsoTimezoneValid(data)
439
- if (isValid) return true
440
- ;(validate as any).errors = [
441
- {
442
- instancePath: ctx?.instancePath ?? '',
443
- message: `is an invalid UtcOffset`,
444
- },
445
- ]
446
- return false
378
+ error: { message: 'is an invalid UtcOffset' },
379
+ code(cxt: KeywordCxt) {
380
+ const opt: JsonSchemaUtcOffsetOptions = cxt.schema
381
+ const isValid = cxt.gen.scopeValue('func', {
382
+ ref: opt.allowZ ? isUtcOffsetOrZValid : isUtcOffsetValid,
383
+ })
384
+ cxt.fail(_`!${isValid}(${cxt.data})`)
447
385
  },
448
386
  })
449
387
 
@@ -496,39 +434,69 @@ export function createAjv(opt?: Options): Ajv2020 {
496
434
  },
497
435
  })
498
436
 
437
+ // Strips the keys that don't match the key schema; the values are validated by `additionalProperties`.
438
+ // The key schema is inlined per key with error reporting off, and the error count is reset after
439
+ // a mismatch, so a stripped key never surfaces as an error. Keys listed in `properties` are skipped.
499
440
  ajv.addKeyword({
500
441
  keyword: 'keySchema',
501
442
  type: 'object',
502
- modifying: true,
503
- errors: false,
504
443
  schemaType: 'object',
505
- compile(innerSchema, _parentSchema, _it) {
506
- const isValidKeyFn: ValidateFunction = ajv.compile(innerSchema)
444
+ trackErrors: true,
445
+ code(cxt: KeywordCxt) {
446
+ const { gen, data, parentSchema } = cxt
447
+ const propertyNames = Object.keys(parentSchema['properties'] || {})
448
+ const valid = gen.name('valid')
449
+
450
+ gen.forIn('key', data, key => {
451
+ const checkKey = (): void => {
452
+ cxt.subschema(
453
+ {
454
+ keyword: 'keySchema',
455
+ data: key,
456
+ dataTypes: ['string'],
457
+ compositeRule: true,
458
+ createErrors: false,
459
+ allErrors: false,
460
+ },
461
+ valid,
462
+ )
463
+ gen.if(_`!${valid}`, () => {
464
+ cxt.reset()
465
+ gen.code(_`delete ${data}[${key}]`)
466
+ })
467
+ }
507
468
 
508
- function validate(data: AnyObject, _ctx: any): boolean {
509
- if (typeof data !== 'object' || data === null) return true
469
+ if (!propertyNames.length) return checkKey()
510
470
 
511
- for (const key of Object.keys(data)) {
512
- if (!isValidKeyFn(key)) {
513
- delete data[key]
514
- }
471
+ let isNotProperty: Code = _`${key} !== ${propertyNames[0]}`
472
+ for (const name of propertyNames.slice(1)) {
473
+ isNotProperty = _`${isNotProperty} && ${key} !== ${name}`
515
474
  }
475
+ gen.if(isNotProperty, checkKey)
476
+ })
477
+ },
478
+ })
516
479
 
517
- return true
518
- }
519
-
520
- return validate
480
+ // Validates the inner schema only when the value is defined. Used for the optional values of
481
+ // `stringMap`/`record`, whose `additionalProperties` loop visits the keys with undefined values too.
482
+ ajv.addKeyword({
483
+ keyword: 'ifDefined',
484
+ schemaType: ['object', 'boolean'],
485
+ code(cxt: KeywordCxt) {
486
+ const { gen, data } = cxt
487
+ gen.if(_`${data} !== undefined`, () => {
488
+ cxt.subschema({ keyword: 'ifDefined' }, gen.name('valid'))
489
+ })
521
490
  },
522
491
  })
523
492
 
524
- // Validates that the value is undefined. Used in record/stringMap with optional value schemas
525
- // to allow undefined values in patternProperties via anyOf.
493
+ // Validates that the value is undefined. Superseded by `ifDefined`, kept for raw JSON schemas.
526
494
  ajv.addKeyword({
527
495
  keyword: 'isUndefined',
528
- modifying: false,
529
- errors: false,
530
496
  schemaType: 'boolean',
531
- validate: (_schema: boolean, data: unknown) => data === undefined,
497
+ code(cxt: KeywordCxt) {
498
+ cxt.fail(_`${cxt.data} !== undefined`)
499
+ },
532
500
  })
533
501
 
534
502
  // This is added because Ajv validates the `min/maxProperties` before validating the properties.
@@ -777,6 +745,27 @@ export function createAjv(opt?: Options): Ajv2020 {
777
745
  return ajv
778
746
  }
779
747
 
748
+ type IsoDateRule = keyof JsonSchemaIsoDateOptions
749
+
750
+ const isoDateRules: IsoDateRule[] = ['before', 'sameOrBefore', 'after', 'sameOrAfter']
751
+
752
+ const isoDateRuleLabels: Record<IsoDateRule, string> = {
753
+ before: 'before',
754
+ sameOrBefore: 'the same or before',
755
+ after: 'after',
756
+ sameOrAfter: 'the same or after',
757
+ }
758
+
759
+ /**
760
+ * IsoDate strings compare chronologically as plain strings.
761
+ */
762
+ function isoDateRuleFails(rule: IsoDateRule, data: Code, limit: string): Code {
763
+ if (rule === 'before') return _`${data} >= ${limit}`
764
+ if (rule === 'sameOrBefore') return _`${data} > ${limit}`
765
+ if (rule === 'after') return _`${data} <= ${limit}`
766
+ return _`${data} < ${limit}`
767
+ }
768
+
780
769
  const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
781
770
 
782
771
  const DASH_CODE = '-'.charCodeAt(0)
@@ -904,6 +893,14 @@ function isIsoTimezoneValid(s: string): boolean {
904
893
  return true
905
894
  }
906
895
 
896
+ function isUtcOffsetValid(s: string): boolean {
897
+ return s !== 'Z' && s !== '' && isIsoTimezoneValid(s)
898
+ }
899
+
900
+ function isUtcOffsetOrZValid(s: string): boolean {
901
+ return s === 'Z' || (s !== '' && isIsoTimezoneValid(s))
902
+ }
903
+
907
904
  /**
908
905
  * This is a performance optimized correct validation
909
906
  * for ISO month formatted as "YYYY-MM".
@@ -89,16 +89,16 @@ export const j = {
89
89
  const isValueOptional = schema.getSchema().optionalField
90
90
  const builtSchema = schema.build()
91
91
  const finalValueSchema: JsonSchema = isValueOptional
92
- ? { anyOf: [{ isUndefined: true }, builtSchema] }
92
+ ? { ifDefined: builtSchema }
93
93
  : builtSchema
94
94
 
95
+ // `additionalProperties` instead of `patternProperties: { '^.+$': ... }`, which made Ajv
96
+ // regex-test every key twice (once more for `additionalProperties: false`)
95
97
  return new JObject<StringMap<SchemaOut<S>>>(
96
98
  {},
97
99
  {
98
100
  hasIsOfTypeCheck: false,
99
- patternProperties: {
100
- '^.+$': finalValueSchema,
101
- },
101
+ additionalProperties: finalValueSchema,
102
102
  },
103
103
  )
104
104
  },
@@ -917,31 +917,30 @@ export class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoDate, Opt
917
917
  }
918
918
 
919
919
  before(date: string): this {
920
- return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
920
+ return this.addRules({ before: date })
921
921
  }
922
922
 
923
923
  sameOrBefore(date: string): this {
924
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
924
+ return this.addRules({ sameOrBefore: date })
925
925
  }
926
926
 
927
927
  after(date: string): this {
928
- return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
928
+ return this.addRules({ after: date })
929
929
  }
930
930
 
931
931
  sameOrAfter(date: string): this {
932
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
932
+ return this.addRules({ sameOrAfter: date })
933
933
  }
934
934
 
935
935
  between(fromDate: string, toDate: string, incl: Inclusiveness): this {
936
- let schemaPatch: Partial<JsonSchema> = {}
937
-
938
936
  if (incl === '[)') {
939
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
940
- } else if (incl === '[]') {
941
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
937
+ return this.addRules({ sameOrAfter: fromDate, before: toDate })
942
938
  }
939
+ return this.addRules({ sameOrAfter: fromDate, sameOrBefore: toDate })
940
+ }
943
941
 
944
- return this.cloneAndUpdateSchema(schemaPatch)
942
+ private addRules(rules: JsonSchemaIsoDateOptions): this {
943
+ return this.cloneAndUpdateSchema({ IsoDate: { ...this.schema.IsoDate, ...rules } })
945
944
  }
946
945
  }
947
946
 
@@ -1108,7 +1107,7 @@ export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends
1108
1107
  type: 'object',
1109
1108
  properties: {},
1110
1109
  required: [],
1111
- additionalProperties: false,
1110
+ additionalProperties: opt?.additionalProperties ?? false,
1112
1111
  hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1113
1112
  patternProperties: opt?.patternProperties ?? undefined,
1114
1113
  keySchema: opt?.keySchema ?? undefined,
@@ -1122,6 +1121,8 @@ export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends
1122
1121
  */
1123
1122
 
1124
1123
  allowAdditionalProperties(): this {
1124
+ // A string map already accepts every property, validated by its value schema
1125
+ if (typeof this.schema.additionalProperties === 'object') return this.clone()
1125
1126
  return this.cloneAndUpdateSchema({ additionalProperties: true })
1126
1127
  }
1127
1128
 
@@ -1207,6 +1208,10 @@ export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends
1207
1208
 
1208
1209
  interface JObjectOpts {
1209
1210
  hasIsOfTypeCheck?: false
1211
+ /**
1212
+ * Schema for every property not listed in `properties`, e.g the values of a string map.
1213
+ */
1214
+ additionalProperties?: JsonSchema<any>
1210
1215
  patternProperties?: StringMap<JsonSchema<any>>
1211
1216
  keySchema?: JsonSchema
1212
1217
  }
@@ -1477,17 +1482,14 @@ function record<
1477
1482
  const isValueOptional = (valueSchema as JSchema<any, any>).getSchema().optionalField
1478
1483
  const valueJsonSchema = valueSchema.build()
1479
1484
 
1480
- // When value schema is optional, wrap in anyOf to allow undefined values
1481
1485
  const finalValueSchema: JsonSchema = isValueOptional
1482
- ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1486
+ ? { ifDefined: valueJsonSchema }
1483
1487
  : valueJsonSchema
1484
1488
 
1485
1489
  return new JObject([], {
1486
1490
  hasIsOfTypeCheck: false,
1487
1491
  keySchema: keyJsonSchema,
1488
- patternProperties: {
1489
- ['^.*$']: finalValueSchema,
1490
- },
1492
+ additionalProperties: finalValueSchema,
1491
1493
  }) as any
1492
1494
  }
1493
1495
 
@@ -2086,7 +2088,7 @@ export interface JsonSchema<OUT = unknown> {
2086
2088
  }
2087
2089
  patternProperties?: StringMap<JsonSchema<any>>
2088
2090
  required?: string[]
2089
- additionalProperties?: boolean
2091
+ additionalProperties?: boolean | JsonSchema<any>
2090
2092
  minProperties?: number
2091
2093
  maxProperties?: number
2092
2094
 
@@ -2146,6 +2148,10 @@ export interface JsonSchema<OUT = unknown> {
2146
2148
  errorMessages?: StringMap<string>
2147
2149
  optionalValues?: (string | number | boolean | null)[]
2148
2150
  keySchema?: JsonSchema
2151
+ /**
2152
+ * Validates the value against the given schema only when it's defined.
2153
+ */
2154
+ ifDefined?: JsonSchema
2149
2155
  isUndefined?: true
2150
2156
  minProperties2?: number
2151
2157
  exclusiveProperties?: (readonly string[])[]
@@ -62,11 +62,12 @@ export function mergeJsonSchemaObjects<T1 extends AnyObject, T2 extends AnyObjec
62
62
  })
63
63
 
64
64
  // Merge `patternProperties`
65
- Object.entries(s2.patternProperties || {}).forEach(([k, v]) => {
66
- s1.patternProperties[k] = v
67
- })
65
+ if (s2.patternProperties) {
66
+ s1.patternProperties = { ...s1.patternProperties, ...s2.patternProperties }
67
+ }
68
68
 
69
69
  s1.propertyNames = s2.propertyNames || s1.propertyNames
70
+ s1.keySchema = s2.keySchema || s1.keySchema
70
71
  s1.minProperties = s2.minProperties ?? s1.minProperties
71
72
  s1.maxProperties = s2.maxProperties ?? s1.maxProperties
72
73
 
@@ -74,7 +75,10 @@ export function mergeJsonSchemaObjects<T1 extends AnyObject, T2 extends AnyObjec
74
75
  s1.required.push(...s2.required)
75
76
  s1.required = _uniq(s1.required).sort()
76
77
 
77
- // `additionalProperties` remains the same
78
+ // `additionalProperties` remains the same, unless s2 is a string map (schema-valued)
79
+ if (typeof s2.additionalProperties === 'object') {
80
+ s1.additionalProperties = s2.additionalProperties
81
+ }
78
82
 
79
83
  return _filterNullishValues(s1, { mutate: true })
80
84
  }