@jarenjs/validate 0.9.2 → 0.34.2
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.
- package/ARCHITECTURE.md +81 -17
- package/README.md +462 -5
- package/dist/types/dollar-data.d.ts +0 -9
- package/dist/types/index.d.ts +167 -69
- package/dist/types/messages.d.ts +142 -0
- package/dist/types/normalize.d.ts +107 -0
- package/dist/types/tools.d.ts +58 -0
- package/docs/ERROR-MESSAGES.md +251 -0
- package/package.json +9 -4
- package/src/array.js +68 -23
- package/src/bigint.js +18 -7
- package/src/combine.js +61 -11
- package/src/condition.js +34 -14
- package/src/content.js +3 -3
- package/src/data.js +11 -387
- package/src/dollar-data.js +55 -472
- package/src/enum.js +0 -1
- package/src/format.js +46 -4
- package/src/index.js +280 -238
- package/src/messages.js +497 -0
- package/src/normalize.js +585 -0
- package/src/number.js +19 -9
- package/src/object.js +126 -33
- package/src/query.js +28 -2
- package/src/schema.js +72 -27
- package/src/string.js +18 -6
- package/src/tools.js +192 -0
- package/src/traverse.js +13 -4
- package/src/unevaluated.js +27 -5
package/ARCHITECTURE.md
CHANGED
|
@@ -27,6 +27,7 @@ The validator embraces these architectural principles:
|
|
|
27
27
|
| `packages/validate/src/index.js` | Main validator classes (`JarenValidator`, `ValidationRoot`, `ValidationObject`) |
|
|
28
28
|
| `packages/validate/src/traverse.js` | Schema traversal and ref resolution (`storeSchemaIdsInMap`, `restoreSchemaRefsInMap`) |
|
|
29
29
|
| `packages/validate/src/schema.js` | Schema compilation dispatcher (`compileSchemaObject`) |
|
|
30
|
+
| `packages/validate/src/messages.js` | Error conversion, message catalogs, the `errorMessage` keyword specs (`ValidationError`, `messagesEn`, `localizeErrors`) |
|
|
30
31
|
| `packages/validate/src/array.js` | Array validation logic |
|
|
31
32
|
| `packages/validate/src/object.js` | Object validation logic |
|
|
32
33
|
| `packages/validate/src/string.js` | String validation logic |
|
|
@@ -84,17 +85,30 @@ The validator operates in four distinct phases that cleanly separate concerns:
|
|
|
84
85
|
**Entry Point**: `JarenValidator.addSchema(schema, key)`
|
|
85
86
|
|
|
86
87
|
**Flow**:
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
```mermaid
|
|
89
|
+
flowchart TD
|
|
90
|
+
A["addSchema(schema, key)"]
|
|
91
|
+
B["store schema under key<br/>(and the alt key with/without #)"]
|
|
92
|
+
C["#traverseAndStoreIds(baseUri, schema)"]
|
|
93
|
+
D["storeSchemaIdsInMap(schemasMap, baseUri, schema)"]
|
|
94
|
+
E["store the root schema under baseUri"]
|
|
95
|
+
F{"BFS over the schema structure"}
|
|
96
|
+
G["$id — store subschema, update baseUri"]
|
|
97
|
+
H["$anchor — store anchor"]
|
|
98
|
+
I["$ref — store a null placeholder, marked as a ref"]
|
|
99
|
+
J["objects / arrays — keep traversing"]
|
|
100
|
+
A --> B
|
|
101
|
+
A --> C
|
|
102
|
+
C --> D
|
|
103
|
+
D --> E
|
|
104
|
+
D --> F
|
|
105
|
+
F --> G
|
|
106
|
+
F --> H
|
|
107
|
+
F --> I
|
|
108
|
+
F --> J
|
|
109
|
+
N["the placeholder is what lets refs<br/>be added in any order"]
|
|
110
|
+
I -.- N
|
|
111
|
+
class N note
|
|
98
112
|
```
|
|
99
113
|
|
|
100
114
|
**Key Data Structure**: `#schemas Map<string, schema|null>`
|
|
@@ -349,6 +363,16 @@ The compilation context that:
|
|
|
349
363
|
- Collects validation errors
|
|
350
364
|
- Provides `$ref` resolution services
|
|
351
365
|
|
|
366
|
+
**Owner threading**: the constructor takes a sixth argument `owner`
|
|
367
|
+
(default `null`), exposed through an `owner` getter, and both construction
|
|
368
|
+
sites in `index.js` pass the owning `JarenValidator` for it. This lets
|
|
369
|
+
`query-keyword.js` hand that instance to `createTypeTestCompiler`, so the
|
|
370
|
+
schema literals inside a `$query` document (`$valid`/`$assert`/`$as`)
|
|
371
|
+
resolve their `$ref`s against the owner's `addSchema` registrations. The
|
|
372
|
+
resulting ESM cycle — `index.js` → `schema.js` → `query-keyword.js` →
|
|
373
|
+
`query.js` → `index.js` — is benign: `query.js` only *reads* the hoisted
|
|
374
|
+
`JarenValidator` declaration at call time, never at module-evaluation time.
|
|
375
|
+
|
|
352
376
|
### ValidationObject
|
|
353
377
|
|
|
354
378
|
Represents a single schema location with its compiled validator:
|
|
@@ -576,6 +600,14 @@ flowchart TB
|
|
|
576
600
|
B5 --> C
|
|
577
601
|
```
|
|
578
602
|
|
|
603
|
+
**Compile-failure fallback**: the ref compilers of `@jarenjs/json`
|
|
604
|
+
(`compileDataRef` for the `data` keyword, `compileRelativeJSONPointer` for
|
|
605
|
+
`$data`) *throw* on a malformed reference. `data.js` and `dollar-data.js`
|
|
606
|
+
each wrap the compile in a `compileRefResolver` try/catch that falls back to
|
|
607
|
+
an always-`JSONPOINTER_NOTHING` resolver. A malformed `$data`/`data`
|
|
608
|
+
reference therefore validates as "not found" — the keyword asserts nothing —
|
|
609
|
+
instead of throwing at compile time, keeping the lax keyword semantics.
|
|
610
|
+
|
|
579
611
|
---
|
|
580
612
|
|
|
581
613
|
## Error Handling Architecture
|
|
@@ -593,10 +625,10 @@ flowchart LR
|
|
|
593
625
|
F --> G[All validators complete]
|
|
594
626
|
G --> H{collectErrors?}
|
|
595
627
|
|
|
596
|
-
H -->|true| I[
|
|
628
|
+
H -->|true| I[convertInternalErrors - messages.js]
|
|
597
629
|
H -->|false| J[Return boolean only]
|
|
598
630
|
|
|
599
|
-
I --> K[
|
|
631
|
+
I --> K[Extract params, resolve msgid,<br/>match errorMessage registry,<br/>render through catalog]
|
|
600
632
|
K --> L[Return {valid, errors}]
|
|
601
633
|
|
|
602
634
|
style C fill:#9f9
|
|
@@ -604,6 +636,29 @@ flowchart LR
|
|
|
604
636
|
style L fill:#9f9
|
|
605
637
|
```
|
|
606
638
|
|
|
639
|
+
### Report-Time Messages (messages.js)
|
|
640
|
+
|
|
641
|
+
Conversion is structured-first, render-late (the normative spec is
|
|
642
|
+
[docs/ERROR-MESSAGES.md](./docs/ERROR-MESSAGES.md)):
|
|
643
|
+
|
|
644
|
+
- Every public `ValidationError` carries a stable `msgid` (the matched
|
|
645
|
+
`errorMessage` spec's `$msgid`, else the `$query` runtime code, else the
|
|
646
|
+
keyword) plus raw `params`; `instancePath` is a straight read of the
|
|
647
|
+
first meta argument every handler call site passes (the
|
|
648
|
+
handler-contract invariant), behind a charCode guard that yields `''`
|
|
649
|
+
rather than ever a wrong path.
|
|
650
|
+
- The `errorMessage` keyword compiles at schema compile time into a
|
|
651
|
+
registry on `ValidationRoot` (`registerErrorMessage`) — no validator
|
|
652
|
+
closure is emitted and the single-keyword fast paths stay eligible (the
|
|
653
|
+
key count excludes it). Matching happens only over the failed set:
|
|
654
|
+
nearest registered ancestor by segment-aware prefix; map-form entries
|
|
655
|
+
and `_` apply at the node itself, the string form covers the subtree.
|
|
656
|
+
- Human text renders through catalogs — plain objects of closures /
|
|
657
|
+
template strings (`messagesEn` built in; packs in `@jarenjs/locales`).
|
|
658
|
+
`localizeErrors(errors, catalog)` re-renders post hoc from
|
|
659
|
+
`msgid` + `params`; the `messages: false` option skips rendering
|
|
660
|
+
entirely (`message: ''`).
|
|
661
|
+
|
|
607
662
|
### Error Handler Creation
|
|
608
663
|
|
|
609
664
|
```mermaid
|
|
@@ -973,8 +1028,8 @@ console.log('Resolving ref:', ref, 'against baseUri:', baseUri);
|
|
|
973
1028
|
|
|
974
1029
|
For broader context on how this package fits into the JarenJS ecosystem:
|
|
975
1030
|
|
|
976
|
-
- **Project-wide Architecture**: See
|
|
977
|
-
- **Developer Guide**: See
|
|
1031
|
+
- **Project-wide Architecture**: See `docs/ARCHITECTURE.md`
|
|
1032
|
+
- **Developer Guide**: See `docs/HOWTO.md`
|
|
978
1033
|
- **Core Package**: Depends on `@jarenjs/core` for fundamental utilities and `@jarenjs/json` for the JSON addressing standards
|
|
979
1034
|
|
|
980
1035
|
---
|
|
@@ -983,7 +1038,8 @@ For broader context on how this package fits into the JarenJS ecosystem:
|
|
|
983
1038
|
|
|
984
1039
|
| File | Purpose | Key Exports |
|
|
985
1040
|
|------|---------|-------------|
|
|
986
|
-
| `index.js` | Public API | `JarenValidator`, `ValidationOptions`, `ValidatorOptions
|
|
1041
|
+
| `index.js` | Public API | `JarenValidator`, `ValidationOptions`, `ValidatorOptions` |
|
|
1042
|
+
| `messages.js` | Error conversion & i18n | `ValidationError`, `convertInternalErrors`, `messagesEn`, `compileMessageTemplate`, `compileMessageCatalog`, `renderErrorMessage`, `localizeErrors`, `compileErrorMessageSpec` |
|
|
987
1043
|
| `schema.js` | Schema compilation | `compileSchemaObject` |
|
|
988
1044
|
| `traverse.js` | Schema traversal | `TraverseOptions`, `storeSchemaIdsInMap`, `resolveRefSchemaDeep` |
|
|
989
1045
|
| `tools.js` | Shared utilities | `isBoolOrObjectClass`, `hasSchemaRef`, `createIsSchemaTypeHandler` |
|
|
@@ -1001,6 +1057,7 @@ For broader context on how this package fits into the JarenJS ecosystem:
|
|
|
1001
1057
|
| `dollar-data.js` | $data keyword | `compileDollarDataSchema` |
|
|
1002
1058
|
| `unevaluated.js` | unevaluated* keywords | `wrapUnevaluated` |
|
|
1003
1059
|
| `dynamic-ref.js` | Dynamic scope helpers | `collectDynamicAnchorsDeep`, `hasRecursiveAnchor`, `getDynamicAnchorName` |
|
|
1060
|
+
| `query-keyword.js` | `$query` extension keyword | `compileQuerySchema` |
|
|
1004
1061
|
|
|
1005
1062
|
---
|
|
1006
1063
|
|
|
@@ -1028,12 +1085,19 @@ For broader context on how this package fits into the JarenJS ecosystem:
|
|
|
1028
1085
|
|
|
1029
1086
|
### 3. Lazy Error Generation
|
|
1030
1087
|
|
|
1031
|
-
**Decision**: Only create error objects when `skipErrors` is false
|
|
1088
|
+
**Decision**: Only create error objects when `skipErrors` is false, and
|
|
1089
|
+
only produce human-readable text at report time (`convertInternalErrors`
|
|
1090
|
+
in messages.js), over the already-failed set, from a `msgid` + `params`
|
|
1091
|
+
pair through a message catalog.
|
|
1032
1092
|
|
|
1033
1093
|
**Rationale**:
|
|
1034
1094
|
- Most production use cases only need boolean results
|
|
1035
1095
|
- Error object creation is expensive
|
|
1036
1096
|
- Reduces GC pressure during high-throughput validation
|
|
1097
|
+
- Structured-first errors make locale a report-time choice: switching
|
|
1098
|
+
language (`localizeErrors`, `@jarenjs/locales`) never recompiles a
|
|
1099
|
+
validator, and the `errorMessage` keyword resolves against a registry
|
|
1100
|
+
with zero validation-time cost (see docs/ERROR-MESSAGES.md)
|
|
1037
1101
|
|
|
1038
1102
|
### 4. Dual Data Reference Systems
|
|
1039
1103
|
|
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ See also:
|
|
|
193
193
|
|
|
194
194
|
Both absolute JSON Pointers (e.g., `/A`, `/limits/min`) and relative JSON Pointers (e.g., `0/parent`, `1/sibling`) are supported.
|
|
195
195
|
|
|
196
|
-
- `$data` | Ajv-style instance references (`{ "minimum": { "$data": "1/limit" } }`) — supports every keyword the `data` list above supports, plus `uniqueItems` and `required`. Refs compile once at schema compile time through the compiled pointer engine of `@jarenjs/json
|
|
196
|
+
- `$data` | Ajv-style instance references (`{ "minimum": { "$data": "1/limit" } }`) — supports every keyword the `data` list above supports, plus `uniqueItems` and `required`. Takes the same three reference forms: empty for the whole instance, an absolute JSON Pointer (`/limit`), or a Relative JSON Pointer (`1/limit`). Refs compile once at schema compile time through the compiled pointer engine of `@jarenjs/json`, and a reference that cannot be compiled **fails the compile** rather than quietly resolving to nothing — a constraint that disables itself is worse than a rejected schema.
|
|
197
197
|
|
|
198
198
|
- `$query` | Jaren's cross-field assertion keyword — see [below](#query--cross-field-assertions)
|
|
199
199
|
|
|
@@ -202,7 +202,7 @@ See also:
|
|
|
202
202
|
- ❌ strict
|
|
203
203
|
- ❌ strictFormat
|
|
204
204
|
- ❌ strictTuple
|
|
205
|
-
-
|
|
205
|
+
- errorMessage | author-supplied messages that override text (never structure) — see [Error messages & i18n](#error-messages--i18n)
|
|
206
206
|
- definitions | used by initial schema traversal _deprecated in `draft2019`_
|
|
207
207
|
- $defs | used by initial schema traversal _new `draft2019`_
|
|
208
208
|
- components | _(OpenAPI)_
|
|
@@ -323,7 +323,13 @@ Semantics and composition:
|
|
|
323
323
|
keyword: under `properties`/`items` the query's `$` is that location's
|
|
324
324
|
value, `$path` its pointer (`/lines/0`, ...), `$root` the whole document.
|
|
325
325
|
- A bare JSONPath string is the degenerate query: `{ "$query": "$.approved" }`
|
|
326
|
-
asserts the EBV of that member
|
|
326
|
+
asserts the **EBV** of that member, not its mere existence: existence would
|
|
327
|
+
let `$.approved` pass on a literal `false`, exactly the case the constraint
|
|
328
|
+
means to reject (missing → empty sequence → false either way).
|
|
329
|
+
- `$query` is a **validation keyword**, so under 2019-09 and 2020-12 it
|
|
330
|
+
asserts as a sibling of `$ref` (both apply together). Under draft-07 the
|
|
331
|
+
`$ref`-overrides-siblings rule stands, so a `$query` written beside a `$ref`
|
|
332
|
+
is ignored on that node.
|
|
327
333
|
- Query **runtime** errors (`JQ2xxx` — e.g. arithmetic on a non-number, the
|
|
328
334
|
EBV of a multi-item result) are validation **failures**, never throws; in
|
|
329
335
|
`collectErrors` mode the error params carry the `code` and the query
|
|
@@ -331,10 +337,461 @@ Semantics and composition:
|
|
|
331
337
|
`root`/`path` fail fast at `compile()`.
|
|
332
338
|
- Schema literals inside the query (`$valid`/`$assert`/`$as`,
|
|
333
339
|
QUERY-FORMAT §8.11) compile against the **same validator instance**, so
|
|
334
|
-
their `$ref`s resolve to your `addSchema` registrations.
|
|
340
|
+
their `$ref`s resolve to your `addSchema` registrations. The bridge
|
|
341
|
+
(`createTypeTestCompiler`) accepts nothing (a fresh default instance), a
|
|
342
|
+
`JarenValidator` instance, or a zero-arg factory, and probes the compiled
|
|
343
|
+
validator's return shape **once per schema literal** — so even a
|
|
344
|
+
`collectErrors` instance is unwrapped into a boolean predicate.
|
|
345
|
+
|
|
346
|
+
## Compatibility settings
|
|
347
|
+
|
|
348
|
+
Five options decide answers that differ between validators, between JSON
|
|
349
|
+
Schema drafts, or between Jaren and the library you are migrating from. Each
|
|
350
|
+
one is a deliberate default, and each one is worth setting explicitly in a
|
|
351
|
+
shared factory rather than inheriting.
|
|
352
|
+
|
|
353
|
+
| Option | Default | What it decides |
|
|
354
|
+
|---|---|---|
|
|
355
|
+
| `collectErrors` | `false` | Whether the compiled validator returns a boolean or `{ valid, errors }` |
|
|
356
|
+
| `skipErrors` | `!collectErrors` | Whether validation stops at the first failure |
|
|
357
|
+
| `useGrapheme` | **`true`** | Whether `minLength`/`maxLength` count grapheme clusters or UTF-16 code units |
|
|
358
|
+
| `formatAssertion` | auto by draft | Whether `format` asserts or only annotates |
|
|
359
|
+
| `contentValidation` | auto by draft | Whether `contentEncoding`/`contentMediaType` assert |
|
|
360
|
+
|
|
361
|
+
### The return shape is `collectErrors`
|
|
362
|
+
|
|
363
|
+
`collectErrors` is the *only* switch between the two return shapes, and it is
|
|
364
|
+
off by default:
|
|
365
|
+
|
|
366
|
+
```javascript
|
|
367
|
+
new JarenValidator().compile(schema)(data);
|
|
368
|
+
// => true | false
|
|
369
|
+
|
|
370
|
+
new JarenValidator({ collectErrors: true }).compile(schema)(data);
|
|
371
|
+
// => { valid: false, errors: [ /* ValidationError */ ] }
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Setting `collectErrors: true` implies `skipErrors: false` (collecting errors
|
|
375
|
+
means recording them), so you do not need to set both. Set `skipErrors`
|
|
376
|
+
yourself only to keep first-failure short-circuiting while still collecting.
|
|
377
|
+
|
|
378
|
+
There is **no `validator.errors` property**. Errors arrive in the returned
|
|
379
|
+
object and nowhere else, which is what makes a compiled validator reentrant
|
|
380
|
+
and safe to share across concurrent requests.
|
|
381
|
+
|
|
382
|
+
Each `ValidationError` carries six fields:
|
|
383
|
+
|
|
384
|
+
```javascript
|
|
385
|
+
{
|
|
386
|
+
keyword: 'format', // the JSON Schema keyword that failed
|
|
387
|
+
instancePath: '/email', // RFC 6901 JSON Pointer into the DATA
|
|
388
|
+
schemaPath: 'https://…#/properties/email', // absolute URI into the SCHEMA
|
|
389
|
+
params: { format: 'email' }, // raw structured values, never prose
|
|
390
|
+
msgid: 'format', // stable catalog key for i18n
|
|
391
|
+
message: 'must match format "email"'
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Two notes for anyone diffing this against another validator's output. The
|
|
396
|
+
data location is `instancePath`, a **JSON Pointer string** — not the dotted
|
|
397
|
+
`dataPath` of Ajv v6, and not an array path. `parseJSONPointerPath` from
|
|
398
|
+
[`@jarenjs/json`](../json/README.md) converts it to the `(string|number)[]`
|
|
399
|
+
path shape that Zod's `issue.path` and most diffing tools use, narrowing
|
|
400
|
+
canonical array indexes to numbers:
|
|
401
|
+
|
|
402
|
+
```javascript
|
|
403
|
+
import { parseJSONPointerPath } from '@jarenjs/json';
|
|
404
|
+
|
|
405
|
+
parseJSONPointerPath('/items/0/id'); // ['items', 0, 'id']
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
And for `additionalProperties: false`, Jaren points `instancePath` at the
|
|
409
|
+
offending member (`/nested/extra`) where Ajv points at the parent object —
|
|
410
|
+
deliberate, and spec-truer.
|
|
411
|
+
|
|
412
|
+
**Collected errors are exhaustive across independent keywords.** Every
|
|
413
|
+
keyword that can fail independently reports its own fault, rather than the
|
|
414
|
+
first failure hiding the rest: keyword groups on one node (`enum` beside
|
|
415
|
+
`minLength`), `minProperties` beside `required`, a numeric bound beside
|
|
416
|
+
`multipleOf`, `minItems` beside `uniqueItems` and beside failing items, every
|
|
417
|
+
`allOf` branch, independent applicator groups, and every absent `required`
|
|
418
|
+
property. Boolean mode still stops at the first failure — that is the whole
|
|
419
|
+
point of it — so the two modes deliberately differ in how much work they do:
|
|
420
|
+
|
|
421
|
+
```javascript
|
|
422
|
+
const contract = {
|
|
423
|
+
type: 'object',
|
|
424
|
+
properties: { slug: { type: 'string', minLength: 3, pattern: '^[a-z]+$' } },
|
|
425
|
+
required: ['name', 'slug'],
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
collecting.compile(contract)({ slug: '!' }).errors;
|
|
429
|
+
// required at '', minLength at '/slug', pattern at '/slug' — three issues
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
**A speculative applicator never reports.** An `anyOf`/`oneOf` branch that was
|
|
433
|
+
not the one that matched, the subschema of a `not`, an `if` condition, and a
|
|
434
|
+
`contains` candidate that is not the match are all probes — the document was
|
|
435
|
+
never required to satisfy them, so their failures are rolled back rather than
|
|
436
|
+
handed to your caller. What survives is what genuinely explains the failure:
|
|
437
|
+
if *no* `anyOf` branch matched, every branch's errors are kept, because then
|
|
438
|
+
they are the reason.
|
|
439
|
+
|
|
440
|
+
```javascript
|
|
441
|
+
collecting.compile({
|
|
442
|
+
type: 'string', minLength: 5,
|
|
443
|
+
anyOf: [{ type: 'number' }, { type: 'string' }],
|
|
444
|
+
})('x').errors;
|
|
445
|
+
// [minLength] — not a type error from the number branch that was never required
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
A `required` error points at the **owning object**, not at the absent member
|
|
449
|
+
(there is no location for something that is not there); the missing name is in
|
|
450
|
+
`params.missingProperty`, which is what an adapter appends to build a
|
|
451
|
+
Zod-style path.
|
|
452
|
+
|
|
453
|
+
### String lengths count graphemes by default
|
|
454
|
+
|
|
455
|
+
`useGrapheme` defaults to **`true`**, so `minLength`/`maxLength` count
|
|
456
|
+
user-perceived characters. Most other validators — and the JSON Schema
|
|
457
|
+
specification itself — count UTF-16 code units:
|
|
458
|
+
|
|
459
|
+
```javascript
|
|
460
|
+
const family = '👨👩👧👦'; // 1 grapheme cluster, 11 UTF-16 code units
|
|
461
|
+
|
|
462
|
+
new JarenValidator().compile({ type: 'string', maxLength: 2 })(family);
|
|
463
|
+
// => true (1 grapheme)
|
|
464
|
+
|
|
465
|
+
new JarenValidator({ useGrapheme: false })
|
|
466
|
+
.compile({ type: 'string', maxLength: 2 })(family);
|
|
467
|
+
// => false (11 code units)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**If you are migrating from a validator that counts code units, set
|
|
471
|
+
`useGrapheme: false`,** or strings containing emoji, combining marks, flags
|
|
472
|
+
or astral-plane characters will silently change validity at the boundaries.
|
|
473
|
+
Grapheme mode is not expensive — ASCII takes a `str.length` fast path and
|
|
474
|
+
most Unicode takes a code-point count; only cluster-forming strings reach
|
|
475
|
+
`Intl.Segmenter` — so the default is about correctness, not speed, and
|
|
476
|
+
switching to it later is a product decision rather than a performance one.
|
|
477
|
+
|
|
478
|
+
### `format` and content assertion follow the draft
|
|
479
|
+
|
|
480
|
+
Both are annotation-only in the drafts that say so, and both take an explicit
|
|
481
|
+
override:
|
|
482
|
+
|
|
483
|
+
- **`format`** asserts through draft 2019-09 and is annotation-only from
|
|
484
|
+
draft 2020-12 on, per spec. It also turns on automatically when the
|
|
485
|
+
schema's meta-schema declares the `format-assertion` vocabulary.
|
|
486
|
+
- **`contentEncoding`/`contentMediaType`** assert through draft-07 and are
|
|
487
|
+
annotation-only from 2019-09 on.
|
|
488
|
+
|
|
489
|
+
```javascript
|
|
490
|
+
const schema = { $schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
491
|
+
type: 'string', format: 'email' };
|
|
492
|
+
|
|
493
|
+
new JarenValidator().addFormats(formats.stringFormats)
|
|
494
|
+
.compile(schema)('nope'); // => true (annotation only)
|
|
495
|
+
|
|
496
|
+
new JarenValidator({ formatAssertion: true }).addFormats(formats.stringFormats)
|
|
497
|
+
.compile(schema)('nope'); // => false (asserted)
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Formats are never registered implicitly — and an unregistered one is an error
|
|
501
|
+
|
|
502
|
+
`@jarenjs/validate` does not depend on `@jarenjs/formats`, so a fresh
|
|
503
|
+
validator has an **empty format registry**. Registering the groups you use is
|
|
504
|
+
the whole setup:
|
|
505
|
+
|
|
506
|
+
```javascript
|
|
507
|
+
import * as formats from '@jarenjs/formats';
|
|
508
|
+
|
|
509
|
+
const jaren = new JarenValidator()
|
|
510
|
+
.addFormats(formats.stringFormats) // email, uri, uuid, hostname, iban, ...
|
|
511
|
+
.addFormats(formats.dateTimeFormats) // date-time, date, time, duration
|
|
512
|
+
.addFormats(formats.numberFormats) // int32, double, decimal, ...
|
|
513
|
+
.addFormats(formats.jsonFormats) // json-pointer, json-path, regex, ...
|
|
514
|
+
.addFormats(formats.geoFormats); // geohash, lat-long, ...
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
**An unregistered format name asserts nothing, and by default says nothing** —
|
|
518
|
+
that is the specification's rule ("implementations MUST NOT fail validation or
|
|
519
|
+
cease processing due to an unknown format attribute"), and it is the default
|
|
520
|
+
because a library has to be able to compile a schema it did not write. The
|
|
521
|
+
cost is a real trap: a `format: 'date-time'` you believed was checking
|
|
522
|
+
timestamps, checking nothing, and looking exactly like one that works.
|
|
523
|
+
|
|
524
|
+
**`unknownFormats: 'error'` turns that into a compile-time throw.** Set it for
|
|
525
|
+
schemas you own:
|
|
526
|
+
|
|
527
|
+
```javascript
|
|
528
|
+
new JarenValidator({ unknownFormats: 'error' })
|
|
529
|
+
.compile({ type: 'string', format: 'date-time' });
|
|
530
|
+
// => Error: Unknown format 'date-time': no compiler is registered for it,
|
|
531
|
+
// so this schema would accept every value for that keyword. Register one
|
|
532
|
+
// (addFormats(dateTimeFormats) …), or pass { unknownFormats: 'ignore' } …
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Instance validation is untouched either way — **no data is ever invalidated by
|
|
536
|
+
an unknown format**, so `'error'` stays inside the specification's letter: it
|
|
537
|
+
reports to the schema's *author*, at compile time, where a missing
|
|
538
|
+
registration is a fact about the setup rather than about the data. The
|
|
539
|
+
official suite's `optional/format/unknown.json` (unknown format, assertion
|
|
540
|
+
forced on, everything must validate) passes on the default and is the one
|
|
541
|
+
thing `'error'` would fail, which is why it is opt-in.
|
|
542
|
+
|
|
543
|
+
This repository sets `'error'` for every schema it ships, and a gate
|
|
544
|
+
(`test/validate/our-schema-formats.test.js`) fails if any of them names a
|
|
545
|
+
format `@jarenjs/formats` does not implement. It is worth copying: the
|
|
546
|
+
published `jaren-query` grammar declared `format: "json-path"` from the day it
|
|
547
|
+
was written, nothing that compiled it registered `jsonFormats`, and so no path
|
|
548
|
+
string was ever checked by that keyword. Nothing failed. That is the shape of
|
|
549
|
+
this bug.
|
|
550
|
+
|
|
551
|
+
Where **not** to reach for `'error'`: a schema a user hands you. Refusing to
|
|
552
|
+
compile a stranger's valid schema over a format you happen not to implement is
|
|
553
|
+
your problem presented as theirs — this repo keeps the default for the
|
|
554
|
+
validator playground's input and for project files in the studio, and uses
|
|
555
|
+
`'error'` only for its own grammars. `@jarenjs/ai`'s ledger is the other
|
|
556
|
+
exception: it cannot depend on `@jarenjs/formats` at all, so it stays on the
|
|
557
|
+
default and puts a `pattern` beside the `format` to do the enforcing.
|
|
558
|
+
|
|
559
|
+
Two things `'error'` deliberately leaves alone. It does not fire where
|
|
560
|
+
`format` is annotation-only anyway (draft 2020-12 without the format-assertion
|
|
561
|
+
vocabulary) — nothing is lost there, so there is nothing to report. And
|
|
562
|
+
**meta-schemas** are exempt: every JSON Schema meta-schema declares
|
|
563
|
+
`format: "uri-reference"` on `$id`, and nobody registers formats in order to
|
|
564
|
+
check that a *schema* is well-formed, so `addMetaSchema` compiles with
|
|
565
|
+
`unknownFormats: 'ignore'` regardless.
|
|
566
|
+
|
|
567
|
+
Registering a format with something that is **not a compiler** throws under
|
|
568
|
+
either setting, because that mistake is never intentional:
|
|
569
|
+
|
|
570
|
+
```javascript
|
|
571
|
+
new JarenValidator().addFormats(formats.formatTesters) // testers, not compilers
|
|
572
|
+
.compile({ type: 'string', format: 'email' });
|
|
573
|
+
// => Error: Format 'email' failed to compile: … Register the format COMPILERS
|
|
574
|
+
// (stringFormats, dateTimeFormats, …) rather than the raw testers.
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
`formatTesters` and `stringFormats` are both objects full of functions, so the
|
|
578
|
+
mistake registers cleanly and then checks nothing. Only the **compilers** take
|
|
579
|
+
`(schemaObj, jsonSchema)` and return the per-value validator.
|
|
580
|
+
|
|
581
|
+
Registration never overwrites an existing name, so register your own
|
|
582
|
+
overrides *before* a bundled group if you want them to win.
|
|
583
|
+
|
|
584
|
+
### Recipe: migrating from Zod
|
|
585
|
+
|
|
586
|
+
Zod counts UTF-16 code units, always reports every issue, and always
|
|
587
|
+
asserts formats. This factory reproduces those three answers:
|
|
588
|
+
|
|
589
|
+
```javascript
|
|
590
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
591
|
+
import * as formats from '@jarenjs/formats';
|
|
592
|
+
|
|
593
|
+
export const jaren = new JarenValidator({
|
|
594
|
+
collectErrors: true, // { valid, errors } instead of a boolean
|
|
595
|
+
useGrapheme: false, // UTF-16 code units, like Zod's .min()/.max()
|
|
596
|
+
formatAssertion: true, // assert format even under draft 2020-12
|
|
597
|
+
contentValidation: true, // assert contentEncoding/contentMediaType
|
|
598
|
+
})
|
|
599
|
+
.addFormats(formats.stringFormats)
|
|
600
|
+
.addFormats(formats.dateTimeFormats);
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
What this recipe does **not** give you is Zod's output normalization: a
|
|
604
|
+
compiled validator is a pure predicate and never modifies its input. That
|
|
605
|
+
boundary does not move — but the normalization itself ships beside it, as a
|
|
606
|
+
separately compiled pass. See [Normalization](#normalization) below.
|
|
607
|
+
|
|
608
|
+
## Normalization
|
|
609
|
+
|
|
610
|
+
Validation answers a question; it does not change your data. When you need
|
|
611
|
+
the *normalized output* that a parse-and-transform library returns —
|
|
612
|
+
materialized defaults, decoded transport values, stripped unknown members —
|
|
613
|
+
compile a normalizer from the same schema and run it first:
|
|
614
|
+
|
|
615
|
+
```javascript
|
|
616
|
+
import { compileNormalizer } from '@jarenjs/validate/normalize';
|
|
617
|
+
|
|
618
|
+
const normalize = compileNormalizer(schema, {
|
|
619
|
+
useDefaults: true, // fill absent properties from `default`, recursively
|
|
620
|
+
removeAdditional: true, // drop members the schema forbids
|
|
621
|
+
coerceTypes: true, // '9000' -> 9000 where the schema says integer
|
|
622
|
+
trimStrings: true, // ' jaren ' -> 'jaren'
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const shaped = normalize(input); // a NEW value; `input` is untouched
|
|
626
|
+
const result = validate(shaped);
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Every option is **off by default** — each one changes what your data means,
|
|
630
|
+
so each is a decision you make rather than one you inherit.
|
|
631
|
+
`compileNormalizer(schema)` with no options is the identity.
|
|
632
|
+
|
|
633
|
+
### Per-field control, not just a global switch
|
|
634
|
+
|
|
635
|
+
`useDefaults`, `coerceTypes` and `trimStrings` each accept a **predicate**
|
|
636
|
+
`(schemaNode) => boolean` in place of a boolean. It runs once per node during
|
|
637
|
+
compilation, so it decides per field and costs nothing at runtime:
|
|
638
|
+
|
|
639
|
+
```javascript
|
|
640
|
+
const normalize = compileNormalizer(schema, {
|
|
641
|
+
trimStrings: (node) => node['x-trim'] === true,
|
|
642
|
+
});
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
This matters more than it looks. A contract typically trims a handful of its
|
|
646
|
+
string fields and must leave the rest byte-for-byte as supplied — a timezone
|
|
647
|
+
name, a deliberately padded identifier, a field whose whitespace is data.
|
|
648
|
+
`trimStrings: true` would quietly rewrite all of them. The predicate reads
|
|
649
|
+
whatever you put in the schema (an `x-` annotation, a `format`, a name
|
|
650
|
+
pattern), which keeps the policy next to the field it governs and portable
|
|
651
|
+
with the schema.
|
|
652
|
+
|
|
653
|
+
### It never mutates, and it shares what it can
|
|
654
|
+
|
|
655
|
+
Ajv's `useDefaults`/`coerceTypes` write into the document you hand them.
|
|
656
|
+
This does not: it is a copy-on-write walk, so the input is exactly as it was
|
|
657
|
+
afterwards — you can normalize a frozen document, or keep the original as an
|
|
658
|
+
audit record, without defensive copying.
|
|
659
|
+
|
|
660
|
+
The other half of that design is identity. A subtree that needs no change is
|
|
661
|
+
returned by reference, and a document that needs no change at all returns the
|
|
662
|
+
input itself:
|
|
663
|
+
|
|
664
|
+
```javascript
|
|
665
|
+
normalize(alreadyClean) === alreadyClean; // true
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
So a no-op costs nothing, and downstream memoization keyed on identity keeps
|
|
669
|
+
working.
|
|
670
|
+
|
|
671
|
+
### What it walks, and what it deliberately does not
|
|
672
|
+
|
|
673
|
+
Walked: `properties`, `patternProperties`, `additionalProperties`,
|
|
674
|
+
`items`/`prefixItems`/`additionalItems` (both tuple spellings), same-document
|
|
675
|
+
`$ref` including recursive ones, and `allOf` — whose branches compose, with
|
|
676
|
+
stripping disabled inside them because one branch cannot know what a sibling
|
|
677
|
+
declares.
|
|
678
|
+
|
|
679
|
+
Member schemas **compose the way JSON Schema says they do**: a member covered
|
|
680
|
+
by `properties` *and* by one or more matching `patternProperties` is
|
|
681
|
+
normalized by every one of them, in that order, and `additionalProperties`
|
|
682
|
+
applies only to a member nothing else covered. A materialized `default` runs
|
|
683
|
+
through its own property's normalizer too, so a defaulted `{ port: '8080' }`
|
|
684
|
+
is shaped exactly like a supplied one rather than keeping its string.
|
|
685
|
+
|
|
686
|
+
Not walked: `anyOf`, `oneOf`, `if`/`then`/`else`, `not`. Which branch applies
|
|
687
|
+
is only known after validating, and normalizing under one branch can change
|
|
688
|
+
which branch validates — so guessing would be worse than declining. There is
|
|
689
|
+
also no transform hook: an arbitrary transform is application code, not
|
|
690
|
+
schema semantics, and belongs on your side of the boundary.
|
|
691
|
+
|
|
692
|
+
### The coercion table
|
|
693
|
+
|
|
694
|
+
Coercion exists to decode transport encodings — query strings, form fields,
|
|
695
|
+
environment variables, CSV cells — where everything arrives as a string. It
|
|
696
|
+
is conservative on purpose: a value it cannot convert unambiguously is passed
|
|
697
|
+
through unchanged, so validation reports the type error instead of the
|
|
698
|
+
normalizer hiding it.
|
|
699
|
+
|
|
700
|
+
| Declared `type` | Converted | Left alone |
|
|
701
|
+
|---|---|---|
|
|
702
|
+
| `number` | a string that is exactly a JSON number (`'1e5'`, `'-0.5'`) | `'0x10'`, `'1_000'`, `''`, `'Infinity'` |
|
|
703
|
+
| `integer` | as `number`, when the result is integral | `'4.5'` |
|
|
704
|
+
| `boolean` | `'true'`, `'false'` | `'yes'`, `'1'`, `0` |
|
|
705
|
+
| `string` | finite numbers and booleans | objects, arrays, `null` |
|
|
706
|
+
| `null` | `'null'` | `''`, `0`, `false` |
|
|
707
|
+
|
|
708
|
+
A union `type` (`['string', 'number']`) gives no single target, so coercion is
|
|
709
|
+
skipped rather than guessed. Trimming runs before coercion, so `' 42 '`
|
|
710
|
+
decodes for an integer field.
|
|
711
|
+
|
|
712
|
+
## Error messages & i18n
|
|
713
|
+
|
|
714
|
+
Every collected error carries a stable message key (`msgid`) and raw
|
|
715
|
+
structured `params` next to its rendered `message` — prose is produced at
|
|
716
|
+
report time from a **catalog** (a plain object of closures), never on the
|
|
717
|
+
validation hot path. The normative spec is
|
|
718
|
+
[ERROR-MESSAGES.md](./docs/ERROR-MESSAGES.md).
|
|
719
|
+
|
|
720
|
+
### The `errorMessage` keyword
|
|
721
|
+
|
|
722
|
+
Author-supplied messages that override *text*, never structure (no error
|
|
723
|
+
aggregation or removal — the deliberate divergence from ajv-errors, whose
|
|
724
|
+
two official plugins are mutually incompatible). Registered at schema
|
|
725
|
+
compile time, resolved only over the failed set at report time — zero
|
|
726
|
+
validation-time cost.
|
|
727
|
+
|
|
728
|
+
```javascript
|
|
729
|
+
// string form: covers the node AND its subtree (quiet oneOf noise)
|
|
730
|
+
{ "type": "string", "minLength": 8, "errorMessage": "Use at least 8 characters" }
|
|
731
|
+
|
|
732
|
+
// map form: per failing keyword, '_' as the node catch-all
|
|
733
|
+
{ "type": "integer", "minimum": 18,
|
|
734
|
+
"errorMessage": { "minimum": "Must be an adult", "_": "Invalid age" } }
|
|
735
|
+
|
|
736
|
+
// required: per missing property
|
|
737
|
+
{ "required": ["vatId", "name"],
|
|
738
|
+
"errorMessage": { "required": { "vatId": "VAT id is required for business accounts" } } }
|
|
739
|
+
|
|
740
|
+
// $query: EBV-false default and per runtime code
|
|
741
|
+
{ "$query": { "$le": ["$.start", "$.end"] },
|
|
742
|
+
"errorMessage": { "$query": {
|
|
743
|
+
"default": "start must not be after end",
|
|
744
|
+
"JQ2003": "start/end must be single values" } } }
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Templates interpolate params: `"errorMessage": "needs {limit} characters"`.
|
|
748
|
+
|
|
749
|
+
### `$msgid` — translatable authored messages
|
|
750
|
+
|
|
751
|
+
An `errorMessage` (or forms `x-form.message`) may be a **MessageSpec**
|
|
752
|
+
object pointing into the catalog space instead of inline text — that keeps
|
|
753
|
+
schema-authored messages translatable:
|
|
754
|
+
|
|
755
|
+
```javascript
|
|
756
|
+
{ "type": "number",
|
|
757
|
+
"errorMessage": { "type": {
|
|
758
|
+
"$msgid": "checkout.total-invalid", // catalog key
|
|
759
|
+
"message": "Total must be a number" // fallback when no catalog covers it
|
|
760
|
+
} } }
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Locale packs
|
|
764
|
+
|
|
765
|
+
```javascript
|
|
766
|
+
import { JarenValidator, compileMessageCatalog, localizeErrors } from '@jarenjs/validate';
|
|
767
|
+
import { nl } from '@jarenjs/locales';
|
|
768
|
+
|
|
769
|
+
const catalog = compileMessageCatalog(nl);
|
|
770
|
+
const validator = new JarenValidator({ collectErrors: true });
|
|
771
|
+
const validate = validator.compile({ type: 'string', minLength: 2 });
|
|
772
|
+
|
|
773
|
+
const result = validate('x'); // English messages
|
|
774
|
+
localizeErrors(result.errors, catalog); // Dutch, re-rendered from msgid + params
|
|
775
|
+
// 'mag niet minder dan 2 tekens bevatten'
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
The locale is chosen at **report time**; switching locale never recompiles
|
|
779
|
+
anything. Catalog entries are plain functions, so packs use the platform's
|
|
780
|
+
`Intl.PluralRules`/`Intl.NumberFormat` — see
|
|
781
|
+
[`@jarenjs/locales`](../locales/README.md) for the pack-authoring guide.
|
|
782
|
+
|
|
783
|
+
### `messages: false`
|
|
784
|
+
|
|
785
|
+
For applications that render exclusively through `localizeErrors` (or
|
|
786
|
+
their own resolver), skip English rendering entirely:
|
|
787
|
+
|
|
788
|
+
```javascript
|
|
789
|
+
const validator = new JarenValidator({ collectErrors: true, messages: false });
|
|
790
|
+
// errors arrive with message: '', params and msgid still set
|
|
791
|
+
```
|
|
335
792
|
|
|
336
793
|
## Development
|
|
337
794
|
|
|
338
795
|
Unit tests live in `test/validate/` at the repository root (`npm run test:validate`). Performance against Ajv is measured over the official test suite with the [benchmark workspace](../../benchmark/README.md) (`node benchmark/profiler.js --profile-all`), which also houses the test-failure debugger, coverage and call-graph tools.
|
|
339
796
|
|
|
340
|
-
This package's internals — the four-phase compile pipeline, ref flattening, annotation tracking, dynamic scope — are described in its own [ARCHITECTURE](./ARCHITECTURE.md) document. For practical usage recipes (options, lightweight setups, custom formats, pitfalls) see the repository [HOWTO](../../HOWTO.md); for the monorepo picture see the repository [README](../../README.md) and [ARCHITECTURE](../../ARCHITECTURE.md); for what is planned next see the [ROADMAP](../../ROADMAP.md).
|
|
797
|
+
This package's internals — the four-phase compile pipeline, ref flattening, annotation tracking, dynamic scope — are described in its own [ARCHITECTURE](./ARCHITECTURE.md) document. For practical usage recipes (options, lightweight setups, custom formats, pitfalls) see the repository [HOWTO](../../docs/HOWTO.md); for the monorepo picture see the repository [README](../../README.md) and [ARCHITECTURE](../../docs/ARCHITECTURE.md); for what is planned next see the [ROADMAP](../../docs/ROADMAP.md).
|