@jarenjs/linq 0.49.2 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
# The Jaren schema pen
|
|
2
|
+
|
|
3
|
+
> `./schema` — JSON Schema 2020-12: the structural keywords, the
|
|
4
|
+
> constraints and the annotations, each with a method of its own, plus
|
|
5
|
+
> `$query`, `$defs`/`$ref` recursion and the normalizer's per-field
|
|
6
|
+
> predicates. **Read it when** you are describing the shape of data —
|
|
7
|
+
> for validation, for a form, or as the base of an entity
|
|
8
|
+
|
|
9
|
+
Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
|
|
10
|
+
interpreted as described in RFC 2119. This document is a **guide** — read
|
|
11
|
+
it in order and you can write the format — whose one normative section is
|
|
12
|
+
[§2 The mapping table](#2-the-mapping-table); the rules every pen keeps,
|
|
13
|
+
the shared refusal table, the index of the other pens and every pen's
|
|
14
|
+
mapping table collected in one place are the normative reference,
|
|
15
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md).
|
|
16
|
+
|
|
17
|
+
## 1. What it writes
|
|
18
|
+
|
|
19
|
+
You have a JSON Schema to write — to validate a request, to generate a
|
|
20
|
+
form, or as the base of a store's entity — and you would rather write it
|
|
21
|
+
in code, where the editor completes the keyword, the compiler knows the
|
|
22
|
+
shape, and a member you rename is renamed everywhere at once. That is
|
|
23
|
+
what this pen is for. It is not a schema library with a JSON exporter
|
|
24
|
+
bolted on: the document IS the deliverable, and every method on this
|
|
25
|
+
surface exists because some keyword of the format needs a spelling.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import * as s from '@jarenjs/linq/schema';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
builds JSON Schema 2020-12 documents: the structural keywords, the
|
|
32
|
+
constraints and the annotations, each with a method of its own; `$query`
|
|
33
|
+
captured through the chain's proxy; `$defs`/`$ref` recursion; the
|
|
34
|
+
normalizer's per-field predicates. Twenty-five further keywords that
|
|
35
|
+
`@jarenjs/validate` also compiles have no method on this surface and are
|
|
36
|
+
written through `.keyword()` or `from()` instead —
|
|
37
|
+
[§6.2](#62-the-absences-twenty-five-keywords-with-no-method) lists them
|
|
38
|
+
by family. The type reading is
|
|
39
|
+
emit's (EMIT-FORMAT §5–§7), because the agreement pins them equal; a
|
|
40
|
+
constraint (`min`, `pattern`, `format`) never changes a type — the honest
|
|
41
|
+
widening emit documents — with one addition: a string with
|
|
42
|
+
`format: 'date-time'` or `'date'` is the `DateTime` brand, so the chain's
|
|
43
|
+
date operators light up on a chain over the pen's shape.
|
|
44
|
+
|
|
45
|
+
Four things are worth naming before the tables, because the rest of this
|
|
46
|
+
document assumes them:
|
|
47
|
+
|
|
48
|
+
- **The format is standard.** The document a builder emits is JSON Schema
|
|
49
|
+
2020-12 and nothing else, valid under the published meta-schema — which
|
|
50
|
+
`test/linq/schema-pen.test.js` asserts for every corpus entry, over the
|
|
51
|
+
draft `@jarenjs/refs` carries. Three keyword families ride beside it,
|
|
52
|
+
each already part of the suite's own vocabulary and each ignored by a
|
|
53
|
+
validator that does not know it: `$query` (a cross-field rule, the
|
|
54
|
+
query language of `packages/json/docs/QUERY-FORMAT.md`),
|
|
55
|
+
`errorMessage` (the validator's author-supplied messages, string, map
|
|
56
|
+
or `$msgid` form), and `x-coerce`/`x-trim` (the normalizer's per-field
|
|
57
|
+
predicates).
|
|
58
|
+
- **The engine is somewhere else.** Nothing under
|
|
59
|
+
`packages/linq/src/schema/` imports `@jarenjs/validate`, `@jarenjs/emit`
|
|
60
|
+
or `@jarenjs/db` — a test asserts it file by file. The pen writes a
|
|
61
|
+
document; the validator's compiler stays the only judge of what a
|
|
62
|
+
keyword means. That is what §7's price is made of.
|
|
63
|
+
- **Immutability and identity are the binder's rules, and this pen keeps
|
|
64
|
+
them.** Every method answers a new builder, `.schema` assembles once
|
|
65
|
+
and memoizes, and a `named()` builder is one `$defs` entry however many
|
|
66
|
+
places reach it — stated in full, for every pen, in
|
|
67
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.2.
|
|
68
|
+
|
|
69
|
+
One complete round trip — build, read the document, compile it, run it:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import * as s from '@jarenjs/linq/schema';
|
|
73
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
74
|
+
|
|
75
|
+
const User = s.object({
|
|
76
|
+
id: s.string().uuid(),
|
|
77
|
+
name: s.string().min(1),
|
|
78
|
+
age: s.integer().min(0).optional(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const validate = new JarenValidator().compile(User.schema);
|
|
82
|
+
validate({ id: '3f1a…', name: 'Ada' }); // true
|
|
83
|
+
validate({ id: '3f1a…', name: '', age: -1 }); // false
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `$query` expressions a `check()` captures are the chain's own, and
|
|
87
|
+
their operators are documented once, in [QUERY-PEN.md](QUERY-PEN.md) §4.
|
|
88
|
+
The rules this pen keeps because every pen keeps them — immutability,
|
|
89
|
+
identity, the name → value map rule, the shared refusal table — are in
|
|
90
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) and are not restated here.
|
|
91
|
+
|
|
92
|
+
**The running example.** `User` above is the document this guide grows.
|
|
93
|
+
Everything from §3 on is one small account service: the stored profile
|
|
94
|
+
first, then the shapes around it — the group tree it belongs to, the
|
|
95
|
+
audit record that names it, the sign-in it produces, what a signup hands
|
|
96
|
+
IN before the normalizer runs, the access level it carries, the write
|
|
97
|
+
bodies an endpoint derives from it, the annotations a generated form
|
|
98
|
+
reads, and the settings file the service loads. §5 then reads the types
|
|
99
|
+
back off the same document. One example in §3 stands outside that story
|
|
100
|
+
deliberately — the cross-field rule the `@jarenjs/validate` README
|
|
101
|
+
publishes as hand-written JSON, kept because a rule whose twin is
|
|
102
|
+
published elsewhere is a rule a reader can check — and it says so where
|
|
103
|
+
it begins.
|
|
104
|
+
|
|
105
|
+
## 2. The mapping table
|
|
106
|
+
|
|
107
|
+
Every name `@jarenjs/linq/schema` exports that a caller writes, and every
|
|
108
|
+
method reachable on a builder it hands back. The eight builder classes,
|
|
109
|
+
the one constant and the one guard it also exports are §5's, because a
|
|
110
|
+
caller meets those through a type annotation, a subclass or an
|
|
111
|
+
`instanceof` narrow rather than by calling one.
|
|
112
|
+
|
|
113
|
+
Status: **native** (emits the named keyword), **emulated** (a composition
|
|
114
|
+
with identical semantics), **refused** (a coded error naming the reason).
|
|
115
|
+
|
|
116
|
+
### 2.1 Primitives, literals and enums
|
|
117
|
+
|
|
118
|
+
The leaves: the five scalar types, the two ways to pin a value to a fixed
|
|
119
|
+
set, the four string formats that carry a date or a time, and the two
|
|
120
|
+
ends of the lattice — `any()`, which admits everything, and `never()`,
|
|
121
|
+
which admits nothing.
|
|
122
|
+
|
|
123
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| `string()` | `{ type: 'string' }` | `string` | native |
|
|
126
|
+
| `number()` | `{ type: 'number' }` | `number` | native |
|
|
127
|
+
| `integer()` | `{ type: 'integer' }` | `number` (integer-ness is a documented widening) | native |
|
|
128
|
+
| number `.int()` | `{ type: 'integer' }` — the same node, retyped; `number().int()` and `integer()` are one document | `number` | native |
|
|
129
|
+
| `boolean()` | `{ type: 'boolean' }` | `boolean` | native |
|
|
130
|
+
| `nil()` | `{ type: 'null' }` | `null` | native |
|
|
131
|
+
| `literal(v)` | `{ const: v }` | the literal | native |
|
|
132
|
+
| `enumOf(values)` | `{ enum: values }` — an UNTYPED enum, any mix of JSON values | the literal union | native; an empty or non-array argument is `JL0101` |
|
|
133
|
+
| string/number `.enumOf(values)` | `enum` beside the `type` — a typed enum (what a store maps to a column); values of another JSON type are `JL0101` | the literal union; with `.coerce()` the `Input` widens by the one source primitive that can reach a member (`1 \| 2 \| 3 \| string`) | native |
|
|
134
|
+
| `datetime()`, `date()` | `{ type: 'string', format: 'date-time' \| 'date' }` | `DateTime` | native |
|
|
135
|
+
| `time()`, `duration()` | `{ type: 'string', format: 'time' \| 'duration' }` | `string` | native |
|
|
136
|
+
| `any()` | `{}` | `unknown` | native |
|
|
137
|
+
| `never()` | `false` | `never` | native; no annotation and no check while `false` IS the document (`JL0102`); `nullable()` lifts both |
|
|
138
|
+
|
|
139
|
+
### 2.2 Objects
|
|
140
|
+
|
|
141
|
+
Everything about a member — whether it is required, whether it may be
|
|
142
|
+
null, whether names the object never declared are allowed — plus the
|
|
143
|
+
methods that derive one object from another rather than declaring it
|
|
144
|
+
afresh.
|
|
145
|
+
|
|
146
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| `object(props)` | `{ type: 'object', properties, required, additionalProperties: false }` — `required` lists every member not `optional()`, in declaration order, and is omitted when empty | a closed object: members required unless `optional()`; no index signature; `object({})` is `Record<string, never>` | native |
|
|
149
|
+
| `.open()` | drops `additionalProperties: false` | `& { [k: string]: unknown }` | native |
|
|
150
|
+
| `.optional()` | the member leaves `required` | `?:` (on both sides; a `default()`ed member is present on `Infer`) | native |
|
|
151
|
+
| `.nullable()` | `type: [t, 'null']` on a typed node; `enum: [..., null]` on `enumOf`/`literal`; `anyOf: [node, { type: 'null' }]` on the rest | `\| null` | native / emulated |
|
|
152
|
+
| `record(values)` | `{ type: 'object', additionalProperties: values }` | `{ [k: string]: V }` | native |
|
|
153
|
+
| `.minProperties(n)`, `.maxProperties(n)` | `minProperties`, `maxProperties` | — | native |
|
|
154
|
+
| `.dependentRequired(map)` | `dependentRequired`, cloned | — | native; anything but a name → array-of-names map is `JL0101` |
|
|
155
|
+
| `.propertyNames(b)` | `propertyNames` | — | native |
|
|
156
|
+
| `.patternProperties(map)` | `patternProperties` | on a closed object the index signature carries the pattern values widened over the members (`[k: string]: V \| members`); on an open one `unknown` | native |
|
|
157
|
+
| `.extend(props)` | the reshaped `properties`/`required` — a later spelling of a name REPLACES the earlier one and moves to the end | the reshaped members | emulated |
|
|
158
|
+
| `.pick(keys)`, `.omit(keys)` | the selected `properties`, in the original order | `Pick<>` / `Omit<>` | emulated; a name the object does not carry is `JL0101` |
|
|
159
|
+
| `.partial()` | every member `optional()`, so `required` disappears | every member `?:` | emulated |
|
|
160
|
+
| `.required(keys?)` | the named members required again; every member when no keys are given | the members no longer `?:` | emulated |
|
|
161
|
+
|
|
162
|
+
### 2.3 Arrays and tuples
|
|
163
|
+
|
|
164
|
+
A homogeneous list and a fixed-position one, with their bounds; the
|
|
165
|
+
difference that matters is that a tuple's tail is open until `rest()`
|
|
166
|
+
closes it.
|
|
167
|
+
|
|
168
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
169
|
+
|---|---|---|---|
|
|
170
|
+
| `array(items)` | `{ type: 'array', items }` | `T[]` | native |
|
|
171
|
+
| array `.min(n)`, `.max(n)`, `.length(n)` | `minItems`, `maxItems`, both | — | native |
|
|
172
|
+
| array `.unique()` | `uniqueItems: true` | — | native |
|
|
173
|
+
| array `.contains(b)` | `contains` | — | native; a normalizer keyword inside it is `JL0102` |
|
|
174
|
+
| `tuple(items)` | `{ type: 'array', prefixItems, minItems: items.length }` | `[A, B, ...unknown[]]` — every position required, the rest open (emit's reading of an omitted `items`) | native |
|
|
175
|
+
| tuple `.rest(b)` | `items: b`; `rest(never())` is `items: false` | `[A, B, ...R[]]`; `[A, B]` | native |
|
|
176
|
+
|
|
177
|
+
### 2.4 Strings
|
|
178
|
+
|
|
179
|
+
The string assertions — a length, a pattern, a named `format` — and the
|
|
180
|
+
three formats common enough to have a method of their own.
|
|
181
|
+
|
|
182
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| string `.min(n)`, `.max(n)`, `.length(n)` | `minLength`, `maxLength`, both | — | native |
|
|
185
|
+
| string `.pattern(p)` | `pattern` (a string, or a flagless `RegExp` by its source) | — | native; flags are `JL0102` |
|
|
186
|
+
| string `.format(f)` | `format` | `DateTime` for `'date-time'`/`'date'`, `string` otherwise | native |
|
|
187
|
+
| string `.email()`, `.uuid()`, `.uri()` | `format: 'email' \| 'uuid' \| 'uri'` | `string` | native |
|
|
188
|
+
|
|
189
|
+
### 2.5 Numbers
|
|
190
|
+
|
|
191
|
+
The numeric bounds, inclusive and exclusive, and the one keyword that
|
|
192
|
+
constrains a number's spacing rather than its range. Retyping a number as
|
|
193
|
+
an integer is `.int()`, in §2.1 beside `integer()` because they are one
|
|
194
|
+
document.
|
|
195
|
+
|
|
196
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
197
|
+
|---|---|---|---|
|
|
198
|
+
| number `.min(n)`, `.max(n)` | `minimum`, `maximum` | — | native |
|
|
199
|
+
| number `.gt(n)`, `.lt(n)` | `exclusiveMinimum`, `exclusiveMaximum` | — | native |
|
|
200
|
+
| number `.multipleOf(n)` | `multipleOf` | — | native; zero or a negative is `JL0101` |
|
|
201
|
+
|
|
202
|
+
### 2.6 Composition
|
|
203
|
+
|
|
204
|
+
The four ways to say "one of these", "all of these" or "this only when
|
|
205
|
+
that" — and which of them the type reading can follow.
|
|
206
|
+
|
|
207
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
208
|
+
|---|---|---|---|
|
|
209
|
+
| `union(options)` | `{ anyOf }` — an option may be a builder or a hand-written JSON Schema (`true`/`false` included), wrapped as `from(json)` | `A \| B` | native |
|
|
210
|
+
| `discriminated(key, options)` | `{ oneOf }` — every option an object declaring `key` as a `literal()`/`enumOf()` member | `A \| B` | native; a missing tag is `JL0102` |
|
|
211
|
+
| `intersection(parts)` | `{ allOf }` | `A & B` | native; a closed object part is `JL0102` (the parts would reject each other's members — `open()` them, or `extend()`) |
|
|
212
|
+
| `when(cond)`, `.then(b)`, `.else(b)` | `{ if, then, else }` | `unknown` (emit records a conditional, never types it) | native |
|
|
213
|
+
|
|
214
|
+
### 2.7 References and `$defs`
|
|
215
|
+
|
|
216
|
+
How a schema is spelled once and reached many times — by value, by name,
|
|
217
|
+
and through a thunk that closes a recursion — plus `from()`, the door a
|
|
218
|
+
hand-written schema comes in by.
|
|
219
|
+
|
|
220
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
221
|
+
|---|---|---|---|
|
|
222
|
+
| `named(name, b)` | `$defs[name]` at the document root, `{ $ref: '#/$defs/name' }` where reached — once, however many places reach it | `Infer<b>` | native; a name outside `[A-Za-z_][A-Za-z0-9_.-]*` is `JL0101`; two distinct builders under one name are `JL0103` |
|
|
223
|
+
| `ref(name)` | `{ $ref: '#/$defs/name' }` | `T` as asserted (`ref<T>`) | native; a name no `named()` in the document answers is `JL0103` |
|
|
224
|
+
| `lazy(() => Named)` | as `named` — the recursion spelling | `T` as annotated on the recursive constant | native; a thunk that is not a function is `JL0101`; an unnamed or non-builder target is `JL0103` |
|
|
225
|
+
| `from(json)` | the JSON, verbatim (cloned, so the document is its own tree) | `T` as asserted (`from<T>`) | native; anything but an object or a boolean is `JL0101` |
|
|
226
|
+
|
|
227
|
+
### 2.8 Annotations and messages
|
|
228
|
+
|
|
229
|
+
What a document says about itself rather than about its data — a title, a
|
|
230
|
+
description, examples — plus the validator's author-supplied error
|
|
231
|
+
messages, the verbatim escape hatch, and the two primitives the four
|
|
232
|
+
named methods are written in terms of.
|
|
233
|
+
|
|
234
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
235
|
+
|---|---|---|---|
|
|
236
|
+
| `.describe(text)`, `.title(text)` | `description`, `title` | — | native |
|
|
237
|
+
| `.example(v)` | one more entry of `examples`, in call order | — | native |
|
|
238
|
+
| `.meta(annotations)` | the keys verbatim, in the order first set | — | native; a pen-owned keyword is `JL0104` |
|
|
239
|
+
| `.message(spec)` | `errorMessage: spec` (the validator's string, map or `$msgid` forms) | — | native |
|
|
240
|
+
| `.annotate(key, value)` | one annotation keyword — the primitive the four above are written in terms of, and the one a subclass overrides | `this` | native; `never()` overrides it to refuse until `nullable()` widens it (`JL0102`) |
|
|
241
|
+
| `.annotation(key)` | nothing: it READS the annotation a builder already carries, or `undefined` — what a subclass consults before it folds one into a keyword it owns | the value as stored | native |
|
|
242
|
+
|
|
243
|
+
Annotations are written after the structural keywords and the
|
|
244
|
+
constraints, in the order they were FIRST set; setting one twice replaces
|
|
245
|
+
the value and keeps the position. Constraint keywords follow the same
|
|
246
|
+
rule through `.keyword()`.
|
|
247
|
+
|
|
248
|
+
### 2.9 Validation extensions
|
|
249
|
+
|
|
250
|
+
Two of the three keyword families §1 names as riding beside standard JSON
|
|
251
|
+
Schema, each ignored by a validator that does not know it: the
|
|
252
|
+
cross-field rule the query language carries, and the normalizer's two
|
|
253
|
+
per-field predicates. The third, `errorMessage`, is §2.8's, because a
|
|
254
|
+
message is something the document says about itself.
|
|
255
|
+
|
|
256
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
257
|
+
|---|---|---|---|
|
|
258
|
+
| `.check(fn)` | `$query`: the callback captured through the chain's proxy — `fn(value, { root, path })`, the value at `$`, the two externals the validator binds; two checks conjoin with `$and` | — (a dropped constraint) | native; another external is `JL0104` |
|
|
259
|
+
| `.check(query)` | `$query`: a query document embedded verbatim | — | native; a value that is not JSON is `JL0101` |
|
|
260
|
+
| `.coerce()` | `'x-coerce': true` on a scalar — the normalizer's per-field predicate | `Input` widens to the transport forms: string `\| number \| boolean`, number/integer `\| string`, boolean `\| string`, null `\| string` | native; on a non-scalar or a nullable, `JL0102` |
|
|
261
|
+
| `.trim()` | `'x-trim': true` on a string | — | native; off a string, `JL0102` |
|
|
262
|
+
|
|
263
|
+
### 2.10 The document, and the builder itself
|
|
264
|
+
|
|
265
|
+
The two ways out of the builder graph — the assembled document, and the
|
|
266
|
+
standalone file with its draft declared — plus the methods that read or
|
|
267
|
+
rebuild a builder rather than adding a keyword to it, and the three
|
|
268
|
+
exports a pen built over this one comes in by.
|
|
269
|
+
|
|
270
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
271
|
+
|---|---|---|---|
|
|
272
|
+
| `document(root, { draft })` | the document, with `$schema` first for `'2020-12'`; without a draft, the root's document unchanged | — | native; another draft, or a draft on a boolean schema, is `JL0102` |
|
|
273
|
+
| `.schema` | the assembled document — a deep-frozen value, computed once and memoized | `JsonSchema \| boolean` | native |
|
|
274
|
+
| `.toJSON()` | the same document, so `JSON.stringify(builder)` is the document | `JsonSchema \| boolean` | native |
|
|
275
|
+
| `.state` | the frozen builder state (kind, children, keywords, annotations) — what a subclass reads, never a document | the state object | native |
|
|
276
|
+
| `.with(patch)` | nothing: a NEW builder of the same class with part of the state replaced. Every method above is written in terms of it, and a subclass keeps its own class through all of them | `this` | native |
|
|
277
|
+
| `.keyword(key, value)` | one constraint keyword, in the order first set | `this` | native |
|
|
278
|
+
| `schemaOf(value)` | nothing: the document of a builder, or the value as given — the one call a consumer needs to accept "a schema, by hand or by pen" | `unknown` | native |
|
|
279
|
+
| `requireJson(value, what)` | nothing: the JSON boundary every value entering a document crosses, exported so a pen built over this one uses the same door | `T` | native; a non-JSON value is `JL0101` |
|
|
280
|
+
| `createFactories(classes)` | nothing: the named factories above, built for one SET of builder classes. `@jarenjs/linq/model` and `@jarenjs/linq/forms` call it with their subclasses, which is why the wiring exists exactly once and no subpath patches another's prototype | the factory record | native |
|
|
281
|
+
|
|
282
|
+
Three rules the tables imply, spelled out:
|
|
283
|
+
|
|
284
|
+
- **A default or coercion lives where the normalizer reaches.**
|
|
285
|
+
`compileNormalizer` does not descend `anyOf`/`oneOf` branches,
|
|
286
|
+
`if`/`then`/`else`, `contains` or `propertyNames`, so a `default()`,
|
|
287
|
+
`coerce()` or `trim()` under one would promise a normalization that
|
|
288
|
+
never happens; the pen refuses it (`JL0102`) at assembly, naming the
|
|
289
|
+
branch and the `docPath`. The same rule is why `coerce()` and
|
|
290
|
+
`nullable()` exclude each other: the normalizer coerces only a
|
|
291
|
+
single-typed scalar.
|
|
292
|
+
- **A rule against the root reads from the root.** `check()`'s `root` is
|
|
293
|
+
an object whose members are the honest top, so a typed member compares
|
|
294
|
+
with it from the root's side — `x.root.currency.eq(l.currency)` — the
|
|
295
|
+
unknown expression takes any operand; the typed one takes its own kind.
|
|
296
|
+
- **A `when()` builder is thenable-shaped.** Its `then()` takes a schema,
|
|
297
|
+
so a promise that resolves one calls it with a function and is refused
|
|
298
|
+
by name (`JL0101`); keep builders out of async return positions.
|
|
299
|
+
|
|
300
|
+
## 3. Worked examples
|
|
301
|
+
|
|
302
|
+
One account service, built up. Every `js` fence below exports exactly one
|
|
303
|
+
builder (or one document), and the `json` fence that follows it is what
|
|
304
|
+
the pen emits — executed by `test/linq/pen-docs.test.js`, which imports
|
|
305
|
+
each fence from the workspace and asserts the document. Read them in
|
|
306
|
+
order and the service assembles: the stored profile, the group tree it
|
|
307
|
+
sits in, the audit record that names it, the sign-in it produces, what a
|
|
308
|
+
signup hands in, the access it carries, the bodies an endpoint derives
|
|
309
|
+
from it, the annotations a form generator reads, and the file the service
|
|
310
|
+
loads at boot.
|
|
311
|
+
|
|
312
|
+
The stored profile — a closed object with the member shapes most
|
|
313
|
+
documents are made of:
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import * as s from '@jarenjs/linq/schema';
|
|
317
|
+
|
|
318
|
+
export const User = s.object({
|
|
319
|
+
id: s.string().uuid(),
|
|
320
|
+
name: s.string().min(1).trim(),
|
|
321
|
+
age: s.integer().min(0).optional(),
|
|
322
|
+
role: s.enumOf(['admin', 'user']).default('user'),
|
|
323
|
+
created: s.datetime(),
|
|
324
|
+
tags: s.array(s.string()).unique().optional(),
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"type": "object",
|
|
331
|
+
"properties": {
|
|
332
|
+
"id": { "type": "string", "format": "uuid" },
|
|
333
|
+
"name": { "type": "string", "minLength": 1, "x-trim": true },
|
|
334
|
+
"age": { "type": "integer", "minimum": 0 },
|
|
335
|
+
"role": { "enum": ["admin", "user"], "default": "user" },
|
|
336
|
+
"created": { "type": "string", "format": "date-time" },
|
|
337
|
+
"tags": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
|
|
338
|
+
},
|
|
339
|
+
"required": ["id", "name", "role", "created"],
|
|
340
|
+
"additionalProperties": false
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`required` is every member not `optional()`, in declaration order — `age`
|
|
345
|
+
and `tags` are out of it, `role` is in it because a `default()` does not
|
|
346
|
+
make a member optional (it makes it optional on the way IN; see §5).
|
|
347
|
+
|
|
348
|
+
**The one example in this section that is not part of the account
|
|
349
|
+
service**, and the reason it is here: the `$query` it emits is the rule
|
|
350
|
+
`packages/validate/README.md` publishes as hand-written JSON — the same
|
|
351
|
+
operators over the same paths — so a reader can compare the two spellings
|
|
352
|
+
of one rule side by side. `check()` is the method this pen exists for,
|
|
353
|
+
and it is worth meeting on a rule whose twin is already published:
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
import * as s from '@jarenjs/linq/schema';
|
|
357
|
+
|
|
358
|
+
export const Invoice = s.object({
|
|
359
|
+
lines: s.array(s.object({ amount: s.number() })),
|
|
360
|
+
total: s.number(),
|
|
361
|
+
}).check((o) => o.total.eq(o.lines.all().amount.sum()));
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"type": "object",
|
|
367
|
+
"properties": {
|
|
368
|
+
"lines": {
|
|
369
|
+
"type": "array",
|
|
370
|
+
"items": {
|
|
371
|
+
"type": "object",
|
|
372
|
+
"properties": { "amount": { "type": "number" } },
|
|
373
|
+
"required": ["amount"],
|
|
374
|
+
"additionalProperties": false
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
"total": { "type": "number" }
|
|
378
|
+
},
|
|
379
|
+
"required": ["lines", "total"],
|
|
380
|
+
"additionalProperties": false,
|
|
381
|
+
"$query": { "$eq": ["$.total", { "$sum": "$.lines[*].amount" }] }
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
The callback is not stored and not called at validation time: it runs
|
|
386
|
+
ONCE, at build, against a recording proxy, and what it leaves behind is
|
|
387
|
+
the `$query` document above. `o.total` records `$.total`; `.all()` on an
|
|
388
|
+
array member records the `[*]` segment; `.sum()` and `.eq()` are the
|
|
389
|
+
query language's `$sum` and `$eq`. That is why a rule can only spell what
|
|
390
|
+
the query language has an operator for, and why an `if` or a `for` in the
|
|
391
|
+
callback would silently capture one branch: build a `union()` or a
|
|
392
|
+
`when()` instead.
|
|
393
|
+
|
|
394
|
+
Back to the service. A user sits in a group, and a group sits in a group
|
|
395
|
+
— a named builder reached again through `lazy()`, hoisted to `$defs`
|
|
396
|
+
once:
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
import * as s from '@jarenjs/linq/schema';
|
|
400
|
+
|
|
401
|
+
export const Group = s.named('Group', s.object({
|
|
402
|
+
name: s.string(),
|
|
403
|
+
children: s.array(s.lazy(() => Group)).optional(),
|
|
404
|
+
}));
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
```json
|
|
408
|
+
{
|
|
409
|
+
"$defs": {
|
|
410
|
+
"Group": {
|
|
411
|
+
"type": "object",
|
|
412
|
+
"properties": {
|
|
413
|
+
"name": { "type": "string" },
|
|
414
|
+
"children": { "type": "array", "items": { "$ref": "#/$defs/Group" } }
|
|
415
|
+
},
|
|
416
|
+
"required": ["name"],
|
|
417
|
+
"additionalProperties": false
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"$ref": "#/$defs/Group"
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
The audit record the service writes on every change reaches ONE
|
|
425
|
+
definition from three places, one of them by name — the identity rule
|
|
426
|
+
made visible. `Uuid` is spelled once and appears in `$defs` once; every
|
|
427
|
+
place that reaches it emits a `$ref`, including `ref('Uuid')`, which
|
|
428
|
+
reaches it by name rather than by value:
|
|
429
|
+
|
|
430
|
+
```js
|
|
431
|
+
import * as s from '@jarenjs/linq/schema';
|
|
432
|
+
|
|
433
|
+
const Uuid = s.named('Uuid', s.string().uuid());
|
|
434
|
+
|
|
435
|
+
export const Audit = s.object({
|
|
436
|
+
id: Uuid,
|
|
437
|
+
actor: Uuid.optional(),
|
|
438
|
+
touched: s.array(s.ref('Uuid')).optional(),
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
```json
|
|
443
|
+
{
|
|
444
|
+
"$defs": {
|
|
445
|
+
"Uuid": { "type": "string", "format": "uuid" }
|
|
446
|
+
},
|
|
447
|
+
"type": "object",
|
|
448
|
+
"properties": {
|
|
449
|
+
"id": { "$ref": "#/$defs/Uuid" },
|
|
450
|
+
"actor": { "$ref": "#/$defs/Uuid" },
|
|
451
|
+
"touched": { "type": "array", "items": { "$ref": "#/$defs/Uuid" } }
|
|
452
|
+
},
|
|
453
|
+
"required": ["id"],
|
|
454
|
+
"additionalProperties": false
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
A sign-in: the factor is a discriminated union, the device may be null,
|
|
459
|
+
and the coordinates are a tuple closed by `never()` so a third number is
|
|
460
|
+
rejected rather than ignored:
|
|
461
|
+
|
|
462
|
+
```js
|
|
463
|
+
import * as s from '@jarenjs/linq/schema';
|
|
464
|
+
|
|
465
|
+
export const Signin = s.object({
|
|
466
|
+
factor: s.discriminated('kind', [
|
|
467
|
+
s.object({ kind: s.literal('password'), rounds: s.number() }),
|
|
468
|
+
s.object({ kind: s.literal('totp'), digits: s.number() }),
|
|
469
|
+
]),
|
|
470
|
+
device: s.string().nullable(),
|
|
471
|
+
at: s.tuple([s.number(), s.number()]).rest(s.never()),
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
```json
|
|
476
|
+
{
|
|
477
|
+
"type": "object",
|
|
478
|
+
"properties": {
|
|
479
|
+
"factor": {
|
|
480
|
+
"oneOf": [
|
|
481
|
+
{
|
|
482
|
+
"type": "object",
|
|
483
|
+
"properties": { "kind": { "const": "password" }, "rounds": { "type": "number" } },
|
|
484
|
+
"required": ["kind", "rounds"],
|
|
485
|
+
"additionalProperties": false
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
"type": "object",
|
|
489
|
+
"properties": { "kind": { "const": "totp" }, "digits": { "type": "number" } },
|
|
490
|
+
"required": ["kind", "digits"],
|
|
491
|
+
"additionalProperties": false
|
|
492
|
+
}
|
|
493
|
+
]
|
|
494
|
+
},
|
|
495
|
+
"device": { "type": ["string", "null"] },
|
|
496
|
+
"at": {
|
|
497
|
+
"type": "array",
|
|
498
|
+
"prefixItems": [{ "type": "number" }, { "type": "number" }],
|
|
499
|
+
"items": false,
|
|
500
|
+
"minItems": 2
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
"required": ["factor", "device", "at"],
|
|
504
|
+
"additionalProperties": false
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
What a signup hands IN is not what the service stores, and this is the
|
|
509
|
+
one example where `Infer<>` and `Input<>` differ, so both are printed:
|
|
510
|
+
|
|
511
|
+
```js
|
|
512
|
+
import * as s from '@jarenjs/linq/schema';
|
|
513
|
+
|
|
514
|
+
// Infer<typeof Signup> = { seats: number; newsletter?: boolean; name: string;
|
|
515
|
+
// discount: number; nickname?: string | null }
|
|
516
|
+
// Input<typeof Signup> = { seats: number | string; newsletter?: boolean | string;
|
|
517
|
+
// name: string; discount?: number | string;
|
|
518
|
+
// nickname?: string | null }
|
|
519
|
+
export const Signup = s.object({
|
|
520
|
+
seats: s.integer().coerce(),
|
|
521
|
+
newsletter: s.boolean().coerce().optional(),
|
|
522
|
+
name: s.string().trim(),
|
|
523
|
+
discount: s.number().coerce().default(1),
|
|
524
|
+
nickname: s.string().nullable().optional(),
|
|
525
|
+
});
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
```json
|
|
529
|
+
{
|
|
530
|
+
"type": "object",
|
|
531
|
+
"properties": {
|
|
532
|
+
"seats": { "type": "integer", "x-coerce": true },
|
|
533
|
+
"newsletter": { "type": "boolean", "x-coerce": true },
|
|
534
|
+
"name": { "type": "string", "x-trim": true },
|
|
535
|
+
"discount": { "type": "number", "x-coerce": true, "default": 1 },
|
|
536
|
+
"nickname": { "type": ["string", "null"] }
|
|
537
|
+
},
|
|
538
|
+
"required": ["seats", "name", "discount"],
|
|
539
|
+
"additionalProperties": false
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
`discount` is in `required` and optional on `Input` — a defaulted member
|
|
544
|
+
is absent on the way in and present on the way out, which is the whole
|
|
545
|
+
point of the two types. `x-coerce` and `x-trim` are not assertions: they
|
|
546
|
+
are predicates the NORMALIZER reads, and a document validated without
|
|
547
|
+
running the normalizer first will reject `{ seats: '3' }`.
|
|
548
|
+
|
|
549
|
+
The access a user carries is a typed enum on both sides of a nullable.
|
|
550
|
+
`null` is folded into the `enum` list rather than added as a union arm,
|
|
551
|
+
because an enum admits exactly what it lists:
|
|
552
|
+
|
|
553
|
+
```js
|
|
554
|
+
import * as s from '@jarenjs/linq/schema';
|
|
555
|
+
|
|
556
|
+
export const Access = s.object({
|
|
557
|
+
level: s.integer().enumOf([1, 2, 3]).coerce(),
|
|
558
|
+
role: s.string().enumOf(['admin', 'user']).nullable().optional(),
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
```json
|
|
563
|
+
{
|
|
564
|
+
"type": "object",
|
|
565
|
+
"properties": {
|
|
566
|
+
"level": { "type": "integer", "enum": [1, 2, 3], "x-coerce": true },
|
|
567
|
+
"role": { "type": ["string", "null"], "enum": ["admin", "user", null] }
|
|
568
|
+
},
|
|
569
|
+
"required": ["level"],
|
|
570
|
+
"additionalProperties": false
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
The bodies the endpoints take are DERIVED from the stored shape rather
|
|
575
|
+
than declared again — which is why §2.2's reshaping methods exist, and
|
|
576
|
+
what their `required` order comes out as. `Account` below is `User`'s
|
|
577
|
+
first three members; `extend()` replaces a name and moves it to the end,
|
|
578
|
+
`omit()` and `pick()` keep the original order, `partial()` empties
|
|
579
|
+
`required` and `required(['id'])` puts one member back:
|
|
580
|
+
|
|
581
|
+
```js
|
|
582
|
+
import * as s from '@jarenjs/linq/schema';
|
|
583
|
+
|
|
584
|
+
const Account = s.object({ id: s.string(), name: s.string(), age: s.integer().optional() });
|
|
585
|
+
|
|
586
|
+
export const Wire = s.object({
|
|
587
|
+
create: Account.extend({ email: s.string() }).omit(['name']).partial().required(['id']),
|
|
588
|
+
summary: Account.pick(['name']).optional(),
|
|
589
|
+
});
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```json
|
|
593
|
+
{
|
|
594
|
+
"type": "object",
|
|
595
|
+
"properties": {
|
|
596
|
+
"create": {
|
|
597
|
+
"type": "object",
|
|
598
|
+
"properties": {
|
|
599
|
+
"id": { "type": "string" },
|
|
600
|
+
"age": { "type": "integer" },
|
|
601
|
+
"email": { "type": "string" }
|
|
602
|
+
},
|
|
603
|
+
"required": ["id"],
|
|
604
|
+
"additionalProperties": false
|
|
605
|
+
},
|
|
606
|
+
"summary": {
|
|
607
|
+
"type": "object",
|
|
608
|
+
"properties": { "name": { "type": "string" } },
|
|
609
|
+
"required": ["name"],
|
|
610
|
+
"additionalProperties": false
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
"required": ["create"],
|
|
614
|
+
"additionalProperties": false
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
The same two members again, annotated for the form generator that reads
|
|
619
|
+
them — in the order the annotations were first set, on a member and on
|
|
620
|
+
the object that holds it:
|
|
621
|
+
|
|
622
|
+
```js
|
|
623
|
+
import * as s from '@jarenjs/linq/schema';
|
|
624
|
+
|
|
625
|
+
export const Profile = s.object({
|
|
626
|
+
id: s.string().describe('The account id').title('Id').example('abc').example('def')
|
|
627
|
+
.meta({ 'x-vendor': { a: 1 }, deprecated: true })
|
|
628
|
+
.message('need an id'),
|
|
629
|
+
age: s.integer().min(18).message({ minimum: 'Must be an adult', _: 'Invalid age' }).optional(),
|
|
630
|
+
}).title('Profile').describe('An account profile');
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
```json
|
|
634
|
+
{
|
|
635
|
+
"type": "object",
|
|
636
|
+
"properties": {
|
|
637
|
+
"id": {
|
|
638
|
+
"type": "string",
|
|
639
|
+
"description": "The account id",
|
|
640
|
+
"title": "Id",
|
|
641
|
+
"examples": ["abc", "def"],
|
|
642
|
+
"x-vendor": { "a": 1 },
|
|
643
|
+
"deprecated": true,
|
|
644
|
+
"errorMessage": "need an id"
|
|
645
|
+
},
|
|
646
|
+
"age": {
|
|
647
|
+
"type": "integer",
|
|
648
|
+
"minimum": 18,
|
|
649
|
+
"errorMessage": { "minimum": "Must be an adult", "_": "Invalid age" }
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
"required": ["id"],
|
|
653
|
+
"additionalProperties": false,
|
|
654
|
+
"title": "Profile",
|
|
655
|
+
"description": "An account profile"
|
|
656
|
+
}
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
`example()` accumulates into one `examples` array; `describe()` twice
|
|
660
|
+
replaces the value and keeps the position; `meta()` writes its keys
|
|
661
|
+
verbatim, which is how `deprecated` — a 2020-12 annotation the pen has no
|
|
662
|
+
method for — and a vendor extension both reach the document.
|
|
663
|
+
|
|
664
|
+
Last, the file the service loads at boot. It is a standalone document
|
|
665
|
+
rather than a member of another — `document()` with the draft declared —
|
|
666
|
+
over an open object extended with a conditional:
|
|
667
|
+
|
|
668
|
+
```js
|
|
669
|
+
import * as s from '@jarenjs/linq/schema';
|
|
670
|
+
|
|
671
|
+
export const Settings = s.document(
|
|
672
|
+
s.intersection([
|
|
673
|
+
s.object({ mode: s.enumOf(['dark', 'light']), accent: s.any() }).open(),
|
|
674
|
+
s.when(s.object({ mode: s.literal('dark') }).open())
|
|
675
|
+
.then(s.object({ accent: s.string() }).open()),
|
|
676
|
+
]),
|
|
677
|
+
{ draft: '2020-12' },
|
|
678
|
+
);
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
```json
|
|
682
|
+
{
|
|
683
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
684
|
+
"allOf": [
|
|
685
|
+
{
|
|
686
|
+
"type": "object",
|
|
687
|
+
"properties": { "mode": { "enum": ["dark", "light"] }, "accent": {} },
|
|
688
|
+
"required": ["mode", "accent"]
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
"if": {
|
|
692
|
+
"type": "object",
|
|
693
|
+
"properties": { "mode": { "const": "dark" } },
|
|
694
|
+
"required": ["mode"]
|
|
695
|
+
},
|
|
696
|
+
"then": {
|
|
697
|
+
"type": "object",
|
|
698
|
+
"properties": { "accent": { "type": "string" } },
|
|
699
|
+
"required": ["accent"]
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
]
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Both `allOf` parts are `open()`. Closed ones would reject each other's
|
|
707
|
+
members and the document would accept nothing, so the pen refuses them
|
|
708
|
+
(§4, `JL0102`).
|
|
709
|
+
|
|
710
|
+
## 4. Refusals
|
|
711
|
+
|
|
712
|
+
The schema pen raises these four `LinqBuildError` codes and no others —
|
|
713
|
+
`test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
714
|
+
to the codes `packages/linq/src/schema/` throws. The full condition each
|
|
715
|
+
code states across every pen is the binder's,
|
|
716
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
717
|
+
|
|
718
|
+
| Code | What this pen raises it for |
|
|
719
|
+
|---|---|
|
|
720
|
+
| `JL0101` | a value this pen cannot spell, or a name → value map it cannot read |
|
|
721
|
+
| `JL0102` | a construct the format cannot carry |
|
|
722
|
+
| `JL0103` | a `$defs` name collision, or a reference no definition answers |
|
|
723
|
+
| `JL0104` | a pen-owned keyword written through `meta()`, or an external a captured rule did not declare |
|
|
724
|
+
|
|
725
|
+
Every message below is the one the pen raised when the spelling beside it
|
|
726
|
+
was run, with the code prefix (`JL0101: `) removed. Where a row lists
|
|
727
|
+
several spellings, the message shown is the first one's: the shared
|
|
728
|
+
predicates interpolate the method name, so the others differ only in the
|
|
729
|
+
word the message opens with. `docPath`, where the refusal carries one, is
|
|
730
|
+
the JSON pointer of the node being assembled, and is appended to the
|
|
731
|
+
message text as well (`… at /properties/b`).
|
|
732
|
+
|
|
733
|
+
### 4.1 `JL0101` — the value, or the map
|
|
734
|
+
|
|
735
|
+
Raised at the door, before anything is assembled. The first four rows are
|
|
736
|
+
one predicate each, reached from every method that takes that kind of
|
|
737
|
+
argument.
|
|
738
|
+
|
|
739
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
740
|
+
|---|---|---|
|
|
741
|
+
| `s.array(42)`, `s.object({ a: 42 })`, `s.when(42)` | `array() takes a schema builder, got 42 — wrap a hand-written JSON Schema with from()` | `s.array(s.from({ type: 'integer' }))` |
|
|
742
|
+
| `s.string().min('x')` | `min() takes a non-negative integer, got a string` | `s.string().min(1)` |
|
|
743
|
+
| `s.number().min('x')`, `s.number().multipleOf(0)` | `min() takes a finite number, got a string`; `multipleOf() takes a positive number, got 0` | `s.number().min(0).multipleOf(0.5)` |
|
|
744
|
+
| `s.string().describe(42)`, `s.discriminated(42, […])` | `describe() takes a string, got 42` | `s.string().describe('…')` |
|
|
745
|
+
| `s.string().default(() => 1)`, `s.literal(NaN)`, `s.number().default(-0)` | `default() received a function, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a JSON value |
|
|
746
|
+
| `s.named('9bad', b)`, `s.ref('a/b')` | `named() takes a definition name (letters, digits, '_', '.', '-', not starting with a digit), got a string` | `s.named('Bad9', b)` |
|
|
747
|
+
| `s.object([])`, `s.object(null)` | `object() takes a plain object of builders, got a Array instance` | a plain object |
|
|
748
|
+
| `s.enumOf([])`, `s.string().enumOf([1])` | `enumOf() takes a non-empty array of JSON values`; `enumOf() on a string takes string values; 1 could never satisfy the enum` | `s.string().enumOf(['a'])` |
|
|
749
|
+
| `s.tuple(b)`, `s.union([])` | `tuple() takes an array of builders`; `union() takes a non-empty array of builders or schemas` | an array with at least one entry |
|
|
750
|
+
| `s.lazy(Node)` (the builder, not a thunk) | `lazy() takes a function returning a named builder` | `s.lazy(() => Node)` |
|
|
751
|
+
| `s.from(null)`, `s.from([])` | `from() takes a JSON Schema object or boolean, got null` | `s.from({})`, `s.from(true)` |
|
|
752
|
+
| `Base.pick(['zzz'])`, `Base.omit('name')` | `pick(): 'zzz' is not a member of this object`; `omit() takes an array of member names` | a name the object carries |
|
|
753
|
+
| `.dependentRequired({ id: 'name' })` | `dependentRequired() takes a plain object mapping a member name to an array of member names` | `.dependentRequired({ id: ['name'] })` |
|
|
754
|
+
| `.meta([…])` | `meta() takes a plain object of annotations, got a Array instance` | a plain object |
|
|
755
|
+
| `await s.when(b)`, or a `when()` builder returned from an `async` function | `a when() builder is not a promise — it was awaited or handed to a promise resolution; keep builders out of async return positions` | keep builders out of async return positions |
|
|
756
|
+
|
|
757
|
+
**The `__proto__` case.** It is the one refusal whose cause is invisible
|
|
758
|
+
in the source text, so it gets its own paragraph:
|
|
759
|
+
|
|
760
|
+
```js
|
|
761
|
+
s.object({ __proto__: s.string() })
|
|
762
|
+
// JL0101: object() received a map whose prototype was replaced: a
|
|
763
|
+
// '__proto__:' key in an object literal sets the prototype instead of
|
|
764
|
+
// adding a member, so that member is not there to emit — spell it
|
|
765
|
+
// { ['__proto__']: … }, which is an own key
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
`{ __proto__: builder }` in an object LITERAL does not add a member: it
|
|
769
|
+
invokes the `Object.prototype.__proto__` setter and replaces the object's
|
|
770
|
+
prototype. The member never reaches the pen — there is nothing to emit
|
|
771
|
+
and nothing to see. The one thing that IS visible is the prototype, and
|
|
772
|
+
no plain map has one, so the pen refuses the map by it rather than
|
|
773
|
+
emitting it a member short. The rule is the binder's §1.1 rule 5, it
|
|
774
|
+
applies at all three doors this pen has (`object()`, `.extend()`,
|
|
775
|
+
`.patternProperties()`), and the spelling that works is the computed key:
|
|
776
|
+
|
|
777
|
+
```js
|
|
778
|
+
s.object({ ['__proto__']: s.string() }) // an own property; emitted as a member
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
The same asymmetry runs the other way on the emission side: a document
|
|
782
|
+
carrying a `__proto__` member has to be written with `setObjectMember`,
|
|
783
|
+
because a plain `out[name] = value` would reassign the emitted object's
|
|
784
|
+
prototype and drop the member. That is why the emitted `properties`
|
|
785
|
+
object still has `Object.prototype` and still carries the key.
|
|
786
|
+
|
|
787
|
+
### 4.2 `JL0102` — the construct the format cannot carry
|
|
788
|
+
|
|
789
|
+
Raised either by the method (a constraint that cannot be spelled) or at
|
|
790
|
+
assembly (a shape whose emitted form would mean something else).
|
|
791
|
+
|
|
792
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
793
|
+
|---|---|---|
|
|
794
|
+
| `s.object({}).coerce()` | `coerce() applies to a scalar (string, number, integer, boolean, nil); a object has no single type the normalizer could coerce to` | coerce the scalar members |
|
|
795
|
+
| `s.string().nullable().coerce()` | `a nullable value cannot be coerced — the normalizer coerces only a single-typed scalar, so the coercion would never run; drop nullable() or coerce()` | drop one of the two |
|
|
796
|
+
| `s.string().coerce().nullable()` | `a coerced value cannot be nullable — the normalizer coerces only a single-typed scalar, so a nullable coercion would never run; drop coerce() or nullable()` | drop one of the two |
|
|
797
|
+
| `s.number().trim()` | `trim() applies to a string; a number carries no whitespace to trim` | `s.string().trim()` |
|
|
798
|
+
| `s.string().pattern(/a/i)` | `pattern() cannot carry the flags 'i' — a JSON Schema pattern is a bare regular expression source; spell the flag inside the expression, or drop it` | `s.string().pattern(/[Aa]/)` |
|
|
799
|
+
| `s.never().describe('x')` | `never() is the boolean schema false, which carries no 'description' — annotate the member that holds it, or nullable() it first` | annotate the member |
|
|
800
|
+
| `s.never().check(fn)` | `never() is the boolean schema false; nothing reaches a check on it — check the member that holds it, or nullable() it first` | check the member |
|
|
801
|
+
| `s.discriminated('kind', [A, B])` where `B` does not declare `kind` | `discriminated('kind') option 1 does not declare 'kind' as a literal() or enumOf() member — without the tag on every option the oneOf is not a discriminated union; use union() for an untagged one` | `s.union([A, B])`, or give `B` the tag |
|
|
802
|
+
| `s.intersection([closedA, closedB])` | `closed objects do not intersect — under allOf each part rejects the other's members, so the document would accept neither; open() the parts, or merge them with extend()` | `.open()` both, or `A.extend(B's members)` |
|
|
803
|
+
| `s.union([s.string().default('x'), …])` | `a default(), coerce() or trim() under union() never runs — the normalizer does not descend that branch, so the document would promise a normalization that does not happen; move it to the member that holds the branch, or drop it` | move the `default()` to the member holding the union |
|
|
804
|
+
| `s.document(b, { draft: 'draft-07' })` | `document() writes the 2020-12 vocabulary only; 'draft-07' is not a draft it can declare` | `{ draft: '2020-12' }`, or no draft |
|
|
805
|
+
| `s.document(s.never(), { draft: '2020-12' })` | `a boolean schema cannot declare a $schema` | give the document a non-boolean root |
|
|
806
|
+
|
|
807
|
+
The normalizer rule fires under seven names, and the message carries
|
|
808
|
+
whichever one it was: `union()`, `discriminated()`, `when()`, `then()`,
|
|
809
|
+
`else()`, `contains()` and `propertyNames()`. Its `docPath` names the
|
|
810
|
+
exact branch (`/anyOf/0`, `/if`, `/contains`, `/propertyNames`).
|
|
811
|
+
|
|
812
|
+
### 4.3 `JL0103` — the definition
|
|
813
|
+
|
|
814
|
+
Raised at assembly, when the `$defs` block is closed and every name a
|
|
815
|
+
`ref()` demanded must be answered.
|
|
816
|
+
|
|
817
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
818
|
+
|---|---|---|
|
|
819
|
+
| `s.object({ a: s.ref('Nope') })` | `ref('Nope') names no definition in this document — a name is defined by named('Nope', …) somewhere the root can reach` | `named('Nope', …)` somewhere the root reaches |
|
|
820
|
+
| `s.object({ a: s.named('T', s.string()), b: s.named('T', s.number()) })` | `two distinct builders are named 'T' in one document — a $defs entry can hold one definition; rename one of them` | rename one, or reach the SAME builder twice |
|
|
821
|
+
| `s.lazy(() => s.string())` | `lazy() must return a NAMED builder — a recursion is spelled as a $ref, and a $ref needs a definition to point at: lazy(() => Node) where Node = named('Node', …)` | `s.lazy(() => Node)` |
|
|
822
|
+
| `s.lazy(() => 42)` | `lazy() must return a builder` | a named builder |
|
|
823
|
+
|
|
824
|
+
"Two distinct builders" is by identity, not by shape: the same builder
|
|
825
|
+
under one name, reached from anywhere, is one definition — that is the
|
|
826
|
+
identity rule §3's `Audit` example shows. Two builders that emit the same
|
|
827
|
+
JSON are still two, and still a collision.
|
|
828
|
+
|
|
829
|
+
### 4.4 `JL0104` — the keyword, and the external
|
|
830
|
+
|
|
831
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
832
|
+
|---|---|---|
|
|
833
|
+
| `s.string().meta({ type: 'x' })` | `meta() cannot write 'type' — the pen owns that keyword; spell it through the builder method that emits it, keyword('type', value) where no method does, or wrap a hand-written schema with from()` | the method that emits it — or, for a keyword §6.2 lists, `s.keyword(…)` or `s.from({ … })` |
|
|
834
|
+
| `.check((o, x) => x.foo.eq(1))` | `a check() rule cannot bind 'foo' — its query evaluates with exactly 2 externals, 'root' and 'path'; anything else has nothing to bind to` | `x.root` and `x.path`, and nothing else |
|
|
835
|
+
|
|
836
|
+
The owned set is every keyword the pen writes itself plus every keyword
|
|
837
|
+
that would change what a document asserts — the structural ones, the
|
|
838
|
+
constraints, `$schema`/`$id`/`$ref`/`$defs` and the anchors, `$query`,
|
|
839
|
+
`default`/`title`/`description`/`examples`/`errorMessage`, and
|
|
840
|
+
`x-coerce`/`x-trim`. `meta()` is for everything else: a 2020-12
|
|
841
|
+
annotation the pen has no method for (`deprecated`, `readOnly`,
|
|
842
|
+
`writeOnly`), and any vendor extension. The type declaration carries the
|
|
843
|
+
same set as `OwnedKeyword`, so a forbidden key does not compile either
|
|
844
|
+
(§5). Twenty-five of the sixty-nine have no method to be spelled through,
|
|
845
|
+
which is why the message names `keyword()` as well;
|
|
846
|
+
[§6.2](#62-the-absences-twenty-five-keywords-with-no-method) lists them
|
|
847
|
+
and the two doors that stay open for them.
|
|
848
|
+
|
|
849
|
+
`check()`'s two externals are the two the validator binds on every
|
|
850
|
+
`$query` evaluation. The refusal is raised at BUILD time, earlier than
|
|
851
|
+
the validator's own compile error and with the same meaning.
|
|
852
|
+
|
|
853
|
+
## 5. The types
|
|
854
|
+
|
|
855
|
+
The service of §3 gets its types from the same constants that emitted its
|
|
856
|
+
documents — there is no second declaration to keep in step. Here is
|
|
857
|
+
`Signup`, three of its five members, read both ways:
|
|
858
|
+
|
|
859
|
+
```ts
|
|
860
|
+
import * as s from '@jarenjs/linq/schema';
|
|
861
|
+
import type { Infer, Input } from '@jarenjs/linq/schema';
|
|
862
|
+
|
|
863
|
+
const Signup = s.object({
|
|
864
|
+
seats: s.integer().coerce(),
|
|
865
|
+
name: s.string().trim(),
|
|
866
|
+
discount: s.number().coerce().default(1),
|
|
867
|
+
});
|
|
868
|
+
type Stored = Infer<typeof Signup>; // { seats: number; name: string; discount: number }
|
|
869
|
+
type Arriving = Input<typeof Signup>; // { seats: number | string; name: string;
|
|
870
|
+
// discount?: number | string }
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
`Infer<>` is the shape AFTER the normalizer ran with the pen's own profile
|
|
874
|
+
— `useDefaults: true`, `coerceTypes`/`trimStrings` as the `x-coerce`/`x-trim`
|
|
875
|
+
predicates — which is what the rest of a program handles; `Input<>` is
|
|
876
|
+
what a caller may hand in before it. That is the whole shape of a request
|
|
877
|
+
handler: take `Arriving`, normalize, and every function below it takes
|
|
878
|
+
`Stored`. Where nothing is defaulted or coerced the two are one type, and
|
|
879
|
+
`User`, `Audit` and `Access` are all in that case.
|
|
880
|
+
|
|
881
|
+
A recursive definition is annotated, as every recursive inference must be
|
|
882
|
+
— §3's group tree, typed:
|
|
883
|
+
|
|
884
|
+
```ts
|
|
885
|
+
interface Group { name: string; children?: Group[] }
|
|
886
|
+
const Group: s.NamedBuilder<Group> = s.named('Group', s.object({
|
|
887
|
+
name: s.string(),
|
|
888
|
+
children: s.array(s.lazy(() => Group)).optional(),
|
|
889
|
+
}));
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
The chain takes a builder where it took a document, and types the
|
|
893
|
+
element from it: `from(rows).ofType(User)` is `Sequence<Infer<typeof User>>`.
|
|
894
|
+
|
|
895
|
+
### 5.1 The phantoms, and the flags
|
|
896
|
+
|
|
897
|
+
Every builder declares four carriers and never has any of them at run
|
|
898
|
+
time (`packages/linq/types/schema.d.ts`): `__out`, the shape after
|
|
899
|
+
normalization; `__in`, the shape before it; `__flags`, a union of the
|
|
900
|
+
member marks; and `schema`, the document type. `Infer<B>` reads `__out`,
|
|
901
|
+
`Input<B>` reads `__in`, `SchemaOf<B>` reads `schema`.
|
|
902
|
+
|
|
903
|
+
`Flag` is `'optional' | 'defaulted' | 'generated' | 'key'` — the last two
|
|
904
|
+
belong to the model pen, which extends these declarations. Two rules read
|
|
905
|
+
the flags, and they are deliberately different:
|
|
906
|
+
|
|
907
|
+
| | required on `Infer` | required on `Input` |
|
|
908
|
+
|---|---|---|
|
|
909
|
+
| plain | yes | yes |
|
|
910
|
+
| `.optional()` | no | no |
|
|
911
|
+
| `.default(v)` | **yes** — the normalizer materializes it | no |
|
|
912
|
+
| `.optional().default(v)` | **yes** | no |
|
|
913
|
+
|
|
914
|
+
That is the one asymmetry to carry: a `default()`ed member is present
|
|
915
|
+
afterwards and absent-able before, which is exactly what §3's `Signup`
|
|
916
|
+
example prints.
|
|
917
|
+
|
|
918
|
+
A builder-shaped position is typed `BuilderLike<Out, In, F>`, an
|
|
919
|
+
INTERFACE compared by those four carriers rather than by its methods —
|
|
920
|
+
so a subclass of another pen (`EntityStringBuilder`, `FormObjectBuilder`)
|
|
921
|
+
fits wherever "a builder" is asked for without either pen importing the
|
|
922
|
+
other.
|
|
923
|
+
|
|
924
|
+
### 5.2 What each family reads
|
|
925
|
+
|
|
926
|
+
- **Scalars, dates, literals and enums.** `string()` is `string`,
|
|
927
|
+
`number()`/`integer()` are `number` — integer-ness is a documented
|
|
928
|
+
widening — and a constraint never narrows: `.min(1)`, `.pattern(…)` and
|
|
929
|
+
`.format('email')` all answer `this`. The two date formats are the
|
|
930
|
+
exception: `.format('date-time')`, `.format('date')`, `datetime()` and
|
|
931
|
+
`date()` answer `StringBuilder<DateTime, DateTime>`, which is what makes
|
|
932
|
+
the chain's date operators legal over a pen shape, while `time()` and
|
|
933
|
+
`duration()` stay `string`. `literal(v)` and `enumOf(values)` are the
|
|
934
|
+
const and the literal union, inferred through `const` type parameters.
|
|
935
|
+
- **Objects.** A closed object is exactly its members, with no index
|
|
936
|
+
signature; `object({})` is `Record<string, never>`. `.open()` adds
|
|
937
|
+
`[key: string]: unknown`. `.patternProperties()` on a CLOSED object
|
|
938
|
+
adds an index signature that carries the pattern values widened over
|
|
939
|
+
the declared members — emit's rule, and an honest widening rather than
|
|
940
|
+
a lie: `{ id: 'a', 'x-1': 'no' }` type-checks and fails validation, and
|
|
941
|
+
`test/consumer/linq-schema.ts` pins both halves.
|
|
942
|
+
- **Arrays and tuples.** `array(b)` is `Infer<B>[]`. A tuple is
|
|
943
|
+
`[...positions, ...unknown[]]` until `.rest(b)` names the tail, and
|
|
944
|
+
`[...positions]` exactly once `.rest(never())` closes it.
|
|
945
|
+
- **Composition.** `union` and `discriminated` are the union of the
|
|
946
|
+
options; `intersection` is their intersection. `when()` is `unknown` on
|
|
947
|
+
both sides — emit records a conditional and never types it.
|
|
948
|
+
- **The two asserted, and the one inferred.** `ref<T>(name)` and
|
|
949
|
+
`from<T>(json)` carry the type the CALLER asserts, because a name and a
|
|
950
|
+
JSON literal carry none — both default to `unknown`, so an unannotated
|
|
951
|
+
one is honest rather than wrong. `lazy(thunk)` is different: it demands
|
|
952
|
+
a `NamedLike` — a builder carrying the `__named` phantom — and reads
|
|
953
|
+
the type off it, which is why `lazy(() => s.string())` does not compile
|
|
954
|
+
and why the recursion's annotation belongs on the `named()` constant
|
|
955
|
+
rather than on the `lazy()` call.
|
|
956
|
+
|
|
957
|
+
### 5.3 The exported classes, the constant and the guard
|
|
958
|
+
|
|
959
|
+
Ten exports are surface a caller does not CALL, which is why none of them
|
|
960
|
+
is in §2:
|
|
961
|
+
|
|
962
|
+
| Export | What a caller meets it as |
|
|
963
|
+
|---|---|
|
|
964
|
+
| `SchemaBuilder` | the base every builder extends; a type annotation, and the class a pen built over this one subclasses |
|
|
965
|
+
| `StringBuilder`, `NumberBuilder`, `ArrayBuilder`, `TupleBuilder`, `ObjectBuilder`, `WhenBuilder`, `NeverBuilder` | the seven kinds with their own methods; annotations, `instanceof` narrows, and the classes `createFactories` is handed |
|
|
966
|
+
| `SCHEMA_BUILDER` | the brand key, a `Symbol.for` registry symbol — how the chain recognises a builder without importing this directory |
|
|
967
|
+
| `isSchemaBuilder(value)` | the guard that reads the brand; `true` for a builder, `false` for a data object that merely carries a `toJSON` member |
|
|
968
|
+
|
|
969
|
+
The eight classes are how the model and forms pens exist at all: each
|
|
970
|
+
calls `createFactories()` with subclasses of these, so the factory wiring
|
|
971
|
+
is written once and no subpath patches another's prototype. A consumer
|
|
972
|
+
subclassing them takes the same route — `with()` keeps the subclass
|
|
973
|
+
through every method, so a subclass never has to re-declare one.
|
|
974
|
+
|
|
975
|
+
All ten are VALUES, exported at run time and declared as one.
|
|
976
|
+
`BooleanBuilder`, `NullBuilder` and `NamedBuilder` are TYPES only —
|
|
977
|
+
`boolean()`, `nil()` and `named()` each build a plain `SchemaBuilder`, so
|
|
978
|
+
there is no class to export, though model and forms extend the
|
|
979
|
+
declaration. Importing one as a value does not compile, and
|
|
980
|
+
`test/linq/types.test.js` holds each pen's two export sets equal.
|
|
981
|
+
|
|
982
|
+
### 5.4 What the pins hold
|
|
983
|
+
|
|
984
|
+
Two files, both compiled by `npm run test:types`:
|
|
985
|
+
|
|
986
|
+
| File | What it proves |
|
|
987
|
+
|---|---|
|
|
988
|
+
| `test/consumer/linq-schema-generated.ts` | emit's own declarations for every document the corpus emits, produced by `scripts/generate-schema-pen-fixture.js`. `test/linq/schema-pen.test.js` asserts the committed file is exactly what the generator produces today, so it cannot drift |
|
|
989
|
+
| `test/consumer/linq-schema.ts` | for all 33 corpus entries, `Infer<>` EQUAL (not merely assignable) to the generated declaration and `Input<>` equal to its accepted twin; the `DateTime` brand on a date member; a `check()` rule seeing its shape; valid instances assignable; and eight negatives |
|
|
990
|
+
|
|
991
|
+
The negatives are worth reading as a list of what the types forbid, since
|
|
992
|
+
each one FAILS the build the day it starts compiling:
|
|
993
|
+
|
|
994
|
+
```ts
|
|
995
|
+
void s.string().min('x'); // a string constraint takes a number
|
|
996
|
+
void s.object({}).meta({ type: 'x' }); // a pen-owned keyword through meta()
|
|
997
|
+
void s.lazy(() => s.string()); // lazy() demands a NAMED builder
|
|
998
|
+
void s.number().trim(); // trim() is a string method
|
|
999
|
+
void s.object({ a: s.string() }).pick(['zzz']); // pick() names members the object has
|
|
1000
|
+
void s.integer().default('three'); // a default is a value of the member's type
|
|
1001
|
+
void s.object({ n: s.number() }).check((o) => o.m.exists()); // a check reads the shape
|
|
1002
|
+
void s.object({ n: s.number() }).check((o, x) => o.n.eq(x.limit)); // root and path, nothing else
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
Beside them the same file pins the closed-object rejection of an extra
|
|
1006
|
+
member, the defaulted member present only on `Infer`, the coerced
|
|
1007
|
+
transport form accepted only on `Input`, the discriminator picking the
|
|
1008
|
+
arm, recursion typed all the way down, a closed tuple having no rest,
|
|
1009
|
+
`Record<string, never>` for `object({})`, and `never()` admitting nothing.
|
|
1010
|
+
|
|
1011
|
+
## 6. What it cannot spell
|
|
1012
|
+
|
|
1013
|
+
Two different kinds of limit live here and it is worth knowing which one
|
|
1014
|
+
you have hit. The first is a **refusal**: the deliverable is a JSON
|
|
1015
|
+
document, and a construct that cannot BE one has nowhere to go, so the
|
|
1016
|
+
pen raises a `JL0102` rather than emitting something it cannot honour.
|
|
1017
|
+
The second is an **absence**: a keyword the format has, the validator
|
|
1018
|
+
compiles, and this surface has no method for — reachable, but not by a
|
|
1019
|
+
name your editor will complete. Every entry below says which it is.
|
|
1020
|
+
|
|
1021
|
+
This is the section a reader arriving from a JavaScript-first schema
|
|
1022
|
+
library needs, because four of the refusals are methods they are used to
|
|
1023
|
+
having.
|
|
1024
|
+
|
|
1025
|
+
### 6.1 The refusals
|
|
1026
|
+
|
|
1027
|
+
- **`refine`, `superRefine`, `transform`, `preprocess`.** They do not
|
|
1028
|
+
exist — calling one is a `TypeError`, not a coded refusal, because
|
|
1029
|
+
there is no method to refuse from. A refinement is a JavaScript
|
|
1030
|
+
closure, and a closure cannot be serialized into a document that a
|
|
1031
|
+
store, a browser and a CLI all have to read the same way. The two
|
|
1032
|
+
halves have separate homes: a cross-field RULE is `check()`, which
|
|
1033
|
+
captures into `$query` and travels with the document; a TRANSFORM is
|
|
1034
|
+
application code that runs before or after validation, and the two
|
|
1035
|
+
transforms common enough to be worth a keyword — coercion and trimming
|
|
1036
|
+
— are `coerce()` and `trim()`, which the normalizer performs.
|
|
1037
|
+
- **A coercion the normalizer would never run.** `coerce()` on a
|
|
1038
|
+
non-scalar, and `coerce()` with `nullable()` in either order.
|
|
1039
|
+
`compileNormalizer` coerces a value toward ONE type; a union of two has
|
|
1040
|
+
no single target, so the annotation would be a promise the pipeline
|
|
1041
|
+
does not keep. Coerce the scalar members instead.
|
|
1042
|
+
- **A default, coercion or trim inside a branch.** Under `anyOf`/`oneOf`,
|
|
1043
|
+
`if`/`then`/`else`, `contains` or `propertyNames`, the normalizer does
|
|
1044
|
+
not descend — so the document would say a member is defaulted and no
|
|
1045
|
+
default would ever appear. Move it to the member that HOLDS the branch.
|
|
1046
|
+
- **Closed objects under `allOf`.** `additionalProperties: false` is
|
|
1047
|
+
evaluated per-subschema in 2020-12: each part rejects the other's
|
|
1048
|
+
members and the intersection accepts nothing. `open()` the parts, or
|
|
1049
|
+
merge them with `extend()` — which is a better document anyway, since
|
|
1050
|
+
it produces one object rather than an `allOf` a reader has to intersect
|
|
1051
|
+
in their head.
|
|
1052
|
+
- **An annotation or a check on `never()`.** `never()` is the boolean
|
|
1053
|
+
schema `false`, and `false` has no place to put a `description` or a
|
|
1054
|
+
`$query`. `optional()` and `nullable()` DO work on it, because neither
|
|
1055
|
+
writes into the node: `optional()` marks the member and `nullable()`
|
|
1056
|
+
wraps it in `anyOf: [false, { type: 'null' }]`. Annotate the member
|
|
1057
|
+
that holds it.
|
|
1058
|
+
- **A regular-expression flag.** JSON Schema's `pattern` is a bare
|
|
1059
|
+
source string with no flag syntax, so `/a/i` cannot be carried. Spell
|
|
1060
|
+
the flag inside the expression (`/[Aa]/`) or drop it.
|
|
1061
|
+
- **Any draft but 2020-12.** `document()` declares
|
|
1062
|
+
`https://json-schema.org/draft/2020-12/schema` and refuses to declare
|
|
1063
|
+
another, because the pen writes that vocabulary and only that one.
|
|
1064
|
+
Emitting a `$schema` it does not honour would be worse than emitting
|
|
1065
|
+
none — which is what `document(root)` without a draft does, and what a
|
|
1066
|
+
schema embedded in a larger document wants.
|
|
1067
|
+
- **A `$schema` or `$defs` on a boolean root.** `never()` and
|
|
1068
|
+
`from(true)` emit `false` and `true`; a boolean is not an object and
|
|
1069
|
+
can carry neither. Name the root instead (`named('X', …)`).
|
|
1070
|
+
- **A keyword the pen owns, through `meta()`.** Not a limit of the format
|
|
1071
|
+
but of the door: `meta()` writes verbatim, so letting it write `type`
|
|
1072
|
+
or `required` would make the annotation a back way around the builder
|
|
1073
|
+
and the phantom types would stop describing the document. Use the
|
|
1074
|
+
method, or wrap a hand-written schema with `from()` — which is the
|
|
1075
|
+
general escape hatch and is exactly as honest, since `from<T>()` makes
|
|
1076
|
+
the type the caller's assertion.
|
|
1077
|
+
- **A rule the query language has no operator for.** `check()` captures a
|
|
1078
|
+
callback against a recording proxy: only what the proxy records becomes
|
|
1079
|
+
the `$query`, so a JavaScript `if`, a loop or a call into another
|
|
1080
|
+
library records one branch or nothing. The operator set is
|
|
1081
|
+
`packages/json/docs/QUERY-FORMAT.md` §8; a rule outside it is
|
|
1082
|
+
application code, run beside validation rather than inside it.
|
|
1083
|
+
|
|
1084
|
+
### 6.2 The absences: twenty-five keywords with no method
|
|
1085
|
+
|
|
1086
|
+
The pen's own list of the keywords it owns
|
|
1087
|
+
(`packages/linq/src/schema/builders.js`) is sixty-nine names, and it holds
|
|
1088
|
+
two kinds — the keywords a method emits, and the ones that "would change
|
|
1089
|
+
what a document asserts" and are kept out of `meta()` for that reason
|
|
1090
|
+
alone (the comment above the list says so). The second kind has no
|
|
1091
|
+
spelling of its own on this surface:
|
|
1092
|
+
|
|
1093
|
+
| Family | Keywords with no method |
|
|
1094
|
+
|---|---|
|
|
1095
|
+
| negation | `not` |
|
|
1096
|
+
| unevaluated | `unevaluatedProperties`, `unevaluatedItems` |
|
|
1097
|
+
| conditional members | `dependentSchemas`, `dependencies` |
|
|
1098
|
+
| `contains` bounds | `minContains`, `maxContains` |
|
|
1099
|
+
| content | `contentEncoding`, `contentMediaType`, `contentSchema` |
|
|
1100
|
+
| format bounds | `formatMinimum`, `formatMaximum`, `formatExclusiveMinimum`, `formatExclusiveMaximum` |
|
|
1101
|
+
| identification | `$id`, `$anchor`, `$vocabulary` |
|
|
1102
|
+
| dynamic references | `$dynamicRef`, `$dynamicAnchor`, `$recursiveRef`, `$recursiveAnchor` |
|
|
1103
|
+
| legacy and extension spellings | `definitions`, `additionalItems`, `$data`, `data` |
|
|
1104
|
+
|
|
1105
|
+
`@jarenjs/validate` compiles every one of them — `not` in `combine.js`,
|
|
1106
|
+
the unevaluated pair in `unevaluated.js`, `dependentSchemas` in
|
|
1107
|
+
`object.js`, the `contains` bounds in `array.js`, the content family in
|
|
1108
|
+
`content.js`, the dynamic references in `dynamic-ref.js`, `$data` in
|
|
1109
|
+
`dollar-data.js` — so the gap is this pen's surface, not the format and
|
|
1110
|
+
not the engine. It is tracked in
|
|
1111
|
+
[docs/ROADMAP.md](../../../docs/ROADMAP.md) under the data pair, and
|
|
1112
|
+
`test/linq/schema-pen.test.js` holds this table equal to the pen's own
|
|
1113
|
+
owned set, so a method that lands for one of them fails the suite until
|
|
1114
|
+
its row goes.
|
|
1115
|
+
|
|
1116
|
+
Two doors are open in the meantime, and both are rows of
|
|
1117
|
+
[§2.10](#210-the-document-and-the-builder-itself):
|
|
1118
|
+
|
|
1119
|
+
```js
|
|
1120
|
+
s.string().keyword('not', { type: 'number' })
|
|
1121
|
+
// { "type": "string", "not": { "type": "number" } }
|
|
1122
|
+
|
|
1123
|
+
s.object({ a: s.string() }).keyword('unevaluatedProperties', false)
|
|
1124
|
+
// { "type": "object", …, "unevaluatedProperties": false }
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
`.keyword(key, value)` writes one keyword onto the node the builder is
|
|
1128
|
+
assembling, in the order first set, exactly as a named method does;
|
|
1129
|
+
`from(json)` wraps a hand-written subschema whole. Neither changes the
|
|
1130
|
+
type reading — `.keyword()` answers `this` and `from<T>()` carries the
|
|
1131
|
+
type the caller asserts — which is the honest trade rather than a
|
|
1132
|
+
shortfall: a keyword with no method has no phantom to read either.
|
|
1133
|
+
|
|
1134
|
+
`definitions` is the draft-07 spelling of `$defs`, and `named()` writes
|
|
1135
|
+
`$defs`: the pen emits one definitions block under one name, so the older
|
|
1136
|
+
keyword is owned to keep a document from carrying both and reached, like
|
|
1137
|
+
the rest of the table, through `keyword()` or `from()`.
|
|
1138
|
+
|
|
1139
|
+
**`meta()` is not a third door.** Every name in the table is owned, so
|
|
1140
|
+
`meta()` refuses it with `JL0104`
|
|
1141
|
+
([§4.4](#44-jl0104--the-keyword-and-the-external)) — and the message
|
|
1142
|
+
names both doors that ARE open, `keyword()` and `from()`. One name is on
|
|
1143
|
+
the owned list for the opposite reason: `nullable` is refused because
|
|
1144
|
+
`.nullable()` already exists and emits a type union
|
|
1145
|
+
(`type: ['string', 'null']`) — `nullable` as a keyword is an OpenAPI
|
|
1146
|
+
spelling that 2020-12 does not carry, and refusing it is what keeps one
|
|
1147
|
+
document from claiming both.
|
|
1148
|
+
|
|
1149
|
+
### 6.3 When not to reach for this pen
|
|
1150
|
+
|
|
1151
|
+
A guide that never says "don't" is a brochure. Write the JSON Schema by
|
|
1152
|
+
hand, or generate it some other way, when:
|
|
1153
|
+
|
|
1154
|
+
- **The schema is data, not code.** A schema loaded from a file, received
|
|
1155
|
+
over the wire, or stored in a database is a value; wrap it with
|
|
1156
|
+
`from()` if a builder has to hold it, and otherwise leave it alone. The
|
|
1157
|
+
pen is a way of AUTHORING a document, and authoring is a thing you do
|
|
1158
|
+
once.
|
|
1159
|
+
- **Another document already carries it.** An entity's schema lives in a
|
|
1160
|
+
`$model` ([MODEL-PEN.md](MODEL-PEN.md)), an operation's in a
|
|
1161
|
+
`$contract` ([CONTRACT-PEN.md](CONTRACT-PEN.md)), a form's in the same
|
|
1162
|
+
schema its rules annotate ([FORMS-PEN.md](FORMS-PEN.md)). Those
|
|
1163
|
+
subpaths are this pen with a vocabulary added, so a schema written here
|
|
1164
|
+
and copied there is two shapes that have to stay equal; write it in the
|
|
1165
|
+
document that owns it.
|
|
1166
|
+
- **It is one line in a test.** `{ type: 'string' }` is shorter than
|
|
1167
|
+
`s.string().schema` and reads the same to everyone. The pen earns its
|
|
1168
|
+
import when a document is big enough that a rename, a reshape or a
|
|
1169
|
+
shared definition would otherwise be a find-and-replace.
|
|
1170
|
+
- **You need a keyword from §6.2 in most of the document.** One
|
|
1171
|
+
`.keyword()` beside twenty methods is a fair trade; twenty
|
|
1172
|
+
`.keyword()` calls beside one method is a hand-written schema with
|
|
1173
|
+
extra syntax.
|
|
1174
|
+
- **The consumer is not a Jaren validator.** Nothing here is
|
|
1175
|
+
Jaren-specific — the output is standard 2020-12 — but `$query`,
|
|
1176
|
+
`errorMessage`, `x-coerce` and `x-trim` are ignored by a validator that
|
|
1177
|
+
does not know them, so a document whose rules live in those keywords
|
|
1178
|
+
asserts less elsewhere than it does here. §1 names the three families;
|
|
1179
|
+
check that the reader of your document implements them.
|
|
1180
|
+
|
|
1181
|
+
## 7. Cost
|
|
1182
|
+
|
|
1183
|
+
`@jarenjs/linq/schema` builds to **<!--fact:bundle.schema-->33,156<!--/fact--> bytes** as a minified,
|
|
1184
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
1185
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
1186
|
+
(<!--fact:bundle.schema.kb-->33<!--/fact--> kB) beside the other nine subpath prices in
|
|
1187
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
1188
|
+
|
|
1189
|
+
The probe is a gate, not a report: building
|
|
1190
|
+
`s.object({ id: s.string() }).schema` as a consumer would, it asserts
|
|
1191
|
+
three things and fails the build on any of them:
|
|
1192
|
+
|
|
1193
|
+
- **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
|
|
1194
|
+
`concurrency.js`, `provider.js`, `sources.js` or `schema-of.js`
|
|
1195
|
+
contributes a byte;
|
|
1196
|
+
- **no engine** — not one byte of `@jarenjs/json`, `@jarenjs/validate`,
|
|
1197
|
+
`@jarenjs/emit`, `@jarenjs/db`, `@jarenjs/formats` or `@jarenjs/refs`,
|
|
1198
|
+
which is the tree-shaken proof of §1's claim that the pen writes a
|
|
1199
|
+
document and compiles nothing;
|
|
1200
|
+
- **no model pen** — the subclasses are built by the `./model` subpath,
|
|
1201
|
+
never patched onto these classes, so taking the schema pen never drags
|
|
1202
|
+
the model pen in.
|
|
1203
|
+
|
|
1204
|
+
Two things ride along by construction and are part of the ceiling: the
|
|
1205
|
+
recording proxy in `expression.js`, which `check()` captures through (a
|
|
1206
|
+
class method cannot be tree-shaken away), and every factory function,
|
|
1207
|
+
built as one closure per class set so the model and forms pens construct
|
|
1208
|
+
their subclasses through the same wiring.
|
|
1209
|
+
|
|
1210
|
+
A consumer who takes the chain as well pays LESS than the two figures
|
|
1211
|
+
suggest. The chain carries nothing from this directory — `ofType`/`cast`
|
|
1212
|
+
recognise a builder by the registry symbol `SCHEMA_BUILDER` rather than
|
|
1213
|
+
by an import — but both bundles carry `expression.js` and the coded
|
|
1214
|
+
errors under it, and a bundler counts a shared module once:
|
|
1215
|
+
[QUERY-PEN.md](QUERY-PEN.md) §17 publishes the pair's measured size and
|
|
1216
|
+
the saving, both derived by the same probe. A consumer who takes only
|
|
1217
|
+
the pen — which is what a shared `schemas.js` module in an application
|
|
1218
|
+
usually is — pays the figure above alone.
|