@naturalcycles/nodejs-lib 15.92.1 → 15.94.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.
@@ -5,7 +5,6 @@ import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycle
5
5
  import {
6
6
  _isObject,
7
7
  _isUndefined,
8
- _lazyValue,
9
8
  _numberEnumValues,
10
9
  _stringEnumValues,
11
10
  getEnumType,
@@ -55,469 +54,27 @@ import {
55
54
  mergeJsonSchemaObjects,
56
55
  } from './jsonSchemaBuilder.util.js'
57
56
 
58
- // ==== AJV =====
59
-
60
- /**
61
- * On creation - compiles ajv validation function.
62
- * Provides convenient methods, error reporting, etc.
63
- */
64
- export class AjvSchema<OUT> {
65
- private constructor(
66
- public schema: JsonSchema<OUT>,
67
- cfg: Partial<AjvSchemaCfg> = {},
68
- ) {
69
- this.cfg = {
70
- lazy: false,
71
- ...cfg,
72
- ajv: cfg.ajv || getAjv(),
73
- // Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json")
74
- inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
75
- }
76
-
77
- if (!cfg.lazy) {
78
- this.getAJVValidateFunction() // compile eagerly
79
- }
80
- }
81
-
82
- /**
83
- * Shortcut for AjvSchema.create(schema, { lazy: true })
84
- */
85
- static createLazy<OUT>(
86
- schema: SchemaHandledByAjv<OUT>,
87
- cfg?: Partial<AjvSchemaCfg>,
88
- ): AjvSchema<OUT> {
89
- return AjvSchema.create(schema, {
90
- lazy: true,
91
- ...cfg,
92
- })
93
- }
94
-
95
- /**
96
- * Conveniently allows to pass either JsonSchema or JsonSchemaBuilder, or existing AjvSchema.
97
- * If it's already an AjvSchema - it'll just return it without any processing.
98
- * If it's a Builder - will call `build` before proceeding.
99
- * Otherwise - will construct AjvSchema instance ready to be used.
100
- *
101
- * Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
102
- * correctly for some reason.
103
- */
104
- static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
105
- if (schema instanceof AjvSchema) return schema
106
-
107
- if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
108
- return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
109
- }
110
-
111
- let jsonSchema: JsonSchema<OUT>
112
-
113
- if (AjvSchema.isJsonSchemaBuilder(schema)) {
114
- // oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
115
- jsonSchema = (schema as JsonSchemaTerminal<OUT, any>).build()
116
- AjvSchema.requireValidJsonSchema(jsonSchema)
117
- } else {
118
- jsonSchema = schema
119
- }
120
-
121
- // This is our own helper which marks a schema as optional
122
- // in case it is going to be used in an object schema,
123
- // where we need to mark the given property as not-required.
124
- // But once all compilation is done, the presence of this field
125
- // really upsets Ajv.
126
- delete jsonSchema.optionalField
127
-
128
- const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
129
- AjvSchema.cacheAjvSchema(schema, ajvSchema)
130
-
131
- return ajvSchema
132
- }
133
-
134
- static isJsonSchemaBuilder<OUT>(schema: unknown): schema is JsonSchemaTerminal<OUT, any> {
135
- return schema instanceof JsonSchemaTerminal
136
- }
137
-
138
- readonly cfg: AjvSchemaCfg
139
-
140
- /**
141
- * It returns the original object just for convenience.
142
- * Reminder: Ajv will MUTATE your object under 2 circumstances:
143
- * 1. `useDefaults` option (enabled by default!), which will set missing/empty values that have `default` set in the schema.
144
- * 2. `coerceTypes` (false by default).
145
- *
146
- * Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
147
- */
148
- validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
149
- const [err, output] = this.getValidationResult(input, opt)
150
- if (err) throw err
151
- return output
152
- }
153
-
154
- isValid(input: unknown, opt?: AjvValidationOptions): boolean {
155
- // todo: we can make it both fast and non-mutating by using Ajv
156
- // with "removeAdditional" and "useDefaults" disabled.
157
- const [err] = this.getValidationResult(input, opt)
158
- return !err
159
- }
160
-
161
- getValidationResult(
162
- input: unknown,
163
- opt: AjvValidationOptions = {},
164
- ): ValidationFunctionResult<OUT, AjvValidationError> {
165
- const fn = this.getAJVValidateFunction()
166
-
167
- const item =
168
- opt.mutateInput !== false || typeof input !== 'object'
169
- ? input // mutate
170
- : _deepCopy(input) // not mutate
171
-
172
- let valid = fn(item) // mutates item, but not input
173
- _typeCast<OUT>(item)
174
-
175
- let output: OUT = item
176
- if (valid && this.schema.postValidation) {
177
- const [err, result] = _try(() => this.schema.postValidation!(output))
178
- if (err) {
179
- valid = false
180
- ;(fn as any).errors = [
181
- {
182
- instancePath: '',
183
- message: err.message,
184
- },
185
- ]
186
- } else {
187
- output = result
188
- }
189
- }
190
-
191
- if (valid) return [null, output]
192
-
193
- const errors = fn.errors!
194
-
195
- const {
196
- inputId = _isObject(input) ? (input as any)['id'] : undefined,
197
- inputName = this.cfg.inputName || 'Object',
198
- } = opt
199
- const dataVar = [inputName, inputId].filter(Boolean).join('.')
200
-
201
- this.applyImprovementsOnErrorMessages(errors)
202
-
203
- let message = this.cfg.ajv.errorsText(errors, {
204
- dataVar,
205
- separator,
206
- })
207
-
208
- // Note: if we mutated the input already, e.g stripped unknown properties,
209
- // the error message Input would contain already mutated object print, such as Input: {}
210
- // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness.
211
- const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 })
212
- message = [message, 'Input: ' + inputStringified].join(separator)
213
-
214
- const err = new AjvValidationError(
215
- message,
216
- _filterNullishValues({
217
- errors,
218
- inputName,
219
- inputId,
220
- }),
221
- )
222
- return [err, output]
223
- }
224
-
225
- getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
226
- return (input, opt) => {
227
- return this.getValidationResult(input, {
228
- mutateInput: opt?.mutateInput,
229
- inputName: opt?.inputName,
230
- inputId: opt?.inputId,
231
- })
232
- }
233
- }
234
-
235
- static isSchemaWithCachedAjvSchema<Base, OUT>(
236
- schema: Base,
237
- ): schema is WithCachedAjvSchema<Base, OUT> {
238
- return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
239
- }
240
-
241
- static cacheAjvSchema<Base extends AnyObject, OUT>(
242
- schema: Base,
243
- ajvSchema: AjvSchema<OUT>,
244
- ): WithCachedAjvSchema<Base, OUT> {
245
- return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
246
- }
247
-
248
- static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
249
- return schema[HIDDEN_AJV_SCHEMA]
250
- }
251
-
252
- private getAJVValidateFunction = _lazyValue(() => this.cfg.ajv.compile(this.schema as any))
253
-
254
- private static requireValidJsonSchema(schema: JsonSchema): void {
255
- // For object schemas we require that it is type checked against an external type, e.g.:
256
- // interface Foo { name: string }
257
- // const schema = j.object({ name: j.string() }).ofType<Foo>()
258
- _assert(
259
- schema.type !== 'object' || schema.hasIsOfTypeCheck,
260
- 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
261
- )
262
- }
263
-
264
- private applyImprovementsOnErrorMessages(
265
- errors: ErrorObject<string, Record<string, any>, unknown>[] | null | undefined,
266
- ): void {
267
- if (!errors) return
268
-
269
- this.filterNullableAnyOfErrors(errors)
270
-
271
- const { errorMessages } = this.schema
272
-
273
- for (const error of errors) {
274
- const errorMessage = this.getErrorMessageForInstancePath(
275
- this.schema,
276
- error.instancePath,
277
- error.keyword,
278
- )
279
-
280
- if (errorMessage) {
281
- error.message = errorMessage
282
- } else if (errorMessages?.[error.keyword]) {
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
- }
289
- }
290
-
291
- error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
292
- }
293
- }
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
-
351
- private getErrorMessageForInstancePath(
352
- schema: JsonSchema<OUT> | undefined,
353
- instancePath: string,
354
- keyword: string,
355
- ): string | undefined {
356
- if (!schema || !instancePath) return undefined
357
-
358
- const segments = instancePath.split('/').filter(Boolean)
359
- return this.traverseSchemaPath(schema, segments, keyword)
360
- }
361
-
362
- private traverseSchemaPath<T>(
363
- schema: JsonSchema<T>,
364
- segments: string[],
365
- keyword: string,
366
- ): string | undefined {
367
- if (!segments.length) return undefined
368
-
369
- const [currentSegment, ...remainingSegments] = segments
370
-
371
- const nextSchema = this.getChildSchema(schema, currentSegment)
372
- if (!nextSchema) return undefined
373
-
374
- if (nextSchema.errorMessages?.[keyword]) {
375
- return nextSchema.errorMessages[keyword]
376
- }
377
-
378
- // Check through nullable wrapper
379
- const unwrapped = unwrapNullableAnyOf(nextSchema)
380
- if (unwrapped?.errorMessages?.[keyword]) {
381
- return unwrapped.errorMessages[keyword]
382
- }
383
-
384
- if (remainingSegments.length) {
385
- return this.traverseSchemaPath(nextSchema, remainingSegments, keyword)
386
- }
387
-
388
- return undefined
389
- }
390
-
391
- private getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined {
392
- if (!segment) return undefined
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)
399
- }
400
-
401
- return this.getObjectPropertySchema(effectiveSchema, segment)
402
- }
403
-
404
- private getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined {
405
- if (!schema.items) return undefined
406
-
407
- if (Array.isArray(schema.items)) {
408
- return schema.items[Number(indexSegment)]
409
- }
410
-
411
- return schema.items
412
- }
413
-
414
- private getObjectPropertySchema(schema: JsonSchema, segment: string): JsonSchema | undefined {
415
- return schema.properties?.[segment as keyof typeof schema.properties]
416
- }
417
- }
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
-
431
- const separator = '\n'
432
-
433
- export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
434
-
435
- export type WithCachedAjvSchema<Base, OUT> = Base & {
436
- [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
437
- }
438
-
439
- export interface AjvValidationOptions {
440
- /**
441
- * Defaults to true,
442
- * because that's how AJV works by default,
443
- * and what gives it performance advantage.
444
- * (Because we have found that deep-clone is surprisingly slow,
445
- * nearly as slow as Joi validation).
446
- *
447
- * If set to true - AJV will mutate the input in case it needs to apply transformations
448
- * (strip unknown properties, convert types, etc).
449
- *
450
- * If false - it will deep-clone (using JSON.stringify+parse) the input to prevent its mutation.
451
- * Will return the cloned/mutated object.
452
- * Please note that JSON.stringify+parse has side-effects,
453
- * e.g it will transform Buffer into a weird object.
454
- */
455
- mutateInput?: boolean
456
- inputName?: string
457
- inputId?: string
458
- /**
459
- * Function that returns "original input".
460
- * What is original input?
461
- * It's an input in its original non-mutated form.
462
- * Why is it needed?
463
- * Because we mutates the Input here. And after its been mutated - we no longer
464
- * can include it "how it was" in an error message. So, for that reason we'll use
465
- * `getOriginalInput()`, if it's provided.
466
- */
467
- getOriginalInput?: () => unknown
468
- }
469
-
470
- export interface AjvSchemaCfg {
471
- /**
472
- * Pass Ajv instance, otherwise Ajv will be created with
473
- * AjvSchema default (not the same as Ajv defaults) parameters
474
- */
475
- ajv: Ajv
476
-
477
- inputName?: string
478
-
479
- /**
480
- * Option of Ajv.
481
- * If set to true - will mutate your input objects!
482
- * Defaults to false.
483
- *
484
- * This option is a "shortcut" to skip creating and passing Ajv instance.
485
- */
486
- // coerceTypes?: boolean
487
-
488
- /**
489
- * If true - schema will be compiled on-demand (lazily).
490
- * Default: false.
491
- */
492
- lazy?: boolean
493
- }
494
-
495
- export type SchemaHandledByAjv<OUT> =
496
- | JsonSchemaTerminal<OUT, any>
497
- | JsonSchema<OUT>
498
- | AjvSchema<OUT>
499
-
500
- // ===== JsonSchemaBuilders ===== //
57
+ // ==== j (factory object) ====
501
58
 
502
59
  export const j = {
503
60
  /**
504
61
  * Matches literally any value - equivalent to TypeScript's `any` type.
505
62
  * Use sparingly, as it bypasses type validation entirely.
506
63
  */
507
- any(): JsonSchemaAnyBuilder<any, false> {
508
- return new JsonSchemaAnyBuilder({})
64
+ any(): JBuilder<any, false> {
65
+ return new JBuilder({})
509
66
  },
510
67
 
511
- string(): JsonSchemaStringBuilder<string, false> {
512
- return new JsonSchemaStringBuilder()
68
+ string(): JString<string, false> {
69
+ return new JString()
513
70
  },
514
71
 
515
- number(): JsonSchemaNumberBuilder<number, false> {
516
- return new JsonSchemaNumberBuilder()
72
+ number(): JNumber<number, false> {
73
+ return new JNumber()
517
74
  },
518
75
 
519
- boolean(): JsonSchemaBooleanBuilder<boolean, false> {
520
- return new JsonSchemaBooleanBuilder()
76
+ boolean(): JBoolean<boolean, false> {
77
+ return new JBoolean()
521
78
  },
522
79
 
523
80
  object: Object.assign(object, {
@@ -527,16 +84,14 @@ export const j = {
527
84
  return j.object<AnyObject>({}).allowAdditionalProperties()
528
85
  },
529
86
 
530
- stringMap<S extends JsonSchemaTerminal<any, any>>(
531
- schema: S,
532
- ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>> {
87
+ stringMap<S extends JSchema<any, any>>(schema: S): JObject<StringMap<SchemaOut<S>>> {
533
88
  const isValueOptional = schema.getSchema().optionalField
534
89
  const builtSchema = schema.build()
535
90
  const finalValueSchema: JsonSchema = isValueOptional
536
91
  ? { anyOf: [{ isUndefined: true }, builtSchema] }
537
92
  : builtSchema
538
93
 
539
- return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>>(
94
+ return new JObject<StringMap<SchemaOut<S>>>(
540
95
  {},
541
96
  {
542
97
  hasIsOfTypeCheck: false,
@@ -584,26 +139,28 @@ export const j = {
584
139
  withRegexKeys,
585
140
  }),
586
141
 
587
- array<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaArrayBuilder<OUT, Opt> {
588
- return new JsonSchemaArrayBuilder(itemSchema)
142
+ array<OUT, Opt>(itemSchema: JBuilder<OUT, Opt>): JArray<OUT, Opt> {
143
+ return new JArray(itemSchema)
589
144
  },
590
145
 
591
- tuple<const S extends JsonSchemaAnyBuilder<any, any>[]>(items: S): JsonSchemaTupleBuilder<S> {
592
- return new JsonSchemaTupleBuilder<S>(items)
146
+ tuple<const S extends JBuilder<any, any>[]>(items: S): JTuple<S> {
147
+ return new JTuple<S>(items)
593
148
  },
594
149
 
595
- set<OUT, Opt>(itemSchema: JsonSchemaAnyBuilder<OUT, Opt>): JsonSchemaSet2Builder<OUT, Opt> {
596
- return new JsonSchemaSet2Builder(itemSchema)
150
+ set<OUT, Opt>(itemSchema: JBuilder<OUT, Opt>): JSet2Builder<OUT, Opt> {
151
+ return new JSet2Builder(itemSchema)
597
152
  },
598
153
 
599
- buffer(): JsonSchemaBufferBuilder {
600
- return new JsonSchemaBufferBuilder()
154
+ buffer(): JBuilder<Buffer, false> {
155
+ return new JBuilder<Buffer, false>({
156
+ Buffer: true,
157
+ })
601
158
  },
602
159
 
603
160
  enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>(
604
161
  input: T,
605
162
  opt?: JsonBuilderRuleOpt,
606
- ): JsonSchemaEnumBuilder<
163
+ ): JEnum<
607
164
  T extends readonly (infer U)[]
608
165
  ? U
609
166
  : T extends StringEnum
@@ -634,7 +191,7 @@ export const j = {
634
191
  }
635
192
 
636
193
  _assert(enumValues, 'Unsupported enum input')
637
- return new JsonSchemaEnumBuilder(enumValues as any, baseType, opt)
194
+ return new JEnum(enumValues as any, baseType, opt)
638
195
  },
639
196
 
640
197
  /**
@@ -648,16 +205,16 @@ export const j = {
648
205
  * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
649
206
  * Use `oneOf` when schemas are mutually exclusive.
650
207
  */
651
- oneOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
208
+ oneOf<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
652
209
  items: [...B],
653
- ): JsonSchemaAnyBuilder<OUT, false> {
210
+ ): JBuilder<OUT, false> {
654
211
  const schemas = items.map(b => b.build())
655
212
  _assert(
656
213
  schemas.every(hasNoObjectSchemas),
657
214
  'Do not use `oneOf` validation with non-primitive types!',
658
215
  )
659
216
 
660
- return new JsonSchemaAnyBuilder<OUT, false>({
217
+ return new JBuilder<OUT, false>({
661
218
  oneOf: schemas,
662
219
  })
663
220
  },
@@ -673,16 +230,16 @@ export const j = {
673
230
  * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format).
674
231
  * Use `oneOf` when schemas are mutually exclusive.
675
232
  */
676
- anyOf<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
233
+ anyOf<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
677
234
  items: [...B],
678
- ): JsonSchemaAnyBuilder<OUT, false> {
235
+ ): JBuilder<OUT, false> {
679
236
  const schemas = items.map(b => b.build())
680
237
  _assert(
681
238
  schemas.every(hasNoObjectSchemas),
682
239
  'Do not use `anyOf` validation with non-primitive types!',
683
240
  )
684
241
 
685
- return new JsonSchemaAnyBuilder<OUT, false>({
242
+ return new JBuilder<OUT, false>({
686
243
  anyOf: schemas,
687
244
  })
688
245
  },
@@ -699,11 +256,23 @@ export const j = {
699
256
  * const schema = j.anyOfBy('success', schemaMap)
700
257
  * ```
701
258
  */
702
- anyOfBy<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>, OUT = AnyOfByOut<D>>(
259
+ anyOfBy<D extends Record<PropertyKey, JSchema<any, any>>, OUT = AnyOfByOut<D>>(
703
260
  propertyName: string,
704
261
  schemaDictionary: D,
705
- ): JsonSchemaAnyOfByBuilder<OUT> {
706
- return new JsonSchemaAnyOfByBuilder<OUT>(propertyName, schemaDictionary)
262
+ ): JBuilder<OUT, false> {
263
+ const builtSchemaDictionary: Record<string, JsonSchema> = {}
264
+ for (const [key, schema] of Object.entries(schemaDictionary)) {
265
+ builtSchemaDictionary[key] = schema.build()
266
+ }
267
+
268
+ return new JBuilder<OUT, false>({
269
+ type: 'object',
270
+ hasIsOfTypeCheck: true,
271
+ anyOfBy: {
272
+ propertyName,
273
+ schemaDictionary: builtSchemaDictionary,
274
+ },
275
+ })
707
276
  },
708
277
 
709
278
  /**
@@ -714,10 +283,10 @@ export const j = {
714
283
  * const schema = j.anyOfThese([successSchema, errorSchema])
715
284
  * ```
716
285
  */
717
- anyOfThese<B extends readonly JsonSchemaAnyBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
286
+ anyOfThese<B extends readonly JBuilder<any, boolean>[], OUT = BuilderOutUnion<B>>(
718
287
  items: [...B],
719
- ): JsonSchemaAnyBuilder<OUT, false> {
720
- return new JsonSchemaAnyBuilder<OUT, false>({
288
+ ): JBuilder<OUT, false> {
289
+ return new JBuilder<OUT, false>({
721
290
  anyOfThese: items.map(b => b.build()),
722
291
  })
723
292
  },
@@ -734,14 +303,32 @@ export const j = {
734
303
  let baseType: EnumBaseType = 'other'
735
304
  if (typeof v === 'string') baseType = 'string'
736
305
  if (typeof v === 'number') baseType = 'number'
737
- return new JsonSchemaEnumBuilder<V>([v], baseType)
306
+ return new JEnum<V>([v], baseType)
307
+ },
308
+
309
+ /**
310
+ * Create a JSchema from a plain JsonSchema object.
311
+ * Useful when the schema is loaded from a JSON file or generated externally.
312
+ *
313
+ * Optionally accepts a custom Ajv instance and/or inputName for error messages.
314
+ */
315
+ fromSchema<OUT>(
316
+ schema: JsonSchema<OUT>,
317
+ cfg?: { ajv?: Ajv; inputName?: string },
318
+ ): JSchema<OUT, false> {
319
+ return new JSchema<OUT, false>(schema, cfg)
738
320
  },
739
321
  }
740
322
 
741
- const TS_2500 = 16725225600 // 2500-01-01
742
- const TS_2500_MILLIS = TS_2500 * 1000
743
- const TS_2000 = 946684800 // 2000-01-01
744
- const TS_2000_MILLIS = TS_2000 * 1000
323
+ // ==== Symbol for caching compiled AjvSchema ====
324
+
325
+ export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA')
326
+
327
+ export type WithCachedAjvSchema<Base, OUT> = Base & {
328
+ [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT>
329
+ }
330
+
331
+ // ==== JSchema (locked base) ====
745
332
 
746
333
  /*
747
334
  Notes for future reference
@@ -752,20 +339,54 @@ const TS_2000_MILLIS = TS_2000 * 1000
752
339
  With `Opt`, we can infer it as `{ foo?: string | undefined }`.
753
340
  */
754
341
 
755
- export class JsonSchemaTerminal<OUT, Opt> {
342
+ export class JSchema<OUT, Opt> {
756
343
  protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined
757
344
  protected schema: JsonSchema
345
+ private _cfg?: { ajv?: Ajv; inputName?: string }
346
+
347
+ constructor(schema: JsonSchema, cfg?: { ajv?: Ajv; inputName?: string }) {
348
+ this.schema = schema
349
+ this._cfg = cfg
350
+ }
351
+
352
+ private _builtSchema?: JsonSchema
353
+ private _compiledFns?: WeakMap<Ajv, any>
354
+
355
+ private _getBuiltSchema(): JsonSchema {
356
+ if (!this._builtSchema) {
357
+ const builtSchema = this.build()
358
+
359
+ if (this instanceof JBuilder) {
360
+ _assert(
361
+ builtSchema.type !== 'object' || builtSchema.hasIsOfTypeCheck,
362
+ 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
363
+ )
364
+ }
365
+
366
+ delete builtSchema.optionalField
367
+ this._builtSchema = builtSchema
368
+ }
758
369
 
759
- constructor(schema: JsonSchema) {
760
- this.schema = schema
370
+ return this._builtSchema
761
371
  }
762
372
 
763
- get ajvSchema(): AjvSchema<any> {
764
- if (!this[HIDDEN_AJV_SCHEMA]) {
765
- this[HIDDEN_AJV_SCHEMA] = AjvSchema.create<OUT>(this)
373
+ private _getCompiled(overrideAjv?: Ajv): { fn: any; builtSchema: JsonSchema } {
374
+ const builtSchema = this._getBuiltSchema()
375
+ const ajv = overrideAjv ?? this._cfg?.ajv ?? getAjv()
376
+
377
+ this._compiledFns ??= new WeakMap()
378
+ let fn = this._compiledFns.get(ajv)
379
+ if (!fn) {
380
+ fn = ajv.compile(builtSchema as any)
381
+ this._compiledFns.set(ajv, fn)
382
+
383
+ // Cache AjvSchema wrapper for HIDDEN_AJV_SCHEMA backward compat (default ajv only)
384
+ if (!overrideAjv) {
385
+ this[HIDDEN_AJV_SCHEMA] = AjvSchema._wrap<any>(builtSchema, fn)
386
+ }
766
387
  }
767
388
 
768
- return this[HIDDEN_AJV_SCHEMA]
389
+ return { fn, builtSchema }
769
390
  }
770
391
 
771
392
  getSchema(): JsonSchema {
@@ -795,6 +416,7 @@ export class JsonSchemaTerminal<OUT, Opt> {
795
416
  clone(): this {
796
417
  const cloned = Object.create(Object.getPrototypeOf(this))
797
418
  cloned.schema = deepCopyPreservingFunctions(this.schema)
419
+ cloned._cfg = this._cfg
798
420
  return cloned
799
421
  }
800
422
 
@@ -805,22 +427,34 @@ export class JsonSchemaTerminal<OUT, Opt> {
805
427
  }
806
428
 
807
429
  validate(input: unknown, opt?: AjvValidationOptions): OUT {
808
- return this.ajvSchema.validate(input, opt)
430
+ const [err, output] = this.getValidationResult(input, opt)
431
+ if (err) throw err
432
+ return output
809
433
  }
810
434
 
811
435
  isValid(input: unknown, opt?: AjvValidationOptions): boolean {
812
- return this.ajvSchema.isValid(input, opt)
436
+ const [err] = this.getValidationResult(input, opt)
437
+ return !err
813
438
  }
814
439
 
815
440
  getValidationResult(
816
441
  input: unknown,
817
442
  opt: AjvValidationOptions = {},
818
443
  ): ValidationFunctionResult<OUT, AjvValidationError> {
819
- return this.ajvSchema.getValidationResult(input, opt)
444
+ const { fn, builtSchema } = this._getCompiled(opt.ajv)
445
+ const inputName =
446
+ this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined)
447
+ return executeValidation<OUT>(fn, builtSchema, input, opt, inputName)
820
448
  }
821
449
 
822
450
  getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
823
- return this.ajvSchema.getValidationFunction()
451
+ return (input, opt) => {
452
+ return this.getValidationResult(input, {
453
+ mutateInput: opt?.mutateInput,
454
+ inputName: opt?.inputName,
455
+ inputId: opt?.inputId,
456
+ })
457
+ }
824
458
  }
825
459
 
826
460
  /**
@@ -832,11 +466,11 @@ export class JsonSchemaTerminal<OUT, Opt> {
832
466
  *
833
467
  * If you throw an error from this function, it will show up as an error in the validation.
834
468
  */
835
- postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JsonSchemaTerminal<OUT2, Opt> {
469
+ postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JSchema<OUT2, Opt> {
836
470
  const clone = this.cloneAndUpdateSchema({
837
471
  postValidation: fn,
838
472
  })
839
- return clone as unknown as JsonSchemaTerminal<OUT2, Opt>
473
+ return clone as unknown as JSchema<OUT2, Opt>
840
474
  }
841
475
 
842
476
  /**
@@ -846,7 +480,9 @@ export class JsonSchemaTerminal<OUT, Opt> {
846
480
  opt!: Opt
847
481
  }
848
482
 
849
- export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt> {
483
+ // ==== JBuilder (chainable base) ====
484
+
485
+ export class JBuilder<OUT, Opt> extends JSchema<OUT, Opt> {
850
486
  protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void {
851
487
  if (_isUndefined(errorMessage)) return
852
488
 
@@ -859,15 +495,6 @@ export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt>
859
495
  *
860
496
  * When the type inferred from the schema differs from the passed-in type,
861
497
  * the schema becomes unusable, by turning its type into `never`.
862
- *
863
- * ```ts
864
- * const schemaGood = j.string().isOfType<string>() // ✅
865
- *
866
- * const schemaBad = j.string().isOfType<number>() // ❌
867
- * schemaBad.build() // TypeError: property "build" does not exist on type "never"
868
- *
869
- * const result = ajvValidateRequest.body(req, schemaBad) // result will have `unknown` type
870
- * ```
871
498
  */
872
499
  isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never {
873
500
  return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any
@@ -901,78 +528,364 @@ export class JsonSchemaAnyBuilder<OUT, Opt> extends JsonSchemaTerminal<OUT, Opt>
901
528
  return this.cloneAndUpdateSchema({ type })
902
529
  }
903
530
 
904
- default(v: any): this {
905
- return this.cloneAndUpdateSchema({ default: v })
531
+ default(v: any): this {
532
+ return this.cloneAndUpdateSchema({ default: v })
533
+ }
534
+
535
+ instanceof(of: string): this {
536
+ return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
537
+ }
538
+
539
+ optional(): JBuilder<OUT | undefined, true> {
540
+ const clone = this.cloneAndUpdateSchema({ optionalField: true })
541
+ return clone as unknown as JBuilder<OUT | undefined, true>
542
+ }
543
+
544
+ nullable(): JBuilder<OUT | null, Opt> {
545
+ return new JBuilder({
546
+ anyOf: [this.build(), { type: 'null' }],
547
+ })
548
+ }
549
+
550
+ /**
551
+ * @deprecated
552
+ * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
553
+ */
554
+ castAs<T>(): JBuilder<T, Opt> {
555
+ return this as unknown as JBuilder<T, Opt>
556
+ }
557
+
558
+ /**
559
+ * Locks the given schema chain and no other modification can be done to it.
560
+ */
561
+ final(): JSchema<OUT, Opt> {
562
+ return new JSchema<OUT, Opt>(this.schema)
563
+ }
564
+
565
+ /**
566
+ *
567
+ * @param validator A validator function that returns an error message or undefined.
568
+ *
569
+ * You may add multiple custom validators and they will be executed in the order you added them.
570
+ */
571
+ custom<OUT2 = OUT>(validator: CustomValidatorFn): JBuilder<OUT2, Opt> {
572
+ const { customValidations = [] } = this.schema
573
+ return this.cloneAndUpdateSchema({
574
+ customValidations: [...customValidations, validator],
575
+ }) as unknown as JBuilder<OUT2, Opt>
576
+ }
577
+
578
+ /**
579
+ *
580
+ * @param converter A converter function that returns a new value.
581
+ *
582
+ * You may add multiple converters and they will be executed in the order you added them,
583
+ * each converter receiving the result from the previous one.
584
+ *
585
+ * This feature only works when the current schema is nested in an object or array schema,
586
+ * due to how mutability works in Ajv.
587
+ */
588
+ convert<OUT2>(converter: CustomConverterFn<OUT2>): JBuilder<OUT2, Opt> {
589
+ const { customConversions = [] } = this.schema
590
+ return this.cloneAndUpdateSchema({
591
+ customConversions: [...customConversions, converter],
592
+ }) as unknown as JBuilder<OUT2, Opt>
593
+ }
594
+ }
595
+
596
+ // ==== Consts
597
+
598
+ const TS_2500 = 16725225600 // 2500-01-01
599
+ const TS_2500_MILLIS = TS_2500 * 1000
600
+ const TS_2000 = 946684800 // 2000-01-01
601
+ const TS_2000_MILLIS = TS_2000 * 1000
602
+
603
+ // ==== Type-specific builders ====
604
+
605
+ export class JString<
606
+ OUT extends string | undefined = string,
607
+ Opt extends boolean = false,
608
+ > extends JBuilder<OUT, Opt> {
609
+ constructor() {
610
+ super({
611
+ type: 'string',
612
+ })
613
+ }
614
+
615
+ /**
616
+ * @param optionalValues List of values that should be considered/converted as `undefined`.
617
+ *
618
+ * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
619
+ * due to how mutability works in Ajv.
620
+ *
621
+ * Make sure this `optional()` call is at the end of your call chain.
622
+ *
623
+ * When `null` is included in optionalValues, the return type becomes `JSchema`
624
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
625
+ */
626
+ override optional<T extends readonly (string | null)[] | undefined = undefined>(
627
+ optionalValues?: T,
628
+ ): T extends readonly (infer U)[]
629
+ ? null extends U
630
+ ? JSchema<OUT | undefined, true>
631
+ : JString<OUT | undefined, true>
632
+ : JString<OUT | undefined, true> {
633
+ if (!optionalValues) {
634
+ return super.optional() as any
635
+ }
636
+
637
+ _typeCast<(string | null)[]>(optionalValues)
638
+
639
+ let newBuilder: JSchema<OUT | undefined, true> = new JString<OUT, Opt>().optional()
640
+ const alternativesSchema = j.enum(optionalValues)
641
+ Object.assign(newBuilder.getSchema(), {
642
+ anyOf: [this.build(), alternativesSchema.build()],
643
+ optionalValues,
644
+ })
645
+
646
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
647
+ // so we must allow `null` values to be parsed by Ajv,
648
+ // but the typing should not reflect that.
649
+ // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
650
+ if (optionalValues.includes(null)) {
651
+ newBuilder = new JSchema({
652
+ anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
653
+ optionalField: true,
654
+ })
655
+ }
656
+
657
+ return newBuilder as any
658
+ }
659
+
660
+ regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
661
+ _assert(
662
+ !pattern.flags,
663
+ `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
664
+ )
665
+ return this.pattern(pattern.source, opt)
666
+ }
667
+
668
+ pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
669
+ const clone = this.cloneAndUpdateSchema({ pattern })
670
+ if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
671
+ if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
672
+ return clone
673
+ }
674
+
675
+ minLength(minLength: number): this {
676
+ return this.cloneAndUpdateSchema({ minLength })
677
+ }
678
+
679
+ maxLength(maxLength: number): this {
680
+ return this.cloneAndUpdateSchema({ maxLength })
681
+ }
682
+
683
+ length(exactLength: number): this
684
+ length(minLength: number, maxLength: number): this
685
+ length(minLengthOrExactLength: number, maxLength?: number): this {
686
+ const maxLengthActual = maxLength ?? minLengthOrExactLength
687
+ return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
688
+ }
689
+
690
+ email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
691
+ const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
692
+ return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
693
+ .trim()
694
+ .toLowerCase()
695
+ }
696
+
697
+ trim(): this {
698
+ return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
699
+ }
700
+
701
+ toLowerCase(): this {
702
+ return this.cloneAndUpdateSchema({
703
+ transform: { ...this.schema.transform, toLowerCase: true },
704
+ })
705
+ }
706
+
707
+ toUpperCase(): this {
708
+ return this.cloneAndUpdateSchema({
709
+ transform: { ...this.schema.transform, toUpperCase: true },
710
+ })
711
+ }
712
+
713
+ truncate(toLength: number): this {
714
+ return this.cloneAndUpdateSchema({
715
+ transform: { ...this.schema.transform, truncate: toLength },
716
+ })
717
+ }
718
+
719
+ branded<B extends string>(): JString<B, Opt> {
720
+ return this as unknown as JString<B, Opt>
721
+ }
722
+
723
+ /**
724
+ * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
725
+ *
726
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
727
+ * because this call effectively starts a new schema chain.
728
+ */
729
+ isoDate(): JIsoDate {
730
+ return new JIsoDate()
731
+ }
732
+
733
+ isoDateTime(): JString<IsoDateTime, Opt> {
734
+ return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
735
+ }
736
+
737
+ isoMonth(): JBuilder<IsoMonth, false> {
738
+ return new JBuilder<IsoMonth, false>({
739
+ type: 'string',
740
+ IsoMonth: {},
741
+ })
742
+ }
743
+
744
+ /**
745
+ * Validates the string format to be JWT.
746
+ * Expects the JWT to be signed!
747
+ */
748
+ jwt(): this {
749
+ return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
750
+ }
751
+
752
+ url(): this {
753
+ return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
754
+ }
755
+
756
+ ipv4(): this {
757
+ return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
758
+ }
759
+
760
+ ipv6(): this {
761
+ return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
762
+ }
763
+
764
+ slug(): this {
765
+ return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
766
+ }
767
+
768
+ semVer(): this {
769
+ return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
770
+ }
771
+
772
+ languageTag(): this {
773
+ return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
774
+ }
775
+
776
+ countryCode(): this {
777
+ return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
778
+ }
779
+
780
+ currency(): this {
781
+ return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
782
+ }
783
+
784
+ /**
785
+ * Validates that the input is a valid IANATimzone value.
786
+ *
787
+ * All previous expectations in the schema chain are dropped - including `.optional()` -
788
+ * because this call effectively starts a new schema chain as an `enum` validation.
789
+ */
790
+ ianaTimezone(): JEnum<IANATimezone, false> {
791
+ // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
792
+ return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
793
+ }
794
+
795
+ base64Url(): this {
796
+ return this.regex(BASE64URL_REGEX, {
797
+ msg: 'contains characters not allowed in Base64 URL characterset',
798
+ })
906
799
  }
907
800
 
908
- instanceof(of: string): this {
909
- return this.cloneAndUpdateSchema({ type: 'object', instanceof: of })
801
+ uuid(): this {
802
+ return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
910
803
  }
804
+ }
911
805
 
912
- optional(): JsonSchemaAnyBuilder<OUT | undefined, true> {
913
- const clone = this.cloneAndUpdateSchema({ optionalField: true })
914
- return clone as unknown as JsonSchemaAnyBuilder<OUT | undefined, true>
915
- }
806
+ export interface JsonSchemaStringEmailOptions {
807
+ checkTLD: boolean
808
+ }
916
809
 
917
- nullable(): JsonSchemaAnyBuilder<OUT | null, Opt> {
918
- return new JsonSchemaAnyBuilder({
919
- anyOf: [this.build(), { type: 'null' }],
810
+ export class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoDate, Opt> {
811
+ constructor() {
812
+ super({
813
+ type: 'string',
814
+ IsoDate: {},
920
815
  })
921
816
  }
922
817
 
923
818
  /**
924
- * @deprecated
925
- * The usage of this function is discouraged as it defeats the purpose of having type-safe validation.
819
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
820
+ *
821
+ * This `null` feature only works when the current schema is nested in an object or array schema,
822
+ * due to how mutability works in Ajv.
823
+ *
824
+ * When `null` is passed, the return type becomes `JSchema`
825
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
926
826
  */
927
- castAs<T>(): JsonSchemaAnyBuilder<T, Opt> {
928
- return this as unknown as JsonSchemaAnyBuilder<T, Opt>
827
+ override optional<N extends null | undefined = undefined>(
828
+ nullValue?: N,
829
+ ): N extends null ? JSchema<IsoDate | undefined, true> : JBuilder<IsoDate | undefined, true> {
830
+ if (nullValue === undefined) {
831
+ return super.optional() as any
832
+ }
833
+
834
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
835
+ // so we must allow `null` values to be parsed by Ajv,
836
+ // but the typing should not reflect that.
837
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
838
+ return new JSchema({
839
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
840
+ optionalField: true,
841
+ }) as any
929
842
  }
930
843
 
931
- /**
932
- * Locks the given schema chain and no other modification can be done to it.
933
- */
934
- final(): JsonSchemaTerminal<OUT, Opt> {
935
- return new JsonSchemaTerminal<OUT, Opt>(this.schema)
844
+ before(date: string): this {
845
+ return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
936
846
  }
937
847
 
938
- /**
939
- *
940
- * @param validator A validator function that returns an error message or undefined.
941
- *
942
- * You may add multiple custom validators and they will be executed in the order you added them.
943
- */
944
- custom<OUT2 = OUT>(validator: CustomValidatorFn): JsonSchemaAnyBuilder<OUT2, Opt> {
945
- const { customValidations = [] } = this.schema
946
- return this.cloneAndUpdateSchema({
947
- customValidations: [...customValidations, validator],
948
- }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
848
+ sameOrBefore(date: string): this {
849
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
949
850
  }
950
851
 
951
- /**
952
- *
953
- * @param converter A converter function that returns a new value.
954
- *
955
- * You may add multiple converters and they will be executed in the order you added them,
956
- * each converter receiving the result from the previous one.
957
- *
958
- * This feature only works when the current schema is nested in an object or array schema,
959
- * due to how mutability works in Ajv.
960
- */
961
- convert<OUT2>(converter: CustomConverterFn<OUT2>): JsonSchemaAnyBuilder<OUT2, Opt> {
962
- const { customConversions = [] } = this.schema
963
- return this.cloneAndUpdateSchema({
964
- customConversions: [...customConversions, converter],
965
- }) as unknown as JsonSchemaAnyBuilder<OUT2, Opt>
852
+ after(date: string): this {
853
+ return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
854
+ }
855
+
856
+ sameOrAfter(date: string): this {
857
+ return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
858
+ }
859
+
860
+ between(fromDate: string, toDate: string, incl: Inclusiveness): this {
861
+ let schemaPatch: Partial<JsonSchema> = {}
862
+
863
+ if (incl === '[)') {
864
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
865
+ } else if (incl === '[]') {
866
+ schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
867
+ }
868
+
869
+ return this.cloneAndUpdateSchema(schemaPatch)
966
870
  }
967
871
  }
968
872
 
969
- export class JsonSchemaStringBuilder<
970
- OUT extends string | undefined = string,
873
+ export interface JsonSchemaIsoDateOptions {
874
+ before?: string
875
+ sameOrBefore?: string
876
+ after?: string
877
+ sameOrAfter?: string
878
+ }
879
+
880
+ export interface JsonSchemaIsoMonthOptions {}
881
+
882
+ export class JNumber<
883
+ OUT extends number | undefined = number,
971
884
  Opt extends boolean = false,
972
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
885
+ > extends JBuilder<OUT, Opt> {
973
886
  constructor() {
974
887
  super({
975
- type: 'string',
888
+ type: 'number',
976
889
  })
977
890
  }
978
891
 
@@ -984,26 +897,23 @@ export class JsonSchemaStringBuilder<
984
897
  *
985
898
  * Make sure this `optional()` call is at the end of your call chain.
986
899
  *
987
- * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
900
+ * When `null` is included in optionalValues, the return type becomes `JSchema`
988
901
  * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
989
902
  */
990
- override optional<T extends readonly (string | null)[] | undefined = undefined>(
903
+ override optional<T extends readonly (number | null)[] | undefined = undefined>(
991
904
  optionalValues?: T,
992
905
  ): T extends readonly (infer U)[]
993
906
  ? null extends U
994
- ? JsonSchemaTerminal<OUT | undefined, true>
995
- : JsonSchemaStringBuilder<OUT | undefined, true>
996
- : JsonSchemaStringBuilder<OUT | undefined, true> {
907
+ ? JSchema<OUT | undefined, true>
908
+ : JNumber<OUT | undefined, true>
909
+ : JNumber<OUT | undefined, true> {
997
910
  if (!optionalValues) {
998
911
  return super.optional() as any
999
912
  }
1000
913
 
1001
- _typeCast<(string | null)[]>(optionalValues)
914
+ _typeCast<(number | null)[]>(optionalValues)
1002
915
 
1003
- let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaStringBuilder<
1004
- OUT,
1005
- Opt
1006
- >().optional()
916
+ let newBuilder: JSchema<OUT | undefined, true> = new JNumber<OUT, Opt>().optional()
1007
917
  const alternativesSchema = j.enum(optionalValues)
1008
918
  Object.assign(newBuilder.getSchema(), {
1009
919
  anyOf: [this.build(), alternativesSchema.build()],
@@ -1013,9 +923,9 @@ export class JsonSchemaStringBuilder<
1013
923
  // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1014
924
  // so we must allow `null` values to be parsed by Ajv,
1015
925
  // but the typing should not reflect that.
1016
- // We also cannot accept more rules attached, since we're not building a StringSchema anymore.
926
+ // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
1017
927
  if (optionalValues.includes(null)) {
1018
- newBuilder = new JsonSchemaTerminal({
928
+ newBuilder = new JSchema({
1019
929
  anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
1020
930
  optionalField: true,
1021
931
  })
@@ -1024,162 +934,318 @@ export class JsonSchemaStringBuilder<
1024
934
  return newBuilder as any
1025
935
  }
1026
936
 
1027
- regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this {
1028
- _assert(
1029
- !pattern.flags,
1030
- `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`,
1031
- )
1032
- return this.pattern(pattern.source, opt)
937
+ integer(): this {
938
+ return this.cloneAndUpdateSchema({ type: 'integer' })
1033
939
  }
1034
940
 
1035
- pattern(pattern: string, opt?: JsonBuilderRuleOpt): this {
1036
- const clone = this.cloneAndUpdateSchema({ pattern })
1037
- if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1038
- if (opt?.msg) clone.setErrorMessage('pattern', opt.msg)
1039
- return clone
941
+ branded<B extends number>(): JNumber<B, Opt> {
942
+ return this as unknown as JNumber<B, Opt>
1040
943
  }
1041
944
 
1042
- minLength(minLength: number): this {
1043
- return this.cloneAndUpdateSchema({ minLength })
945
+ multipleOf(multipleOf: number): this {
946
+ return this.cloneAndUpdateSchema({ multipleOf })
1044
947
  }
1045
948
 
1046
- maxLength(maxLength: number): this {
1047
- return this.cloneAndUpdateSchema({ maxLength })
949
+ min(minimum: number): this {
950
+ return this.cloneAndUpdateSchema({ minimum })
1048
951
  }
1049
952
 
1050
- length(exactLength: number): this
1051
- length(minLength: number, maxLength: number): this
1052
- length(minLengthOrExactLength: number, maxLength?: number): this {
1053
- const maxLengthActual = maxLength ?? minLengthOrExactLength
1054
- return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual)
953
+ exclusiveMin(exclusiveMinimum: number): this {
954
+ return this.cloneAndUpdateSchema({ exclusiveMinimum })
1055
955
  }
1056
956
 
1057
- email(opt?: Partial<JsonSchemaStringEmailOptions>): this {
1058
- const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true }
1059
- return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } })
1060
- .trim()
1061
- .toLowerCase()
957
+ max(maximum: number): this {
958
+ return this.cloneAndUpdateSchema({ maximum })
1062
959
  }
1063
960
 
1064
- trim(): this {
1065
- return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } })
961
+ exclusiveMax(exclusiveMaximum: number): this {
962
+ return this.cloneAndUpdateSchema({ exclusiveMaximum })
1066
963
  }
1067
964
 
1068
- toLowerCase(): this {
1069
- return this.cloneAndUpdateSchema({
1070
- transform: { ...this.schema.transform, toLowerCase: true },
965
+ lessThan(value: number): this {
966
+ return this.exclusiveMax(value)
967
+ }
968
+
969
+ lessThanOrEqual(value: number): this {
970
+ return this.max(value)
971
+ }
972
+
973
+ moreThan(value: number): this {
974
+ return this.exclusiveMin(value)
975
+ }
976
+
977
+ moreThanOrEqual(value: number): this {
978
+ return this.min(value)
979
+ }
980
+
981
+ equal(value: number): this {
982
+ return this.min(value).max(value)
983
+ }
984
+
985
+ range(minimum: number, maximum: number, incl: Inclusiveness): this {
986
+ if (incl === '[)') {
987
+ return this.moreThanOrEqual(minimum).lessThan(maximum)
988
+ }
989
+ return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
990
+ }
991
+
992
+ int32(): this {
993
+ const MIN_INT32 = -(2 ** 31)
994
+ const MAX_INT32 = 2 ** 31 - 1
995
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
996
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
997
+ const newMin = Math.max(MIN_INT32, currentMin)
998
+ const newMax = Math.min(MAX_INT32, currentMax)
999
+ return this.integer().min(newMin).max(newMax)
1000
+ }
1001
+
1002
+ int64(): this {
1003
+ const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1004
+ const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1005
+ const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
1006
+ const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
1007
+ return this.integer().min(newMin).max(newMax)
1008
+ }
1009
+
1010
+ float(): this {
1011
+ return this
1012
+ }
1013
+
1014
+ double(): this {
1015
+ return this
1016
+ }
1017
+
1018
+ unixTimestamp(): JNumber<UnixTimestamp, Opt> {
1019
+ return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
1020
+ }
1021
+
1022
+ unixTimestamp2000(): JNumber<UnixTimestamp, Opt> {
1023
+ return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
1024
+ }
1025
+
1026
+ unixTimestampMillis(): JNumber<UnixTimestampMillis, Opt> {
1027
+ return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1028
+ }
1029
+
1030
+ unixTimestamp2000Millis(): JNumber<UnixTimestampMillis, Opt> {
1031
+ return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1032
+ }
1033
+
1034
+ utcOffset(): this {
1035
+ return this.integer()
1036
+ .multipleOf(15)
1037
+ .min(-12 * 60)
1038
+ .max(14 * 60)
1039
+ }
1040
+
1041
+ utcOffsetHour(): this {
1042
+ return this.integer().min(-12).max(14)
1043
+ }
1044
+
1045
+ /**
1046
+ * Specify the precision of the floating point numbers by the number of digits after the ".".
1047
+ * Excess digits will be cut-off when the current schema is nested in an object or array schema,
1048
+ * due to how mutability works in Ajv.
1049
+ */
1050
+ precision(numberOfDigits: number): this {
1051
+ return this.cloneAndUpdateSchema({ precision: numberOfDigits })
1052
+ }
1053
+ }
1054
+
1055
+ export class JBoolean<
1056
+ OUT extends boolean | undefined = boolean,
1057
+ Opt extends boolean = false,
1058
+ > extends JBuilder<OUT, Opt> {
1059
+ constructor() {
1060
+ super({
1061
+ type: 'boolean',
1071
1062
  })
1072
1063
  }
1073
1064
 
1074
- toUpperCase(): this {
1075
- return this.cloneAndUpdateSchema({
1076
- transform: { ...this.schema.transform, toUpperCase: true },
1065
+ /**
1066
+ * @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
1067
+ *
1068
+ * This `optionalValue` feature only works when the current schema is nested in an object or array schema,
1069
+ * due to how mutability works in Ajv.
1070
+ */
1071
+ override optional(optionalValue?: boolean): JBoolean<OUT | undefined, true> {
1072
+ if (typeof optionalValue === 'undefined') {
1073
+ return super.optional() as unknown as JBoolean<OUT | undefined, true>
1074
+ }
1075
+
1076
+ const newBuilder = new JBoolean<OUT, Opt>().optional()
1077
+ const alternativesSchema = j.enum([optionalValue])
1078
+ Object.assign(newBuilder.getSchema(), {
1079
+ anyOf: [this.build(), alternativesSchema.build()],
1080
+ optionalValues: [optionalValue],
1077
1081
  })
1082
+
1083
+ return newBuilder
1078
1084
  }
1085
+ }
1079
1086
 
1080
- truncate(toLength: number): this {
1081
- return this.cloneAndUpdateSchema({
1082
- transform: { ...this.schema.transform, truncate: toLength },
1087
+ export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends JBuilder<
1088
+ OUT,
1089
+ Opt
1090
+ > {
1091
+ constructor(props?: AnyObject, opt?: JObjectOpts) {
1092
+ super({
1093
+ type: 'object',
1094
+ properties: {},
1095
+ required: [],
1096
+ additionalProperties: false,
1097
+ hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1098
+ patternProperties: opt?.patternProperties ?? undefined,
1099
+ keySchema: opt?.keySchema ?? undefined,
1083
1100
  })
1084
- }
1085
1101
 
1086
- branded<B extends string>(): JsonSchemaStringBuilder<B, Opt> {
1087
- return this as unknown as JsonSchemaStringBuilder<B, Opt>
1102
+ if (props) addPropertiesToSchema(this.schema, props)
1088
1103
  }
1089
1104
 
1090
1105
  /**
1091
- * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value.
1106
+ * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1092
1107
  *
1093
- * All previous expectations in the schema chain are dropped - including `.optional()` -
1094
- * because this call effectively starts a new schema chain.
1108
+ * This `null` feature only works when the current schema is nested in an object or array schema,
1109
+ * due to how mutability works in Ajv.
1110
+ *
1111
+ * When `null` is passed, the return type becomes `JSchema`
1112
+ * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1095
1113
  */
1096
- isoDate(): JsonSchemaIsoDateBuilder {
1097
- return new JsonSchemaIsoDateBuilder()
1098
- }
1099
-
1100
- isoDateTime(): JsonSchemaStringBuilder<IsoDateTime, Opt> {
1101
- return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>()
1102
- }
1114
+ override optional<N extends null | undefined = undefined>(
1115
+ nullValue?: N,
1116
+ ): N extends null ? JSchema<OUT | undefined, true> : JBuilder<OUT | undefined, true> {
1117
+ if (nullValue === undefined) {
1118
+ return super.optional() as any
1119
+ }
1103
1120
 
1104
- isoMonth(): JsonSchemaIsoMonthBuilder {
1105
- return new JsonSchemaIsoMonthBuilder()
1121
+ // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1122
+ // so we must allow `null` values to be parsed by Ajv,
1123
+ // but the typing should not reflect that.
1124
+ // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1125
+ return new JSchema({
1126
+ anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1127
+ optionalField: true,
1128
+ }) as any
1106
1129
  }
1107
1130
 
1108
1131
  /**
1109
- * Validates the string format to be JWT.
1110
- * Expects the JWT to be signed!
1132
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1111
1133
  */
1112
- jwt(): this {
1113
- return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' })
1114
- }
1115
-
1116
- url(): this {
1117
- return this.regex(URL_REGEX, { msg: 'is not a valid URL format' })
1118
- }
1119
-
1120
- ipv4(): this {
1121
- return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' })
1122
- }
1123
-
1124
- ipv6(): this {
1125
- return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' })
1126
- }
1127
1134
 
1128
- slug(): this {
1129
- return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
1135
+ allowAdditionalProperties(): this {
1136
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1130
1137
  }
1131
1138
 
1132
- semVer(): this {
1133
- return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
1134
- }
1139
+ extend<P extends Record<string, JBuilder<any, any>>>(
1140
+ props: P,
1141
+ ): JObject<
1142
+ Override<
1143
+ OUT,
1144
+ {
1145
+ // required keys
1146
+ [K in keyof P as P[K] extends JBuilder<any, infer IsOpt>
1147
+ ? IsOpt extends true
1148
+ ? never
1149
+ : K
1150
+ : never]: P[K] extends JBuilder<infer OUT2, any> ? OUT2 : never
1151
+ } & {
1152
+ // optional keys
1153
+ [K in keyof P as P[K] extends JBuilder<any, infer IsOpt>
1154
+ ? IsOpt extends true
1155
+ ? K
1156
+ : never
1157
+ : never]?: P[K] extends JBuilder<infer OUT2, any> ? OUT2 : never
1158
+ }
1159
+ >,
1160
+ false
1161
+ > {
1162
+ const newBuilder = new JObject()
1163
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1135
1164
 
1136
- languageTag(): this {
1137
- return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
1138
- }
1165
+ const incomingSchemaBuilder = new JObject(props)
1166
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1139
1167
 
1140
- countryCode(): this {
1141
- return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' })
1142
- }
1168
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1143
1169
 
1144
- currency(): this {
1145
- return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' })
1170
+ return newBuilder as any
1146
1171
  }
1147
1172
 
1148
1173
  /**
1149
- * Validates that the input is a valid IANATimzone value.
1174
+ * Concatenates another schema to the current schema.
1150
1175
  *
1151
- * All previous expectations in the schema chain are dropped - including `.optional()` -
1152
- * because this call effectively starts a new schema chain as an `enum` validation.
1176
+ * It expects you to use `isOfType<T>()` in the chain,
1177
+ * otherwise the validation will throw. This is to ensure
1178
+ * that the schemas you concatenated match the intended final type.
1153
1179
  */
1154
- ianaTimezone(): JsonSchemaEnumBuilder<IANATimezone, false> {
1155
- // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier)
1156
- return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>()
1180
+ concat<OUT2 extends AnyObject>(other: JObject<OUT2, any>): JObject<OUT & OUT2, false> {
1181
+ const clone = this.clone()
1182
+ mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
1183
+ _objectAssign(clone.schema, { hasIsOfTypeCheck: false })
1184
+ return clone as unknown as JObject<OUT & OUT2, false>
1157
1185
  }
1158
1186
 
1159
- base64Url(): this {
1160
- return this.regex(BASE64URL_REGEX, {
1161
- msg: 'contains characters not allowed in Base64 URL characterset',
1187
+ /**
1188
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1189
+ */
1190
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1191
+ dbEntity() {
1192
+ return this.extend({
1193
+ id: j.string(),
1194
+ created: j.number().unixTimestamp2000(),
1195
+ updated: j.number().unixTimestamp2000(),
1162
1196
  })
1163
1197
  }
1164
1198
 
1165
- uuid(): this {
1166
- return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' })
1199
+ minProperties(minProperties: number): this {
1200
+ return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
1201
+ }
1202
+
1203
+ maxProperties(maxProperties: number): this {
1204
+ return this.cloneAndUpdateSchema({ maxProperties })
1205
+ }
1206
+
1207
+ exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
1208
+ const exclusiveProperties = this.schema.exclusiveProperties ?? []
1209
+ return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
1167
1210
  }
1168
1211
  }
1169
1212
 
1170
- export interface JsonSchemaStringEmailOptions {
1171
- checkTLD: boolean
1213
+ interface JObjectOpts {
1214
+ hasIsOfTypeCheck?: false
1215
+ patternProperties?: StringMap<JsonSchema<any>>
1216
+ keySchema?: JsonSchema
1172
1217
  }
1173
1218
 
1174
- export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1175
- IsoDate,
1219
+ export class JObjectInfer<
1220
+ PROPS extends Record<string, JBuilder<any, any>>,
1221
+ Opt extends boolean = false,
1222
+ > extends JBuilder<
1223
+ Expand<
1224
+ {
1225
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1226
+ ? IsOpt extends true
1227
+ ? never
1228
+ : K
1229
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1230
+ } & {
1231
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1232
+ ? IsOpt extends true
1233
+ ? K
1234
+ : never
1235
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1236
+ }
1237
+ >,
1176
1238
  Opt
1177
1239
  > {
1178
- constructor() {
1240
+ constructor(props?: PROPS) {
1179
1241
  super({
1180
- type: 'string',
1181
- IsoDate: {},
1242
+ type: 'object',
1243
+ properties: {},
1244
+ required: [],
1245
+ additionalProperties: false,
1182
1246
  })
1247
+
1248
+ if (props) addPropertiesToSchema(this.schema, props)
1183
1249
  }
1184
1250
 
1185
1251
  /**
@@ -1188,14 +1254,51 @@ export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonS
1188
1254
  * This `null` feature only works when the current schema is nested in an object or array schema,
1189
1255
  * due to how mutability works in Ajv.
1190
1256
  *
1191
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1257
+ * When `null` is passed, the return type becomes `JSchema`
1192
1258
  * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1193
1259
  */
1260
+ // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1194
1261
  override optional<N extends null | undefined = undefined>(
1195
1262
  nullValue?: N,
1196
1263
  ): N extends null
1197
- ? JsonSchemaTerminal<IsoDate | undefined, true>
1198
- : JsonSchemaAnyBuilder<IsoDate | undefined, true> {
1264
+ ? JSchema<
1265
+ | Expand<
1266
+ {
1267
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1268
+ ? IsOpt extends true
1269
+ ? never
1270
+ : K
1271
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1272
+ } & {
1273
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1274
+ ? IsOpt extends true
1275
+ ? K
1276
+ : never
1277
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1278
+ }
1279
+ >
1280
+ | undefined,
1281
+ true
1282
+ >
1283
+ : JBuilder<
1284
+ | Expand<
1285
+ {
1286
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1287
+ ? IsOpt extends true
1288
+ ? never
1289
+ : K
1290
+ : never]: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1291
+ } & {
1292
+ [K in keyof PROPS as PROPS[K] extends JBuilder<any, infer IsOpt>
1293
+ ? IsOpt extends true
1294
+ ? K
1295
+ : never
1296
+ : never]?: PROPS[K] extends JBuilder<infer OUT, any> ? OUT : never
1297
+ }
1298
+ >
1299
+ | undefined,
1300
+ true
1301
+ > {
1199
1302
  if (nullValue === undefined) {
1200
1303
  return super.optional() as any
1201
1304
  }
@@ -1204,745 +1307,811 @@ export class JsonSchemaIsoDateBuilder<Opt extends boolean = false> extends JsonS
1204
1307
  // so we must allow `null` values to be parsed by Ajv,
1205
1308
  // but the typing should not reflect that.
1206
1309
  // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1207
- return new JsonSchemaTerminal({
1310
+ return new JSchema({
1208
1311
  anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1209
1312
  optionalField: true,
1210
1313
  }) as any
1211
1314
  }
1212
1315
 
1213
- before(date: string): this {
1214
- return this.cloneAndUpdateSchema({ IsoDate: { before: date } })
1316
+ /**
1317
+ * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1318
+ */
1319
+
1320
+ allowAdditionalProperties(): this {
1321
+ return this.cloneAndUpdateSchema({ additionalProperties: true })
1322
+ }
1323
+
1324
+ extend<NEW_PROPS extends Record<string, JBuilder<any, any>>>(
1325
+ props: NEW_PROPS,
1326
+ ): JObjectInfer<
1327
+ {
1328
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1329
+ ? NEW_PROPS[K]
1330
+ : K extends keyof PROPS
1331
+ ? PROPS[K]
1332
+ : never
1333
+ },
1334
+ Opt
1335
+ > {
1336
+ const newBuilder = new JObjectInfer<PROPS, Opt>()
1337
+ _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1338
+
1339
+ const incomingSchemaBuilder = new JObjectInfer<NEW_PROPS, false>(props)
1340
+ mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1341
+
1342
+ // This extend function is not type-safe as it is inferring,
1343
+ // so even if the base schema was already type-checked,
1344
+ // the new schema loses that quality.
1345
+ _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1346
+
1347
+ return newBuilder as unknown as JObjectInfer<
1348
+ {
1349
+ [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1350
+ ? NEW_PROPS[K]
1351
+ : K extends keyof PROPS
1352
+ ? PROPS[K]
1353
+ : never
1354
+ },
1355
+ Opt
1356
+ >
1357
+ }
1358
+
1359
+ /**
1360
+ * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1361
+ */
1362
+ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1363
+ dbEntity() {
1364
+ return this.extend({
1365
+ id: j.string(),
1366
+ created: j.number().unixTimestamp2000(),
1367
+ updated: j.number().unixTimestamp2000(),
1368
+ })
1369
+ }
1370
+ }
1371
+
1372
+ export class JArray<OUT, Opt> extends JBuilder<OUT[], Opt> {
1373
+ constructor(itemsSchema: JBuilder<OUT, Opt>) {
1374
+ super({
1375
+ type: 'array',
1376
+ items: itemsSchema.build(),
1377
+ })
1378
+ }
1379
+
1380
+ minLength(minItems: number): this {
1381
+ return this.cloneAndUpdateSchema({ minItems })
1382
+ }
1383
+
1384
+ maxLength(maxItems: number): this {
1385
+ return this.cloneAndUpdateSchema({ maxItems })
1386
+ }
1387
+
1388
+ length(exactLength: number): this
1389
+ length(minItems: number, maxItems: number): this
1390
+ length(minItemsOrExact: number, maxItems?: number): this {
1391
+ const maxItemsActual = maxItems ?? minItemsOrExact
1392
+ return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
1215
1393
  }
1216
1394
 
1217
- sameOrBefore(date: string): this {
1218
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } })
1395
+ exactLength(length: number): this {
1396
+ return this.minLength(length).maxLength(length)
1219
1397
  }
1220
1398
 
1221
- after(date: string): this {
1222
- return this.cloneAndUpdateSchema({ IsoDate: { after: date } })
1399
+ unique(): this {
1400
+ return this.cloneAndUpdateSchema({ uniqueItems: true })
1223
1401
  }
1402
+ }
1224
1403
 
1225
- sameOrAfter(date: string): this {
1226
- return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } })
1404
+ class JSet2Builder<OUT, Opt> extends JBuilder<Set2<OUT>, Opt> {
1405
+ constructor(itemsSchema: JBuilder<OUT, Opt>) {
1406
+ super({
1407
+ type: ['array', 'object'],
1408
+ Set2: itemsSchema.build(),
1409
+ })
1227
1410
  }
1228
1411
 
1229
- between(fromDate: string, toDate: string, incl: Inclusiveness): this {
1230
- let schemaPatch: Partial<JsonSchema> = {}
1231
-
1232
- if (incl === '[)') {
1233
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }
1234
- } else if (incl === '[]') {
1235
- schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }
1236
- }
1412
+ min(minItems: number): this {
1413
+ return this.cloneAndUpdateSchema({ minItems })
1414
+ }
1237
1415
 
1238
- return this.cloneAndUpdateSchema(schemaPatch)
1416
+ max(maxItems: number): this {
1417
+ return this.cloneAndUpdateSchema({ maxItems })
1239
1418
  }
1240
1419
  }
1241
1420
 
1242
- export interface JsonSchemaIsoDateOptions {
1243
- before?: string
1244
- sameOrBefore?: string
1245
- after?: string
1246
- sameOrAfter?: string
1247
- }
1421
+ export class JEnum<
1422
+ OUT extends string | number | boolean | null,
1423
+ Opt extends boolean = false,
1424
+ > extends JBuilder<OUT, Opt> {
1425
+ constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
1426
+ const jsonSchema: JsonSchema = { enum: enumValues }
1427
+ // Specifying the base type helps in cases when we ask Ajv to coerce the types.
1428
+ // Having only the `enum` in the schema does not trigger a coercion in Ajv.
1429
+ if (baseType === 'string') jsonSchema.type = 'string'
1430
+ if (baseType === 'number') jsonSchema.type = 'number'
1248
1431
 
1249
- export class JsonSchemaIsoMonthBuilder<Opt extends boolean = false> extends JsonSchemaAnyBuilder<
1250
- IsoMonth,
1251
- Opt
1252
- > {
1253
- constructor() {
1254
- super({
1255
- type: 'string',
1256
- IsoMonth: {},
1257
- })
1432
+ super(jsonSchema)
1433
+
1434
+ if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1435
+ if (opt?.msg) this.setErrorMessage('enum', opt.msg)
1258
1436
  }
1259
- }
1260
1437
 
1261
- export interface JsonSchemaIsoMonthOptions {}
1438
+ branded<B extends OUT>(): JEnum<B, Opt> {
1439
+ return this as unknown as JEnum<B, Opt>
1440
+ }
1441
+ }
1262
1442
 
1263
- export class JsonSchemaNumberBuilder<
1264
- OUT extends number | undefined = number,
1265
- Opt extends boolean = false,
1266
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1267
- constructor() {
1443
+ export class JTuple<ITEMS extends JBuilder<any, any>[]> extends JBuilder<TupleOut<ITEMS>, false> {
1444
+ constructor(items: ITEMS) {
1268
1445
  super({
1269
- type: 'number',
1446
+ type: 'array',
1447
+ prefixItems: items.map(i => i.build()),
1448
+ minItems: items.length,
1449
+ maxItems: items.length,
1270
1450
  })
1271
1451
  }
1452
+ }
1272
1453
 
1273
- /**
1274
- * @param optionalValues List of values that should be considered/converted as `undefined`.
1275
- *
1276
- * This `optionalValues` feature only works when the current schema is nested in an object or array schema,
1277
- * due to how mutability works in Ajv.
1278
- *
1279
- * Make sure this `optional()` call is at the end of your call chain.
1280
- *
1281
- * When `null` is included in optionalValues, the return type becomes `JsonSchemaTerminal`
1282
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1283
- */
1284
- override optional<T extends readonly (number | null)[] | undefined = undefined>(
1285
- optionalValues?: T,
1286
- ): T extends readonly (infer U)[]
1287
- ? null extends U
1288
- ? JsonSchemaTerminal<OUT | undefined, true>
1289
- : JsonSchemaNumberBuilder<OUT | undefined, true>
1290
- : JsonSchemaNumberBuilder<OUT | undefined, true> {
1291
- if (!optionalValues) {
1292
- return super.optional() as any
1293
- }
1294
-
1295
- _typeCast<(number | null)[]>(optionalValues)
1454
+ // ==== Standalone functions for j.object ====
1296
1455
 
1297
- let newBuilder: JsonSchemaTerminal<OUT | undefined, true> = new JsonSchemaNumberBuilder<
1298
- OUT,
1299
- Opt
1300
- >().optional()
1301
- const alternativesSchema = j.enum(optionalValues)
1302
- Object.assign(newBuilder.getSchema(), {
1303
- anyOf: [this.build(), alternativesSchema.build()],
1304
- optionalValues,
1305
- })
1456
+ function object(props: AnyObject): never
1457
+ function object<OUT extends AnyObject>(props: {
1458
+ [K in keyof Required<OUT>]-?: JSchema<OUT[K], any>
1459
+ }): JObject<OUT, false>
1306
1460
 
1307
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1308
- // so we must allow `null` values to be parsed by Ajv,
1309
- // but the typing should not reflect that.
1310
- // We also cannot accept more rules attached, since we're not building a NumberSchema anymore.
1311
- if (optionalValues.includes(null)) {
1312
- newBuilder = new JsonSchemaTerminal({
1313
- anyOf: [{ type: 'null', optionalValues }, newBuilder.build()],
1314
- optionalField: true,
1315
- })
1316
- }
1461
+ function object<OUT extends AnyObject>(props: {
1462
+ [key in keyof OUT]: JSchema<OUT[key], any>
1463
+ }): JObject<OUT, false> {
1464
+ return new JObject<OUT, false>(props)
1465
+ }
1317
1466
 
1318
- return newBuilder as any
1319
- }
1467
+ function objectInfer<P extends Record<string, JBuilder<any, any>>>(
1468
+ props: P,
1469
+ ): JObjectInfer<P, false> {
1470
+ return new JObjectInfer<P, false>(props)
1471
+ }
1320
1472
 
1321
- integer(): this {
1322
- return this.cloneAndUpdateSchema({ type: 'integer' })
1323
- }
1473
+ function objectDbEntity(props: AnyObject): never
1474
+ function objectDbEntity<
1475
+ OUT extends BaseDBEntity,
1476
+ EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
1477
+ keyof OUT,
1478
+ keyof BaseDBEntity
1479
+ >,
1480
+ >(
1481
+ props: {
1482
+ // ✅ all non-system fields must be explicitly provided
1483
+ [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
1484
+ } &
1485
+ // ✅ if `id` differs, it's required
1486
+ (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
1487
+ ? { id?: BuilderFor<BaseDBEntity['id']> }
1488
+ : { id: BuilderFor<OUT['id']> }) &
1489
+ (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
1490
+ ? { created?: BuilderFor<BaseDBEntity['created']> }
1491
+ : { created: BuilderFor<OUT['created']> }) &
1492
+ (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
1493
+ ? { updated?: BuilderFor<BaseDBEntity['updated']> }
1494
+ : { updated: BuilderFor<OUT['updated']> }),
1495
+ ): JObject<OUT, false>
1324
1496
 
1325
- branded<B extends number>(): JsonSchemaNumberBuilder<B, Opt> {
1326
- return this as unknown as JsonSchemaNumberBuilder<B, Opt>
1327
- }
1497
+ function objectDbEntity(props: AnyObject): any {
1498
+ return j.object({
1499
+ id: j.string(),
1500
+ created: j.number().unixTimestamp2000(),
1501
+ updated: j.number().unixTimestamp2000(),
1502
+ ...props,
1503
+ })
1504
+ }
1328
1505
 
1329
- multipleOf(multipleOf: number): this {
1330
- return this.cloneAndUpdateSchema({ multipleOf })
1331
- }
1506
+ function record<
1507
+ KS extends JBuilder<any, any>,
1508
+ VS extends JBuilder<any, any>,
1509
+ Opt extends boolean = SchemaOpt<VS>,
1510
+ >(
1511
+ keySchema: KS,
1512
+ valueSchema: VS,
1513
+ ): JObject<
1514
+ Opt extends true
1515
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
1516
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
1517
+ false
1518
+ > {
1519
+ const keyJsonSchema = keySchema.build()
1520
+ // Check if value schema is optional before build() strips the optionalField flag
1521
+ const isValueOptional = (valueSchema as JSchema<any, any>).getSchema().optionalField
1522
+ const valueJsonSchema = valueSchema.build()
1332
1523
 
1333
- min(minimum: number): this {
1334
- return this.cloneAndUpdateSchema({ minimum })
1335
- }
1524
+ // When value schema is optional, wrap in anyOf to allow undefined values
1525
+ const finalValueSchema: JsonSchema = isValueOptional
1526
+ ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
1527
+ : valueJsonSchema
1336
1528
 
1337
- exclusiveMin(exclusiveMinimum: number): this {
1338
- return this.cloneAndUpdateSchema({ exclusiveMinimum })
1339
- }
1529
+ return new JObject<
1530
+ Opt extends true
1531
+ ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
1532
+ : Record<SchemaOut<KS>, SchemaOut<VS>>,
1533
+ false
1534
+ >([], {
1535
+ hasIsOfTypeCheck: false,
1536
+ keySchema: keyJsonSchema,
1537
+ patternProperties: {
1538
+ ['^.*$']: finalValueSchema,
1539
+ },
1540
+ })
1541
+ }
1340
1542
 
1341
- max(maximum: number): this {
1342
- return this.cloneAndUpdateSchema({ maximum })
1543
+ function withRegexKeys<S extends JBuilder<any, any>>(
1544
+ keyRegex: RegExp | string,
1545
+ schema: S,
1546
+ ): JObject<StringMap<SchemaOut<S>>, false> {
1547
+ if (keyRegex instanceof RegExp) {
1548
+ _assert(
1549
+ !keyRegex.flags,
1550
+ `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
1551
+ )
1343
1552
  }
1553
+ const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
1554
+ const jsonSchema = schema.build()
1344
1555
 
1345
- exclusiveMax(exclusiveMaximum: number): this {
1346
- return this.cloneAndUpdateSchema({ exclusiveMaximum })
1347
- }
1556
+ return new JObject<StringMap<SchemaOut<S>>, false>([], {
1557
+ hasIsOfTypeCheck: false,
1558
+ patternProperties: {
1559
+ [pattern]: jsonSchema,
1560
+ },
1561
+ })
1562
+ }
1348
1563
 
1349
- lessThan(value: number): this {
1350
- return this.exclusiveMax(value)
1564
+ /**
1565
+ * Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
1566
+ */
1567
+ function withEnumKeys<
1568
+ const T extends readonly (string | number)[] | StringEnum | NumberEnum,
1569
+ S extends JBuilder<any, any>,
1570
+ K extends string | number = EnumKeyUnion<T>,
1571
+ Opt extends boolean = SchemaOpt<S>,
1572
+ >(
1573
+ keys: T,
1574
+ schema: S,
1575
+ ): JObject<Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> }, false> {
1576
+ let enumValues: readonly (string | number)[] | undefined
1577
+ if (Array.isArray(keys)) {
1578
+ _assert(
1579
+ isEveryItemPrimitive(keys),
1580
+ 'Every item in the key list should be string, number or symbol',
1581
+ )
1582
+ enumValues = keys
1583
+ } else if (typeof keys === 'object') {
1584
+ const enumType = getEnumType(keys)
1585
+ _assert(
1586
+ enumType === 'NumberEnum' || enumType === 'StringEnum',
1587
+ 'The key list should be StringEnum or NumberEnum',
1588
+ )
1589
+ if (enumType === 'NumberEnum') {
1590
+ enumValues = _numberEnumValues(keys as NumberEnum)
1591
+ } else if (enumType === 'StringEnum') {
1592
+ enumValues = _stringEnumValues(keys as StringEnum)
1593
+ }
1351
1594
  }
1352
1595
 
1353
- lessThanOrEqual(value: number): this {
1354
- return this.max(value)
1355
- }
1596
+ _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
1356
1597
 
1357
- moreThan(value: number): this {
1358
- return this.exclusiveMin(value)
1359
- }
1598
+ const typedValues = enumValues as readonly K[]
1599
+ const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
1360
1600
 
1361
- moreThanOrEqual(value: number): this {
1362
- return this.min(value)
1363
- }
1601
+ return new JObject<
1602
+ Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
1603
+ false
1604
+ >(props, { hasIsOfTypeCheck: false })
1605
+ }
1364
1606
 
1365
- equal(value: number): this {
1366
- return this.min(value).max(value)
1367
- }
1607
+ // ==== AjvSchema compat wrapper ====
1368
1608
 
1369
- range(minimum: number, maximum: number, incl: Inclusiveness): this {
1370
- if (incl === '[)') {
1371
- return this.moreThanOrEqual(minimum).lessThan(maximum)
1609
+ /**
1610
+ * On creation - compiles ajv validation function.
1611
+ * Provides convenient methods, error reporting, etc.
1612
+ */
1613
+ export class AjvSchema<OUT> {
1614
+ private constructor(
1615
+ public schema: JsonSchema<OUT>,
1616
+ cfg: Partial<AjvSchemaCfg> = {},
1617
+ preCompiledFn?: any,
1618
+ ) {
1619
+ this.cfg = {
1620
+ lazy: false,
1621
+ ...cfg,
1622
+ ajv: cfg.ajv || getAjv(),
1623
+ // Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json")
1624
+ inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
1372
1625
  }
1373
- return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum)
1374
- }
1375
-
1376
- int32(): this {
1377
- const MIN_INT32 = -(2 ** 31)
1378
- const MAX_INT32 = 2 ** 31 - 1
1379
- const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1380
- const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1381
- const newMin = Math.max(MIN_INT32, currentMin)
1382
- const newMax = Math.min(MAX_INT32, currentMax)
1383
- return this.integer().min(newMin).max(newMax)
1384
- }
1385
1626
 
1386
- int64(): this {
1387
- const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER
1388
- const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER
1389
- const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin)
1390
- const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax)
1391
- return this.integer().min(newMin).max(newMax)
1627
+ if (preCompiledFn) {
1628
+ this._compiledFn = preCompiledFn
1629
+ } else if (!cfg.lazy) {
1630
+ this._getValidateFn() // compile eagerly
1631
+ }
1392
1632
  }
1393
1633
 
1394
- float(): this {
1395
- return this
1634
+ /**
1635
+ * Shortcut for AjvSchema.create(schema, { lazy: true })
1636
+ */
1637
+ static createLazy<OUT>(
1638
+ schema: SchemaHandledByAjv<OUT>,
1639
+ cfg?: Partial<AjvSchemaCfg>,
1640
+ ): AjvSchema<OUT> {
1641
+ return AjvSchema.create(schema, {
1642
+ lazy: true,
1643
+ ...cfg,
1644
+ })
1396
1645
  }
1397
1646
 
1398
- double(): this {
1399
- return this
1400
- }
1647
+ /**
1648
+ * Conveniently allows to pass either JsonSchema or JSchema builder, or existing AjvSchema.
1649
+ * If it's already an AjvSchema - it'll just return it without any processing.
1650
+ * If it's a Builder - will call `build` before proceeding.
1651
+ * Otherwise - will construct AjvSchema instance ready to be used.
1652
+ */
1653
+ static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> {
1654
+ if (schema instanceof AjvSchema) return schema
1401
1655
 
1402
- unixTimestamp(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1403
- return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>()
1404
- }
1656
+ if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) {
1657
+ return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema)
1658
+ }
1405
1659
 
1406
- unixTimestamp2000(): JsonSchemaNumberBuilder<UnixTimestamp, Opt> {
1407
- return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>()
1408
- }
1660
+ let jsonSchema: JsonSchema<OUT>
1409
1661
 
1410
- unixTimestampMillis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1411
- return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1412
- }
1662
+ if (schema instanceof JSchema) {
1663
+ // oxlint-disable typescript-eslint(no-unnecessary-type-assertion)
1664
+ jsonSchema = (schema as JSchema<OUT, any>).build()
1665
+ AjvSchema.requireValidJsonSchema(jsonSchema)
1666
+ } else {
1667
+ jsonSchema = schema
1668
+ }
1413
1669
 
1414
- unixTimestamp2000Millis(): JsonSchemaNumberBuilder<UnixTimestampMillis, Opt> {
1415
- return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>()
1416
- }
1670
+ // This is our own helper which marks a schema as optional
1671
+ // in case it is going to be used in an object schema,
1672
+ // where we need to mark the given property as not-required.
1673
+ // But once all compilation is done, the presence of this field
1674
+ // really upsets Ajv.
1675
+ delete jsonSchema.optionalField
1417
1676
 
1418
- utcOffset(): this {
1419
- return this.integer()
1420
- .multipleOf(15)
1421
- .min(-12 * 60)
1422
- .max(14 * 60)
1423
- }
1677
+ const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg)
1678
+ AjvSchema.cacheAjvSchema(schema, ajvSchema)
1424
1679
 
1425
- utcOffsetHour(): this {
1426
- return this.integer().min(-12).max(14)
1680
+ return ajvSchema
1427
1681
  }
1428
1682
 
1429
1683
  /**
1430
- * Specify the precision of the floating point numbers by the number of digits after the ".".
1431
- * Excess digits will be cut-off when the current schema is nested in an object or array schema,
1432
- * due to how mutability works in Ajv.
1684
+ * Creates a minimal AjvSchema wrapper from a pre-compiled validate function.
1685
+ * Used internally by JSchema to cache a compatible AjvSchema instance.
1433
1686
  */
1434
- precision(numberOfDigits: number): this {
1435
- return this.cloneAndUpdateSchema({ precision: numberOfDigits })
1687
+ static _wrap<OUT>(schema: JsonSchema<OUT>, compiledFn: any): AjvSchema<OUT> {
1688
+ return new AjvSchema<OUT>(schema, {}, compiledFn)
1436
1689
  }
1437
- }
1438
1690
 
1439
- export class JsonSchemaBooleanBuilder<
1440
- OUT extends boolean | undefined = boolean,
1441
- Opt extends boolean = false,
1442
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1443
- constructor() {
1444
- super({
1445
- type: 'boolean',
1446
- })
1691
+ static isSchemaWithCachedAjvSchema<Base, OUT>(
1692
+ schema: Base,
1693
+ ): schema is WithCachedAjvSchema<Base, OUT> {
1694
+ return !!(schema as any)?.[HIDDEN_AJV_SCHEMA]
1447
1695
  }
1448
1696
 
1449
- /**
1450
- * @param optionalValue One of the two possible boolean values that should be considered/converted as `undefined`.
1451
- *
1452
- * This `optionalValue` feature only works when the current schema is nested in an object or array schema,
1453
- * due to how mutability works in Ajv.
1454
- */
1455
- override optional(optionalValue?: boolean): JsonSchemaBooleanBuilder<OUT | undefined, true> {
1456
- if (typeof optionalValue === 'undefined') {
1457
- return super.optional() as unknown as JsonSchemaBooleanBuilder<OUT | undefined, true>
1458
- }
1459
-
1460
- const newBuilder = new JsonSchemaBooleanBuilder<OUT, Opt>().optional()
1461
- const alternativesSchema = j.enum([optionalValue])
1462
- Object.assign(newBuilder.getSchema(), {
1463
- anyOf: [this.build(), alternativesSchema.build()],
1464
- optionalValues: [optionalValue],
1465
- })
1466
-
1467
- return newBuilder
1697
+ static cacheAjvSchema<Base extends AnyObject, OUT>(
1698
+ schema: Base,
1699
+ ajvSchema: AjvSchema<OUT>,
1700
+ ): WithCachedAjvSchema<Base, OUT> {
1701
+ return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema })
1468
1702
  }
1469
- }
1470
-
1471
- export class JsonSchemaObjectBuilder<
1472
- OUT extends AnyObject,
1473
- Opt extends boolean = false,
1474
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1475
- constructor(props?: AnyObject, opt?: JsonSchemaObjectBuilderOpts) {
1476
- super({
1477
- type: 'object',
1478
- properties: {},
1479
- required: [],
1480
- additionalProperties: false,
1481
- hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true,
1482
- patternProperties: opt?.patternProperties ?? undefined,
1483
- keySchema: opt?.keySchema ?? undefined,
1484
- })
1485
1703
 
1486
- if (props) this.addProperties(props)
1704
+ static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> {
1705
+ return schema[HIDDEN_AJV_SCHEMA]
1487
1706
  }
1488
1707
 
1489
- addProperties(props: AnyObject): this {
1490
- const properties: Record<string, JsonSchema> = {}
1491
- const required: string[] = []
1708
+ readonly cfg: AjvSchemaCfg
1492
1709
 
1493
- for (const [key, builder] of Object.entries(props)) {
1494
- const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1495
- if (!isOptional) {
1496
- required.push(key)
1497
- }
1710
+ private _compiledFn: any
1498
1711
 
1499
- const schema = builder.build()
1500
- properties[key] = schema
1712
+ private _getValidateFn(): any {
1713
+ if (!this._compiledFn) {
1714
+ this._compiledFn = this.cfg.ajv.compile(this.schema as any)
1501
1715
  }
1502
-
1503
- this.schema.properties = properties
1504
- this.schema.required = _uniq(required).sort()
1505
-
1506
- return this
1716
+ return this._compiledFn
1507
1717
  }
1508
1718
 
1509
1719
  /**
1510
- * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1511
- *
1512
- * This `null` feature only works when the current schema is nested in an object or array schema,
1513
- * due to how mutability works in Ajv.
1514
- *
1515
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1516
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1720
+ * It returns the original object just for convenience.
1517
1721
  */
1518
- override optional<N extends null | undefined = undefined>(
1519
- nullValue?: N,
1520
- ): N extends null
1521
- ? JsonSchemaTerminal<OUT | undefined, true>
1522
- : JsonSchemaAnyBuilder<OUT | undefined, true> {
1523
- if (nullValue === undefined) {
1524
- return super.optional() as any
1525
- }
1526
-
1527
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1528
- // so we must allow `null` values to be parsed by Ajv,
1529
- // but the typing should not reflect that.
1530
- // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1531
- return new JsonSchemaTerminal({
1532
- anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1533
- optionalField: true,
1534
- }) as any
1722
+ validate(input: unknown, opt: AjvValidationOptions = {}): OUT {
1723
+ const [err, output] = this.getValidationResult(input, opt)
1724
+ if (err) throw err
1725
+ return output
1535
1726
  }
1536
1727
 
1537
- /**
1538
- * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1539
- */
1540
-
1541
- allowAdditionalProperties(): this {
1542
- return this.cloneAndUpdateSchema({ additionalProperties: true })
1728
+ isValid(input: unknown, opt?: AjvValidationOptions): boolean {
1729
+ const [err] = this.getValidationResult(input, opt)
1730
+ return !err
1543
1731
  }
1544
1732
 
1545
- extend<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1546
- props: P,
1547
- ): JsonSchemaObjectBuilder<
1548
- Override<
1549
- OUT,
1550
- {
1551
- // required keys
1552
- [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1553
- ? IsOpt extends true
1554
- ? never
1555
- : K
1556
- : never]: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1557
- } & {
1558
- // optional keys
1559
- [K in keyof P as P[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1560
- ? IsOpt extends true
1561
- ? K
1562
- : never
1563
- : never]?: P[K] extends JsonSchemaAnyBuilder<infer OUT2, any> ? OUT2 : never
1564
- }
1565
- >,
1566
- false
1567
- > {
1568
- const newBuilder = new JsonSchemaObjectBuilder()
1569
- _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1570
-
1571
- const incomingSchemaBuilder = new JsonSchemaObjectBuilder(props)
1572
- mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1573
-
1574
- _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1575
-
1576
- return newBuilder as any
1733
+ getValidationResult(
1734
+ input: unknown,
1735
+ opt: AjvValidationOptions = {},
1736
+ ): ValidationFunctionResult<OUT, AjvValidationError> {
1737
+ const fn = this._getValidateFn()
1738
+ return executeValidation<OUT>(fn, this.schema, input, opt, this.cfg.inputName)
1577
1739
  }
1578
1740
 
1579
- /**
1580
- * Concatenates another schema to the current schema.
1581
- *
1582
- * It expects you to use `isOfType<T>()` in the chain,
1583
- * otherwise the validation will throw. This is to ensure
1584
- * that the schemas you concatenated match the intended final type.
1585
- *
1586
- * ```ts
1587
- * interface Foo { foo: string }
1588
- * const fooSchema = j.object<Foo>({ foo: j.string() })
1589
- *
1590
- * interface Bar { bar: number }
1591
- * const barSchema = j.object<Bar>({ bar: j.number() })
1592
- *
1593
- * interface Shu { foo: string, bar: number }
1594
- * const shuSchema = fooSchema.concat(barSchema).isOfType<Shu>() // important
1595
- * ```
1596
- */
1597
- concat<OUT2 extends AnyObject>(
1598
- other: JsonSchemaObjectBuilder<OUT2, any>,
1599
- ): JsonSchemaObjectBuilder<OUT & OUT2, false> {
1600
- const clone = this.clone()
1601
- mergeJsonSchemaObjects(clone.schema as any, other.schema as any)
1602
- _objectAssign(clone.schema, { hasIsOfTypeCheck: false })
1603
- return clone as unknown as JsonSchemaObjectBuilder<OUT & OUT2, false>
1741
+ getValidationFunction(): ValidationFunction<OUT, AjvValidationError> {
1742
+ return (input, opt) => {
1743
+ return this.getValidationResult(input, {
1744
+ mutateInput: opt?.mutateInput,
1745
+ inputName: opt?.inputName,
1746
+ inputId: opt?.inputId,
1747
+ })
1748
+ }
1604
1749
  }
1605
1750
 
1606
- /**
1607
- * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1608
- */
1609
- // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1610
- dbEntity() {
1611
- return this.extend({
1612
- id: j.string(),
1613
- created: j.number().unixTimestamp2000(),
1614
- updated: j.number().unixTimestamp2000(),
1615
- })
1751
+ private static requireValidJsonSchema(schema: JsonSchema): void {
1752
+ // For object schemas we require that it is type checked against an external type, e.g.:
1753
+ // interface Foo { name: string }
1754
+ // const schema = j.object({ name: j.string() }).ofType<Foo>()
1755
+ _assert(
1756
+ schema.type !== 'object' || schema.hasIsOfTypeCheck,
1757
+ 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.',
1758
+ )
1616
1759
  }
1760
+ }
1617
1761
 
1618
- minProperties(minProperties: number): this {
1619
- return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties })
1620
- }
1762
+ // ==== Shared validation logic ====
1621
1763
 
1622
- maxProperties(maxProperties: number): this {
1623
- return this.cloneAndUpdateSchema({ maxProperties })
1624
- }
1764
+ const separator = '\n'
1625
1765
 
1626
- exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this {
1627
- const exclusiveProperties = this.schema.exclusiveProperties ?? []
1628
- return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] })
1766
+ function executeValidation<OUT>(
1767
+ fn: any,
1768
+ builtSchema: JsonSchema,
1769
+ input: unknown,
1770
+ opt: AjvValidationOptions = {},
1771
+ defaultInputName?: string,
1772
+ ): ValidationFunctionResult<OUT, AjvValidationError> {
1773
+ const item =
1774
+ opt.mutateInput !== false || typeof input !== 'object'
1775
+ ? input // mutate
1776
+ : _deepCopy(input) // not mutate
1777
+
1778
+ let valid = fn(item) // mutates item, but not input
1779
+ _typeCast<OUT>(item)
1780
+
1781
+ let output: OUT = item
1782
+ if (valid && builtSchema.postValidation) {
1783
+ const [err, result] = _try(() => builtSchema.postValidation!(output))
1784
+ if (err) {
1785
+ valid = false
1786
+ ;(fn as any).errors = [
1787
+ {
1788
+ instancePath: '',
1789
+ message: err.message,
1790
+ },
1791
+ ]
1792
+ } else {
1793
+ output = result as OUT
1794
+ }
1629
1795
  }
1630
- }
1631
1796
 
1632
- interface JsonSchemaObjectBuilderOpts {
1633
- hasIsOfTypeCheck?: false
1634
- patternProperties?: StringMap<JsonSchema<any>>
1635
- keySchema?: JsonSchema
1797
+ if (valid) return [null, output]
1798
+
1799
+ const errors = fn.errors!
1800
+
1801
+ const {
1802
+ inputId = _isObject(input) ? (input as any)['id'] : undefined,
1803
+ inputName = defaultInputName || 'Object',
1804
+ } = opt
1805
+ const dataVar = [inputName, inputId].filter(Boolean).join('.')
1806
+
1807
+ applyImprovementsOnErrorMessages(errors, builtSchema)
1808
+
1809
+ let message = getAjv().errorsText(errors, {
1810
+ dataVar,
1811
+ separator,
1812
+ })
1813
+
1814
+ // Note: if we mutated the input already, e.g stripped unknown properties,
1815
+ // the error message Input would contain already mutated object print, such as Input: {}
1816
+ // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness.
1817
+ const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 })
1818
+ message = [message, 'Input: ' + inputStringified].join(separator)
1819
+
1820
+ const err = new AjvValidationError(
1821
+ message,
1822
+ _filterNullishValues({
1823
+ errors,
1824
+ inputName,
1825
+ inputId,
1826
+ }),
1827
+ )
1828
+ return [err, output]
1636
1829
  }
1637
1830
 
1638
- export class JsonSchemaObjectInferringBuilder<
1639
- PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>,
1640
- Opt extends boolean = false,
1641
- > extends JsonSchemaAnyBuilder<
1642
- Expand<
1643
- {
1644
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1645
- ? IsOpt extends true
1646
- ? never
1647
- : K
1648
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1649
- } & {
1650
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1651
- ? IsOpt extends true
1652
- ? K
1653
- : never
1654
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1831
+ // ==== Error formatting helpers ====
1832
+
1833
+ function applyImprovementsOnErrorMessages(
1834
+ errors: ErrorObject<string, Record<string, any>, unknown>[] | null | undefined,
1835
+ schema: JsonSchema,
1836
+ ): void {
1837
+ if (!errors) return
1838
+
1839
+ filterNullableAnyOfErrors(errors, schema)
1840
+
1841
+ const { errorMessages } = schema
1842
+
1843
+ for (const error of errors) {
1844
+ const errorMessage = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword)
1845
+
1846
+ if (errorMessage) {
1847
+ error.message = errorMessage
1848
+ } else if (errorMessages?.[error.keyword]) {
1849
+ error.message = errorMessages[error.keyword]
1850
+ } else {
1851
+ const unwrapped = unwrapNullableAnyOf(schema)
1852
+ if (unwrapped?.errorMessages?.[error.keyword]) {
1853
+ error.message = unwrapped.errorMessages[error.keyword]
1854
+ }
1655
1855
  }
1656
- >,
1657
- Opt
1658
- > {
1659
- constructor(props?: PROPS) {
1660
- super({
1661
- type: 'object',
1662
- properties: {},
1663
- required: [],
1664
- additionalProperties: false,
1665
- })
1666
1856
 
1667
- if (props) this.addProperties(props)
1857
+ error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.')
1668
1858
  }
1859
+ }
1669
1860
 
1670
- addProperties(props: PROPS): this {
1671
- const properties: Record<string, JsonSchema> = {}
1672
- const required: string[] = []
1861
+ /**
1862
+ * Filters out noisy errors produced by nullable anyOf patterns.
1863
+ * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`,
1864
+ * AJV produces "must be null" and "must match a schema in anyOf" errors
1865
+ * that are confusing. This method splices them out, keeping only the real errors.
1866
+ */
1867
+ function filterNullableAnyOfErrors(
1868
+ errors: ErrorObject<string, Record<string, any>, unknown>[],
1869
+ schema: JsonSchema,
1870
+ ): void {
1871
+ // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches)
1872
+ const exactPaths: string[] = []
1873
+ const nullBranchPrefixes: string[] = []
1673
1874
 
1674
- for (const [key, builder] of Object.entries(props)) {
1675
- const isOptional = (builder as JsonSchemaTerminal<any, any>).getSchema().optionalField
1676
- if (!isOptional) {
1677
- required.push(key)
1678
- }
1875
+ for (const error of errors) {
1876
+ if (error.keyword !== 'anyOf') continue
1679
1877
 
1680
- const schema = builder.build()
1681
- properties[key] = schema
1682
- }
1878
+ const parentSchema = resolveSchemaPath(schema, error.schemaPath)
1879
+ if (!parentSchema) continue
1683
1880
 
1684
- this.schema.properties = properties
1685
- this.schema.required = _uniq(required).sort()
1881
+ const nullIndex = unwrapNullableAnyOfIndex(parentSchema)
1882
+ if (nullIndex === -1) continue
1686
1883
 
1687
- return this
1884
+ exactPaths.push(error.schemaPath) // e.g. "#/anyOf"
1885
+ const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length)
1886
+ nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`) // e.g. "#/anyOf/1/"
1688
1887
  }
1689
1888
 
1690
- /**
1691
- * @param nullValue Pass `null` to have `null` values be considered/converted as `undefined`.
1692
- *
1693
- * This `null` feature only works when the current schema is nested in an object or array schema,
1694
- * due to how mutability works in Ajv.
1695
- *
1696
- * When `null` is passed, the return type becomes `JsonSchemaTerminal`
1697
- * (no further chaining allowed) because the schema is wrapped in an anyOf structure.
1698
- */
1699
- // @ts-expect-error override adds optional parameter which is compatible but TS can't verify complex mapped types
1700
- override optional<N extends null | undefined = undefined>(
1701
- nullValue?: N,
1702
- ): N extends null
1703
- ? JsonSchemaTerminal<
1704
- | Expand<
1705
- {
1706
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1707
- ? IsOpt extends true
1708
- ? never
1709
- : K
1710
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1711
- } & {
1712
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1713
- ? IsOpt extends true
1714
- ? K
1715
- : never
1716
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1717
- }
1718
- >
1719
- | undefined,
1720
- true
1721
- >
1722
- : JsonSchemaAnyBuilder<
1723
- | Expand<
1724
- {
1725
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1726
- ? IsOpt extends true
1727
- ? never
1728
- : K
1729
- : never]: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1730
- } & {
1731
- [K in keyof PROPS as PROPS[K] extends JsonSchemaAnyBuilder<any, infer IsOpt>
1732
- ? IsOpt extends true
1733
- ? K
1734
- : never
1735
- : never]?: PROPS[K] extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
1736
- }
1737
- >
1738
- | undefined,
1739
- true
1740
- > {
1741
- if (nullValue === undefined) {
1742
- return super.optional() as any
1743
- }
1889
+ if (!exactPaths.length) return
1744
1890
 
1745
- // When `null` is specified, we want `null` to be stripped and the value to become `undefined`,
1746
- // so we must allow `null` values to be parsed by Ajv,
1747
- // but the typing should not reflect that.
1748
- // We also cannot accept more rules attached, since we're not building an ObjectSchema anymore.
1749
- return new JsonSchemaTerminal({
1750
- anyOf: [{ type: 'null', optionalValues: [null] }, this.build()],
1751
- optionalField: true,
1752
- }) as any
1891
+ for (let i = errors.length - 1; i >= 0; i--) {
1892
+ const sp = errors[i]!.schemaPath
1893
+ if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) {
1894
+ errors.splice(i, 1)
1895
+ }
1753
1896
  }
1897
+ }
1754
1898
 
1755
- /**
1756
- * When set, the validation will not strip away properties that are not specified explicitly in the schema.
1757
- */
1899
+ /**
1900
+ * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf")
1901
+ * and returns the parent schema containing the last keyword.
1902
+ */
1903
+ function resolveSchemaPath(schema: JsonSchema, schemaPath: string): JsonSchema | undefined {
1904
+ // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf"
1905
+ // We want the schema that contains the final keyword (e.g. "anyOf")
1906
+ const segments = schemaPath.replace(/^#\//, '').split('/')
1907
+ // Remove the last segment (the keyword itself, e.g. "anyOf")
1908
+ segments.pop()
1909
+
1910
+ let current: any = schema
1911
+ for (const segment of segments) {
1912
+ if (!current || typeof current !== 'object') return undefined
1913
+ current = current[segment]
1914
+ }
1915
+ return current as JsonSchema | undefined
1916
+ }
1758
1917
 
1759
- allowAdditionalProperties(): this {
1760
- return this.cloneAndUpdateSchema({ additionalProperties: true })
1761
- }
1918
+ function getErrorMessageForInstancePath(
1919
+ schema: JsonSchema | undefined,
1920
+ instancePath: string,
1921
+ keyword: string,
1922
+ ): string | undefined {
1923
+ if (!schema || !instancePath) return undefined
1762
1924
 
1763
- extend<NEW_PROPS extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
1764
- props: NEW_PROPS,
1765
- ): JsonSchemaObjectInferringBuilder<
1766
- {
1767
- [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1768
- ? NEW_PROPS[K]
1769
- : K extends keyof PROPS
1770
- ? PROPS[K]
1771
- : never
1772
- },
1773
- Opt
1774
- > {
1775
- const newBuilder = new JsonSchemaObjectInferringBuilder<PROPS, Opt>()
1776
- _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema))
1925
+ const segments = instancePath.split('/').filter(Boolean)
1926
+ return traverseSchemaPath(schema, segments, keyword)
1927
+ }
1777
1928
 
1778
- const incomingSchemaBuilder = new JsonSchemaObjectInferringBuilder<NEW_PROPS, false>(props)
1779
- mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any)
1929
+ function traverseSchemaPath(
1930
+ schema: JsonSchema,
1931
+ segments: string[],
1932
+ keyword: string,
1933
+ ): string | undefined {
1934
+ if (!segments.length) return undefined
1780
1935
 
1781
- // This extend function is not type-safe as it is inferring,
1782
- // so even if the base schema was already type-checked,
1783
- // the new schema loses that quality.
1784
- _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false })
1936
+ const [currentSegment, ...remainingSegments] = segments
1785
1937
 
1786
- return newBuilder as unknown as JsonSchemaObjectInferringBuilder<
1787
- {
1788
- [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS
1789
- ? NEW_PROPS[K]
1790
- : K extends keyof PROPS
1791
- ? PROPS[K]
1792
- : never
1793
- },
1794
- Opt
1795
- >
1796
- }
1938
+ const nextSchema = getChildSchema(schema, currentSegment)
1939
+ if (!nextSchema) return undefined
1797
1940
 
1798
- /**
1799
- * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions.
1800
- */
1801
- // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types
1802
- dbEntity() {
1803
- return this.extend({
1804
- id: j.string(),
1805
- created: j.number().unixTimestamp2000(),
1806
- updated: j.number().unixTimestamp2000(),
1807
- })
1941
+ if (nextSchema.errorMessages?.[keyword]) {
1942
+ return nextSchema.errorMessages[keyword]
1808
1943
  }
1809
- }
1810
1944
 
1811
- export class JsonSchemaArrayBuilder<OUT, Opt> extends JsonSchemaAnyBuilder<OUT[], Opt> {
1812
- constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1813
- super({
1814
- type: 'array',
1815
- items: itemsSchema.build(),
1816
- })
1945
+ // Check through nullable wrapper
1946
+ const unwrapped = unwrapNullableAnyOf(nextSchema)
1947
+ if (unwrapped?.errorMessages?.[keyword]) {
1948
+ return unwrapped.errorMessages[keyword]
1817
1949
  }
1818
1950
 
1819
- minLength(minItems: number): this {
1820
- return this.cloneAndUpdateSchema({ minItems })
1951
+ if (remainingSegments.length) {
1952
+ return traverseSchemaPath(nextSchema, remainingSegments, keyword)
1821
1953
  }
1822
1954
 
1823
- maxLength(maxItems: number): this {
1824
- return this.cloneAndUpdateSchema({ maxItems })
1825
- }
1955
+ return undefined
1956
+ }
1826
1957
 
1827
- length(exactLength: number): this
1828
- length(minItems: number, maxItems: number): this
1829
- length(minItemsOrExact: number, maxItems?: number): this {
1830
- const maxItemsActual = maxItems ?? minItemsOrExact
1831
- return this.minLength(minItemsOrExact).maxLength(maxItemsActual)
1832
- }
1958
+ function getChildSchema(schema: JsonSchema, segment: string | undefined): JsonSchema | undefined {
1959
+ if (!segment) return undefined
1833
1960
 
1834
- exactLength(length: number): this {
1835
- return this.minLength(length).maxLength(length)
1836
- }
1961
+ // Unwrap nullable anyOf to find properties/items through nullable wrappers
1962
+ const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema
1837
1963
 
1838
- unique(): this {
1839
- return this.cloneAndUpdateSchema({ uniqueItems: true })
1964
+ if (/^\d+$/.test(segment) && effectiveSchema.items) {
1965
+ return getArrayItemSchema(effectiveSchema, segment)
1840
1966
  }
1967
+
1968
+ return getObjectPropertySchema(effectiveSchema, segment)
1841
1969
  }
1842
1970
 
1843
- export class JsonSchemaSet2Builder<OUT, Opt> extends JsonSchemaAnyBuilder<Set2<OUT>, Opt> {
1844
- constructor(itemsSchema: JsonSchemaAnyBuilder<OUT, Opt>) {
1845
- super({
1846
- type: ['array', 'object'],
1847
- Set2: itemsSchema.build(),
1848
- })
1849
- }
1971
+ function getArrayItemSchema(schema: JsonSchema, indexSegment: string): JsonSchema | undefined {
1972
+ if (!schema.items) return undefined
1850
1973
 
1851
- min(minItems: number): this {
1852
- return this.cloneAndUpdateSchema({ minItems })
1974
+ if (Array.isArray(schema.items)) {
1975
+ return schema.items[Number(indexSegment)]
1853
1976
  }
1854
1977
 
1855
- max(maxItems: number): this {
1856
- return this.cloneAndUpdateSchema({ maxItems })
1857
- }
1978
+ return schema.items
1858
1979
  }
1859
1980
 
1860
- export class JsonSchemaBufferBuilder extends JsonSchemaAnyBuilder<Buffer, false> {
1861
- constructor() {
1862
- super({
1863
- Buffer: true,
1864
- })
1865
- }
1981
+ function getObjectPropertySchema(schema: JsonSchema, segment: string): JsonSchema | undefined {
1982
+ return schema.properties?.[segment as keyof typeof schema.properties]
1866
1983
  }
1867
1984
 
1868
- export class JsonSchemaEnumBuilder<
1869
- OUT extends string | number | boolean | null,
1870
- Opt extends boolean = false,
1871
- > extends JsonSchemaAnyBuilder<OUT, Opt> {
1872
- constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) {
1873
- const jsonSchema: JsonSchema = { enum: enumValues }
1874
- // Specifying the base type helps in cases when we ask Ajv to coerce the types.
1875
- // Having only the `enum` in the schema does not trigger a coercion in Ajv.
1876
- if (baseType === 'string') jsonSchema.type = 'string'
1877
- if (baseType === 'number') jsonSchema.type = 'number'
1985
+ function unwrapNullableAnyOf(schema: JsonSchema): JsonSchema | undefined {
1986
+ const nullIndex = unwrapNullableAnyOfIndex(schema)
1987
+ if (nullIndex === -1) return undefined
1988
+ return schema.anyOf![1 - nullIndex]!
1989
+ }
1878
1990
 
1879
- super(jsonSchema)
1991
+ function unwrapNullableAnyOfIndex(schema: JsonSchema): number {
1992
+ if (schema.anyOf?.length !== 2) return -1
1993
+ const nullIndex = schema.anyOf.findIndex(s => s.type === 'null')
1994
+ return nullIndex
1995
+ }
1880
1996
 
1881
- if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`)
1882
- if (opt?.msg) this.setErrorMessage('enum', opt.msg)
1997
+ // ==== Utility helpers ====
1998
+
1999
+ function addPropertiesToSchema(schema: JsonSchema, props: AnyObject): void {
2000
+ const properties: Record<string, JsonSchema> = {}
2001
+ const required: string[] = []
2002
+
2003
+ for (const [key, builder] of Object.entries(props)) {
2004
+ const isOptional = (builder as JSchema<any, any>).getSchema().optionalField
2005
+ if (!isOptional) {
2006
+ required.push(key)
2007
+ }
2008
+
2009
+ const builtSchema = builder.build()
2010
+ properties[key] = builtSchema
1883
2011
  }
1884
2012
 
1885
- branded<B extends OUT>(): JsonSchemaEnumBuilder<B, Opt> {
1886
- return this as unknown as JsonSchemaEnumBuilder<B, Opt>
2013
+ schema.properties = properties
2014
+ schema.required = _uniq(required).sort()
2015
+ }
2016
+
2017
+ function hasNoObjectSchemas(schema: JsonSchema): boolean {
2018
+ if (Array.isArray(schema.type)) {
2019
+ return schema.type.every(type =>
2020
+ ['string', 'number', 'integer', 'boolean', 'null'].includes(type),
2021
+ )
2022
+ } else if (schema.anyOf) {
2023
+ return schema.anyOf.every(hasNoObjectSchemas)
2024
+ } else if (schema.oneOf) {
2025
+ return schema.oneOf.every(hasNoObjectSchemas)
2026
+ } else if (schema.enum) {
2027
+ return true
2028
+ } else if (schema.type === 'array') {
2029
+ return !schema.items || hasNoObjectSchemas(schema.items)
2030
+ } else {
2031
+ return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
1887
2032
  }
2033
+
2034
+ return false
1888
2035
  }
1889
2036
 
1890
- export class JsonSchemaTupleBuilder<
1891
- ITEMS extends JsonSchemaAnyBuilder<any, any>[],
1892
- > extends JsonSchemaAnyBuilder<TupleOut<ITEMS>, false> {
1893
- constructor(items: ITEMS) {
1894
- super({
1895
- type: 'array',
1896
- prefixItems: items.map(i => i.build()),
1897
- minItems: items.length,
1898
- maxItems: items.length,
1899
- })
2037
+ type EnumBaseType = 'string' | 'number' | 'other'
2038
+
2039
+ /**
2040
+ * Deep copy that preserves functions in customValidations/customConversions.
2041
+ * Unlike structuredClone, this handles function references (which only exist in those two properties).
2042
+ */
2043
+ function deepCopyPreservingFunctions<T>(obj: T): T {
2044
+ if (obj === null || typeof obj !== 'object') return obj
2045
+ if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
2046
+ const copy = {} as T
2047
+ for (const key of Object.keys(obj)) {
2048
+ const value = (obj as any)[key]
2049
+ // customValidations/customConversions are arrays of functions - shallow copy the array
2050
+ ;(copy as any)[key] =
2051
+ (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
2052
+ ? [...value]
2053
+ : deepCopyPreservingFunctions(value)
1900
2054
  }
2055
+ return copy
1901
2056
  }
1902
2057
 
1903
- export class JsonSchemaAnyOfByBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1904
- constructor(
1905
- propertyName: string,
1906
- schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1907
- ) {
1908
- const builtSchemaDictionary: Record<string, JsonSchema> = {}
1909
- for (const [key, schema] of Object.entries(schemaDictionary)) {
1910
- builtSchemaDictionary[key] = schema.build()
1911
- }
2058
+ // ==== Types & Interfaces ====
1912
2059
 
1913
- super({
1914
- type: 'object',
1915
- hasIsOfTypeCheck: true,
1916
- anyOfBy: {
1917
- propertyName,
1918
- schemaDictionary: builtSchemaDictionary,
1919
- },
1920
- })
1921
- }
2060
+ export interface AjvValidationOptions {
2061
+ /**
2062
+ * Custom Ajv instance to use for this validation.
2063
+ * Overrides the default Ajv or any Ajv set at construction time.
2064
+ * Compiled functions are cached per Ajv instance.
2065
+ */
2066
+ ajv?: Ajv
2067
+
2068
+ /**
2069
+ * Defaults to true,
2070
+ * because that's how AJV works by default,
2071
+ * and what gives it performance advantage.
2072
+ * (Because we have found that deep-clone is surprisingly slow,
2073
+ * nearly as slow as Joi validation).
2074
+ *
2075
+ * If set to true - AJV will mutate the input in case it needs to apply transformations
2076
+ * (strip unknown properties, convert types, etc).
2077
+ *
2078
+ * If false - it will deep-clone (using JSON.stringify+parse) the input to prevent its mutation.
2079
+ * Will return the cloned/mutated object.
2080
+ * Please note that JSON.stringify+parse has side-effects,
2081
+ * e.g it will transform Buffer into a weird object.
2082
+ */
2083
+ mutateInput?: boolean
2084
+ inputName?: string
2085
+ inputId?: string
2086
+ /**
2087
+ * Function that returns "original input".
2088
+ * What is original input?
2089
+ * It's an input in its original non-mutated form.
2090
+ * Why is it needed?
2091
+ * Because we mutates the Input here. And after its been mutated - we no longer
2092
+ * can include it "how it was" in an error message. So, for that reason we'll use
2093
+ * `getOriginalInput()`, if it's provided.
2094
+ */
2095
+ getOriginalInput?: () => unknown
1922
2096
  }
1923
2097
 
1924
- export class JsonSchemaAnyOfTheseBuilder<OUT> extends JsonSchemaAnyBuilder<OUT, false> {
1925
- constructor(
1926
- propertyName: string,
1927
- schemaDictionary: Record<PropertyKey, JsonSchemaTerminal<any, any>>,
1928
- ) {
1929
- const builtSchemaDictionary: Record<string, JsonSchema> = {}
1930
- for (const [key, schema] of Object.entries(schemaDictionary)) {
1931
- builtSchemaDictionary[key] = schema.build()
1932
- }
2098
+ export interface AjvSchemaCfg {
2099
+ /**
2100
+ * Pass Ajv instance, otherwise Ajv will be created with
2101
+ * AjvSchema default (not the same as Ajv defaults) parameters
2102
+ */
2103
+ ajv: Ajv
1933
2104
 
1934
- super({
1935
- type: 'object',
1936
- hasIsOfTypeCheck: true,
1937
- anyOfBy: {
1938
- propertyName,
1939
- schemaDictionary: builtSchemaDictionary,
1940
- },
1941
- })
1942
- }
2105
+ inputName?: string
2106
+
2107
+ /**
2108
+ * If true - schema will be compiled on-demand (lazily).
2109
+ * Default: false.
2110
+ */
2111
+ lazy?: boolean
1943
2112
  }
1944
2113
 
1945
- type EnumBaseType = 'string' | 'number' | 'other'
2114
+ export type SchemaHandledByAjv<OUT> = JSchema<OUT, any> | JsonSchema<OUT> | AjvSchema<OUT>
1946
2115
 
1947
2116
  export interface JsonSchema<OUT = unknown> {
1948
2117
  readonly out?: OUT
@@ -1951,7 +2120,6 @@ export interface JsonSchema<OUT = unknown> {
1951
2120
  $id?: string
1952
2121
  title?: string
1953
2122
  description?: string
1954
- // $comment?: string
1955
2123
  deprecated?: boolean
1956
2124
  readOnly?: boolean
1957
2125
  writeOnly?: boolean
@@ -2032,177 +2200,9 @@ export interface JsonSchema<OUT = unknown> {
2032
2200
  postValidation?: PostValidatonFn<any, OUT>
2033
2201
  }
2034
2202
 
2035
- function object(props: AnyObject): never
2036
- function object<OUT extends AnyObject>(props: {
2037
- [K in keyof Required<OUT>]-?: JsonSchemaTerminal<OUT[K], any>
2038
- }): JsonSchemaObjectBuilder<OUT, false>
2039
-
2040
- function object<OUT extends AnyObject>(props: {
2041
- [key in keyof OUT]: JsonSchemaTerminal<OUT[key], any>
2042
- }): JsonSchemaObjectBuilder<OUT, false> {
2043
- return new JsonSchemaObjectBuilder<OUT, false>(props)
2044
- }
2045
-
2046
- function objectInfer<P extends Record<string, JsonSchemaAnyBuilder<any, any>>>(
2047
- props: P,
2048
- ): JsonSchemaObjectInferringBuilder<P, false> {
2049
- return new JsonSchemaObjectInferringBuilder<P, false>(props)
2050
- }
2051
-
2052
- function objectDbEntity(props: AnyObject): never
2053
- function objectDbEntity<
2054
- OUT extends BaseDBEntity,
2055
- EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude<
2056
- keyof OUT,
2057
- keyof BaseDBEntity
2058
- >,
2059
- >(
2060
- props: {
2061
- // ✅ all non-system fields must be explicitly provided
2062
- [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]>
2063
- } &
2064
- // ✅ if `id` differs, it's required
2065
- (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true
2066
- ? { id?: BuilderFor<BaseDBEntity['id']> }
2067
- : { id: BuilderFor<OUT['id']> }) &
2068
- (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true
2069
- ? { created?: BuilderFor<BaseDBEntity['created']> }
2070
- : { created: BuilderFor<OUT['created']> }) &
2071
- (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true
2072
- ? { updated?: BuilderFor<BaseDBEntity['updated']> }
2073
- : { updated: BuilderFor<OUT['updated']> }),
2074
- ): JsonSchemaObjectBuilder<OUT, false>
2075
-
2076
- function objectDbEntity(props: AnyObject): any {
2077
- return j.object({
2078
- id: j.string(),
2079
- created: j.number().unixTimestamp2000(),
2080
- updated: j.number().unixTimestamp2000(),
2081
- ...props,
2082
- })
2083
- }
2084
-
2085
- function record<
2086
- KS extends JsonSchemaAnyBuilder<any, any>,
2087
- VS extends JsonSchemaAnyBuilder<any, any>,
2088
- Opt extends boolean = SchemaOpt<VS>,
2089
- >(
2090
- keySchema: KS,
2091
- valueSchema: VS,
2092
- ): JsonSchemaObjectBuilder<
2093
- Opt extends true
2094
- ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2095
- : Record<SchemaOut<KS>, SchemaOut<VS>>,
2096
- false
2097
- > {
2098
- const keyJsonSchema = keySchema.build()
2099
- // Check if value schema is optional before build() strips the optionalField flag
2100
- const isValueOptional = (valueSchema as JsonSchemaTerminal<any, any>).getSchema().optionalField
2101
- const valueJsonSchema = valueSchema.build()
2102
-
2103
- // When value schema is optional, wrap in anyOf to allow undefined values
2104
- const finalValueSchema: JsonSchema = isValueOptional
2105
- ? { anyOf: [{ isUndefined: true }, valueJsonSchema] }
2106
- : valueJsonSchema
2107
-
2108
- return new JsonSchemaObjectBuilder<
2109
- Opt extends true
2110
- ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>>
2111
- : Record<SchemaOut<KS>, SchemaOut<VS>>,
2112
- false
2113
- >([], {
2114
- hasIsOfTypeCheck: false,
2115
- keySchema: keyJsonSchema,
2116
- patternProperties: {
2117
- ['^.*$']: finalValueSchema,
2118
- },
2119
- })
2120
- }
2121
-
2122
- function withRegexKeys<S extends JsonSchemaAnyBuilder<any, any>>(
2123
- keyRegex: RegExp | string,
2124
- schema: S,
2125
- ): JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false> {
2126
- if (keyRegex instanceof RegExp) {
2127
- _assert(
2128
- !keyRegex.flags,
2129
- `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`,
2130
- )
2131
- }
2132
- const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex
2133
- const jsonSchema = schema.build()
2134
-
2135
- return new JsonSchemaObjectBuilder<StringMap<SchemaOut<S>>, false>([], {
2136
- hasIsOfTypeCheck: false,
2137
- patternProperties: {
2138
- [pattern]: jsonSchema,
2139
- },
2140
- })
2141
- }
2142
-
2143
- /**
2144
- * Builds the object schema with the indicated `keys` and uses the `schema` for their validation.
2145
- */
2146
- function withEnumKeys<
2147
- const T extends readonly (string | number)[] | StringEnum | NumberEnum,
2148
- S extends JsonSchemaAnyBuilder<any, any>,
2149
- K extends string | number = EnumKeyUnion<T>,
2150
- Opt extends boolean = SchemaOpt<S>,
2151
- >(
2152
- keys: T,
2153
- schema: S,
2154
- ): JsonSchemaObjectBuilder<
2155
- Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2156
- false
2157
- > {
2158
- let enumValues: readonly (string | number)[] | undefined
2159
- if (Array.isArray(keys)) {
2160
- _assert(
2161
- isEveryItemPrimitive(keys),
2162
- 'Every item in the key list should be string, number or symbol',
2163
- )
2164
- enumValues = keys
2165
- } else if (typeof keys === 'object') {
2166
- const enumType = getEnumType(keys)
2167
- _assert(
2168
- enumType === 'NumberEnum' || enumType === 'StringEnum',
2169
- 'The key list should be StringEnum or NumberEnum',
2170
- )
2171
- if (enumType === 'NumberEnum') {
2172
- enumValues = _numberEnumValues(keys as NumberEnum)
2173
- } else if (enumType === 'StringEnum') {
2174
- enumValues = _stringEnumValues(keys as StringEnum)
2175
- }
2176
- }
2177
-
2178
- _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum')
2179
-
2180
- const typedValues = enumValues as readonly K[]
2181
- const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any
2182
-
2183
- return new JsonSchemaObjectBuilder<
2184
- Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> },
2185
- false
2186
- >(props, { hasIsOfTypeCheck: false })
2187
- }
2188
-
2189
- function hasNoObjectSchemas(schema: JsonSchema): boolean {
2190
- if (Array.isArray(schema.type)) {
2191
- schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type))
2192
- } else if (schema.anyOf) {
2193
- return schema.anyOf.every(hasNoObjectSchemas)
2194
- } else if (schema.oneOf) {
2195
- return schema.oneOf.every(hasNoObjectSchemas)
2196
- } else if (schema.enum) {
2197
- return true
2198
- } else if (schema.type === 'array') {
2199
- return !schema.items || hasNoObjectSchemas(schema.items)
2200
- } else {
2201
- return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type)
2202
- }
2203
-
2204
- return false
2205
- }
2203
+ export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
2204
+ export type CustomValidatorFn = (v: any) => string | undefined
2205
+ export type CustomConverterFn<OUT> = (v: any) => OUT
2206
2206
 
2207
2207
  type Expand<T> = { [K in keyof T]: T[K] }
2208
2208
 
@@ -2255,17 +2255,17 @@ type ExactMatch<A, B> =
2255
2255
  ? true
2256
2256
  : ExactMatchBase<Expand<StripIndexSignatureDeep<A>>, Expand<StripIndexSignatureDeep<B>>>
2257
2257
 
2258
- type BuilderOutUnion<B extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2259
- [K in keyof B]: B[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2258
+ type BuilderOutUnion<B extends readonly JBuilder<any, any>[]> = {
2259
+ [K in keyof B]: B[K] extends JBuilder<infer O, any> ? O : never
2260
2260
  }[number]
2261
2261
 
2262
- type AnyOfByOut<D extends Record<PropertyKey, JsonSchemaTerminal<any, any>>> = {
2263
- [K in keyof D]: D[K] extends JsonSchemaTerminal<infer O, any> ? O : never
2262
+ type AnyOfByOut<D extends Record<PropertyKey, JSchema<any, any>>> = {
2263
+ [K in keyof D]: D[K] extends JSchema<infer O, any> ? O : never
2264
2264
  }[keyof D]
2265
2265
 
2266
- type BuilderFor<T> = JsonSchemaAnyBuilder<T, any>
2266
+ type BuilderFor<T> = JBuilder<T, any>
2267
2267
 
2268
- interface JsonBuilderRuleOpt {
2268
+ export interface JsonBuilderRuleOpt {
2269
2269
  /**
2270
2270
  * Text of error message to return when the validation fails for the given rule:
2271
2271
  *
@@ -2289,33 +2289,9 @@ type EnumKeyUnion<T> =
2289
2289
  ? T[keyof T]
2290
2290
  : never
2291
2291
 
2292
- type SchemaOut<S> = S extends JsonSchemaAnyBuilder<infer OUT, any> ? OUT : never
2293
- type SchemaOpt<S> =
2294
- S extends JsonSchemaAnyBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
2295
-
2296
- type TupleOut<T extends readonly JsonSchemaAnyBuilder<any, any>[]> = {
2297
- [K in keyof T]: T[K] extends JsonSchemaAnyBuilder<infer O, any> ? O : never
2298
- }
2299
-
2300
- export type PostValidatonFn<OUT, OUT2> = (v: OUT) => OUT2
2301
- export type CustomValidatorFn = (v: any) => string | undefined
2302
- export type CustomConverterFn<OUT> = (v: any) => OUT
2292
+ type SchemaOut<S> = S extends JBuilder<infer OUT, any> ? OUT : never
2293
+ type SchemaOpt<S> = S extends JBuilder<any, infer Opt> ? (Opt extends true ? true : false) : false
2303
2294
 
2304
- /**
2305
- * Deep copy that preserves functions in customValidations/customConversions.
2306
- * Unlike structuredClone, this handles function references (which only exist in those two properties).
2307
- */
2308
- function deepCopyPreservingFunctions<T>(obj: T): T {
2309
- if (obj === null || typeof obj !== 'object') return obj
2310
- if (Array.isArray(obj)) return obj.map(deepCopyPreservingFunctions) as T
2311
- const copy = {} as T
2312
- for (const key of Object.keys(obj)) {
2313
- const value = (obj as any)[key]
2314
- // customValidations/customConversions are arrays of functions - shallow copy the array
2315
- ;(copy as any)[key] =
2316
- (key === 'customValidations' || key === 'customConversions') && Array.isArray(value)
2317
- ? [...value]
2318
- : deepCopyPreservingFunctions(value)
2319
- }
2320
- return copy
2295
+ type TupleOut<T extends readonly JBuilder<any, any>[]> = {
2296
+ [K in keyof T]: T[K] extends JBuilder<infer O, any> ? O : never
2321
2297
  }