@naturalcycles/nodejs-lib 15.92.0 → 15.92.1

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.
@@ -44,6 +44,18 @@ export declare class AjvSchema<OUT> {
44
44
  private getAJVValidateFunction;
45
45
  private static requireValidJsonSchema;
46
46
  private applyImprovementsOnErrorMessages;
47
+ /**
48
+ * Filters out noisy errors produced by nullable anyOf patterns.
49
+ * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
50
+ * AJV produces "must be null" and "must match a schema in anyOf" errors
51
+ * that are confusing. This method splices them out, keeping only the real errors.
52
+ */
53
+ private filterNullableAnyOfErrors;
54
+ /**
55
+ * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
56
+ * and returns the parent schema containing the last keyword.
57
+ */
58
+ private resolveSchemaPath;
47
59
  private getErrorMessageForInstancePath;
48
60
  private traverseSchemaPath;
49
61
  private getChildSchema;
@@ -172,6 +172,7 @@ export class AjvSchema {
172
172
  applyImprovementsOnErrorMessages(errors) {
173
173
  if (!errors)
174
174
  return;
175
+ this.filterNullableAnyOfErrors(errors);
175
176
  const { errorMessages } = this.schema;
176
177
  for (const error of errors) {
177
178
  const errorMessage = this.getErrorMessageForInstancePath(this.schema, error.instancePath, error.keyword);
@@ -181,9 +182,65 @@ export class AjvSchema {
181
182
  else if (errorMessages?.[error.keyword]) {
182
183
  error.message = errorMessages[error.keyword];
183
184
  }
185
+ else {
186
+ const unwrapped = unwrapNullableAnyOf(this.schema);
187
+ if (unwrapped?.errorMessages?.[error.keyword]) {
188
+ error.message = unwrapped.errorMessages[error.keyword];
189
+ }
190
+ }
184
191
  error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.');
185
192
  }
186
193
  }
194
+ /**
195
+ * Filters out noisy errors produced by nullable anyOf patterns.
196
+ * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
197
+ * AJV produces "must be null" and "must match a schema in anyOf" errors
198
+ * that are confusing. This method splices them out, keeping only the real errors.
199
+ */
200
+ filterNullableAnyOfErrors(errors) {
201
+ // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches)
202
+ const exactPaths = [];
203
+ const nullBranchPrefixes = [];
204
+ for (const error of errors) {
205
+ if (error.keyword !== 'anyOf')
206
+ continue;
207
+ const parentSchema = this.resolveSchemaPath(error.schemaPath);
208
+ if (!parentSchema)
209
+ continue;
210
+ const nullIndex = unwrapNullableAnyOfIndex(parentSchema);
211
+ if (nullIndex === -1)
212
+ continue;
213
+ exactPaths.push(error.schemaPath); // e.g. "#/anyOf"
214
+ const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length);
215
+ nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`); // e.g. "#/anyOf/1/"
216
+ }
217
+ if (!exactPaths.length)
218
+ return;
219
+ for (let i = errors.length - 1; i >= 0; i--) {
220
+ const sp = errors[i].schemaPath;
221
+ if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) {
222
+ errors.splice(i, 1);
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
228
+ * and returns the parent schema containing the last keyword.
229
+ */
230
+ resolveSchemaPath(schemaPath) {
231
+ // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf"
232
+ // We want the schema that contains the final keyword (e.g. "anyOf")
233
+ const segments = schemaPath.replace(/^#\//, '').split('/');
234
+ // Remove the last segment (the keyword itself, e.g. "anyOf")
235
+ segments.pop();
236
+ let current = this.schema;
237
+ for (const segment of segments) {
238
+ if (!current || typeof current !== 'object')
239
+ return undefined;
240
+ current = current[segment];
241
+ }
242
+ return current;
243
+ }
187
244
  getErrorMessageForInstancePath(schema, instancePath, keyword) {
188
245
  if (!schema || !instancePath)
189
246
  return undefined;
@@ -200,6 +257,11 @@ export class AjvSchema {
200
257
  if (nextSchema.errorMessages?.[keyword]) {
201
258
  return nextSchema.errorMessages[keyword];
202
259
  }
260
+ // Check through nullable wrapper
261
+ const unwrapped = unwrapNullableAnyOf(nextSchema);
262
+ if (unwrapped?.errorMessages?.[keyword]) {
263
+ return unwrapped.errorMessages[keyword];
264
+ }
203
265
  if (remainingSegments.length) {
204
266
  return this.traverseSchemaPath(nextSchema, remainingSegments, keyword);
205
267
  }
@@ -208,10 +270,12 @@ export class AjvSchema {
208
270
  getChildSchema(schema, segment) {
209
271
  if (!segment)
210
272
  return undefined;
211
- if (/^\d+$/.test(segment) && schema.items) {
212
- return this.getArrayItemSchema(schema, segment);
273
+ // Unwrap nullable anyOf to find properties/items through nullable wrappers
274
+ const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema;
275
+ if (/^\d+$/.test(segment) && effectiveSchema.items) {
276
+ return this.getArrayItemSchema(effectiveSchema, segment);
213
277
  }
214
- return this.getObjectPropertySchema(schema, segment);
278
+ return this.getObjectPropertySchema(effectiveSchema, segment);
215
279
  }
216
280
  getArrayItemSchema(schema, indexSegment) {
217
281
  if (!schema.items)
@@ -225,6 +289,18 @@ export class AjvSchema {
225
289
  return schema.properties?.[segment];
226
290
  }
227
291
  }
292
+ function unwrapNullableAnyOf(schema) {
293
+ const nullIndex = unwrapNullableAnyOfIndex(schema);
294
+ if (nullIndex === -1)
295
+ return undefined;
296
+ return schema.anyOf[1 - nullIndex];
297
+ }
298
+ function unwrapNullableAnyOfIndex(schema) {
299
+ if (schema.anyOf?.length !== 2)
300
+ return -1;
301
+ const nullIndex = schema.anyOf.findIndex(s => s.type === 'null');
302
+ return nullIndex;
303
+ }
228
304
  const separator = '\n';
229
305
  export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA');
230
306
  // ===== JsonSchemaBuilders ===== //
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.92.0",
4
+ "version": "15.92.1",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@types/js-yaml": "^4",
@@ -266,6 +266,8 @@ export class AjvSchema<OUT> {
266
266
  ): void {
267
267
  if (!errors) return
268
268
 
269
+ this.filterNullableAnyOfErrors(errors)
270
+
269
271
  const { errorMessages } = this.schema
270
272
 
271
273
  for (const error of errors) {
@@ -279,12 +281,73 @@ export class AjvSchema<OUT> {
279
281
  error.message = errorMessage
280
282
  } else if (errorMessages?.[error.keyword]) {
281
283
  error.message = errorMessages[error.keyword]
284
+ } else {
285
+ const unwrapped = unwrapNullableAnyOf(this.schema)
286
+ if (unwrapped?.errorMessages?.[error.keyword]) {
287
+ error.message = unwrapped.errorMessages[error.keyword]
288
+ }
282
289
  }
283
290
 
284
291
  error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
285
292
  }
286
293
  }
287
294
 
295
+ /**
296
+ * Filters out noisy errors produced by nullable anyOf patterns.
297
+ * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
298
+ * AJV produces "must be null" and "must match a schema in anyOf" errors
299
+ * that are confusing. This method splices them out, keeping only the real errors.
300
+ */
301
+ private filterNullableAnyOfErrors(
302
+ errors: ErrorObject<string, Record<string, any>, unknown>[],
303
+ ): void {
304
+ // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches)
305
+ const exactPaths: string[] = []
306
+ const nullBranchPrefixes: string[] = []
307
+
308
+ for (const error of errors) {
309
+ if (error.keyword !== 'anyOf') continue
310
+
311
+ const parentSchema = this.resolveSchemaPath(error.schemaPath)
312
+ if (!parentSchema) continue
313
+
314
+ const nullIndex = unwrapNullableAnyOfIndex(parentSchema)
315
+ if (nullIndex === -1) continue
316
+
317
+ exactPaths.push(error.schemaPath) // e.g. "#/anyOf"
318
+ const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length)
319
+ nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`) // e.g. "#/anyOf/1/"
320
+ }
321
+
322
+ if (!exactPaths.length) return
323
+
324
+ for (let i = errors.length - 1; i >= 0; i--) {
325
+ const sp = errors[i]!.schemaPath
326
+ if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) {
327
+ errors.splice(i, 1)
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
334
+ * and returns the parent schema containing the last keyword.
335
+ */
336
+ private resolveSchemaPath(schemaPath: string): JsonSchema | undefined {
337
+ // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf"
338
+ // We want the schema that contains the final keyword (e.g. "anyOf")
339
+ const segments = schemaPath.replace(/^#\//, '').split('/')
340
+ // Remove the last segment (the keyword itself, e.g. "anyOf")
341
+ segments.pop()
342
+
343
+ let current: any = this.schema
344
+ for (const segment of segments) {
345
+ if (!current || typeof current !== 'object') return undefined
346
+ current = current[segment]
347
+ }
348
+ return current as JsonSchema | undefined
349
+ }
350
+
288
351
  private getErrorMessageForInstancePath(
289
352
  schema: JsonSchema<OUT> | undefined,
290
353
  instancePath: string,
@@ -312,6 +375,12 @@ export class AjvSchema<OUT> {
312
375
  return nextSchema.errorMessages[keyword]
313
376
  }
314
377
 
378
+ // Check through nullable wrapper
379
+ const unwrapped = unwrapNullableAnyOf(nextSchema)
380
+ if (unwrapped?.errorMessages?.[keyword]) {
381
+ return unwrapped.errorMessages[keyword]
382
+ }
383
+
315
384
  if (remainingSegments.length) {
316
385
  return this.traverseSchemaPath(nextSchema, remainingSegments, keyword)
317
386
  }
@@ -321,11 +390,15 @@ export class AjvSchema<OUT> {
321
390
 
322
391
  private getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined {
323
392
  if (!segment) return undefined
324
- if (/^\d+$/.test(segment) && schema.items) {
325
- return this.getArrayItemSchema(schema, segment)
393
+
394
+ // Unwrap nullable anyOf to find properties/items through nullable wrappers
395
+ const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema
396
+
397
+ if (/^\d+$/.test(segment) && effectiveSchema.items) {
398
+ return this.getArrayItemSchema(effectiveSchema, segment)
326
399
  }
327
400
 
328
- return this.getObjectPropertySchema(schema, segment)
401
+ return this.getObjectPropertySchema(effectiveSchema, segment)
329
402
  }
330
403
 
331
404
  private getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined {
@@ -343,6 +416,18 @@ export class AjvSchema<OUT> {
343
416
  }
344
417
  }
345
418
 
419
+ function unwrapNullableAnyOf(schema: JsonSchema): JsonSchema | undefined {
420
+ const nullIndex = unwrapNullableAnyOfIndex(schema)
421
+ if (nullIndex === -1) return undefined
422
+ return schema.anyOf![1 - nullIndex]!
423
+ }
424
+
425
+ function unwrapNullableAnyOfIndex(schema: JsonSchema): number {
426
+ if (schema.anyOf?.length !== 2) return -1
427
+ const nullIndex = schema.anyOf.findIndex(s => s.type === 'null')
428
+ return nullIndex
429
+ }
430
+
346
431
  const separator = '\n'
347
432
 
348
433
  export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')