@jarenjs/linq 0.49.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -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 +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -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 +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -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 +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
# The Jaren forms pen
|
|
2
|
+
|
|
3
|
+
> `./forms` — the `x-form` vocabulary on JSON Schema, and
|
|
4
|
+
> `assertOnSubmit()`, the same rules' layer-3 `$query` twin. **Read it
|
|
5
|
+
> when** you are turning a schema into a form
|
|
6
|
+
|
|
7
|
+
Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
|
|
8
|
+
interpreted as described in RFC 2119. This document is a **guide** — read
|
|
9
|
+
it in order and you can write the format — whose one normative section is
|
|
10
|
+
[§2 The mapping table](#2-the-mapping-table); the rules every pen keeps, the shared refusal table, the
|
|
11
|
+
index of the other pens and every pen's mapping table collected in one
|
|
12
|
+
place are the normative reference,
|
|
13
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md).
|
|
14
|
+
|
|
15
|
+
## 1. What it writes
|
|
16
|
+
|
|
17
|
+
You have a schema for some data and now you need a form over it — this
|
|
18
|
+
field only when that one is filled, this one required when the company
|
|
19
|
+
box is not empty, this one computed rather than typed. Those are rules
|
|
20
|
+
about the same members the schema already describes, and keeping them in
|
|
21
|
+
a second file beside it is how a renamed member breaks a form silently.
|
|
22
|
+
This pen puts them on the member: one JSON Schema, with the form's rules
|
|
23
|
+
annotated onto it.
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import * as f from '@jarenjs/linq/forms';
|
|
27
|
+
import { assertOnSubmit, withForm } from '@jarenjs/linq/forms';
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
is the schema pen's every name from SUBCLASSES that carry the `x-form`
|
|
31
|
+
vocabulary — `form({ visible, enabled, assert, computed, message })` on
|
|
32
|
+
every builder — plus `assertOnSubmit()`, the one call that answers the
|
|
33
|
+
same rules' layer-3 `$query` twin
|
|
34
|
+
([the forms README](../../forms/README.md)).
|
|
35
|
+
|
|
36
|
+
There is no `jaren-form` format. What this pen writes is a **JSON
|
|
37
|
+
Schema**, exactly the one [SCHEMA-PEN.md](SCHEMA-PEN.md) describes, with
|
|
38
|
+
one namespaced annotation keyword added: `x-form`. That is the whole
|
|
39
|
+
difference between the two pens, and it is why this document is the
|
|
40
|
+
shortest of the nine — 27 of its 31 names are the schema pen's, emitting
|
|
41
|
+
the schema pen's documents, and §2 links them rather than restating them
|
|
42
|
+
(the binder's D4 rule: a fact has one home).
|
|
43
|
+
|
|
44
|
+
A rule is an ANNOTATION. It never changes what the schema validates:
|
|
45
|
+
`required`, `additionalProperties` and every other keyword stay exactly
|
|
46
|
+
what the schema pen wrote, `Infer<>`/`Input<>` read exactly as they do
|
|
47
|
+
there, and the subclasses exist only so `form()` survives every chained
|
|
48
|
+
method. What `buildFormModel`, `compileFormRules` and
|
|
49
|
+
`evaluateFormRules` then read is the annotation; a validator that has
|
|
50
|
+
never heard of `x-form` ignores it, which is what makes the keyword safe
|
|
51
|
+
under every metaschema.
|
|
52
|
+
|
|
53
|
+
The pen imports nothing of `@jarenjs/forms`: the reader is the only
|
|
54
|
+
judge of what a rule means, and `assertOnSubmit()` is pinned deep-equal
|
|
55
|
+
to `formRulesToQueryAssertions` over the whole corpus
|
|
56
|
+
(`test/linq/forms-pen.test.js`).
|
|
57
|
+
|
|
58
|
+
**The running example.** §3 is one shop's checkout, field by field: the
|
|
59
|
+
terms box that is the smallest rule the pen can write, the shipping
|
|
60
|
+
address where all three context names appear at once, the invoice block
|
|
61
|
+
whose VAT id is required only for companies, that same block's submit
|
|
62
|
+
twin, the project's own VAT string kind carrying the rules through a
|
|
63
|
+
subclass, and the delivery parcel whose map preview is derived rather
|
|
64
|
+
than authored. §5 reads the types off the same documents, and finds them
|
|
65
|
+
identical to the schema pen's — which is the point.
|
|
66
|
+
|
|
67
|
+
### 1.1 The rule context, and its three names
|
|
68
|
+
|
|
69
|
+
The three predicates and `computed` are CALLBACKS captured over the rule
|
|
70
|
+
context, never paths typed as strings. The context spells the three
|
|
71
|
+
names the evaluator binds, and no others:
|
|
72
|
+
|
|
73
|
+
| Written | Captured as | What it is |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `c.root` | `$` | the whole form document — cross-field is the point |
|
|
76
|
+
| `c.value` | `$value` | the field's current value; an absent field binds `null`, because `undefined` is not a JSON value |
|
|
77
|
+
| `c.pointer` | `$pointer` | the field's data pointer, `'/vatId'` |
|
|
78
|
+
|
|
79
|
+
`c.root` is the capture's document proxy; `c.value` and `c.pointer` are
|
|
80
|
+
the two EXTERNALS `compileFormRules` binds per evaluation. Any other
|
|
81
|
+
name is `JL0104` here, where the fix can be named, rather than a compile
|
|
82
|
+
error out of `compileFormRules` naming the same two — which is why the
|
|
83
|
+
message says "exactly 2 externals" and then names all three spellings
|
|
84
|
+
(§4.3).
|
|
85
|
+
|
|
86
|
+
Wherever a rule takes a callback it also takes a query document written
|
|
87
|
+
by hand, copied through the JSON boundary. That is the escape for a
|
|
88
|
+
query the chain cannot spell, and it is unchecked: the pen copies what
|
|
89
|
+
it is given, so `form({ visible: 42 })` emits `"visible": 42` and the
|
|
90
|
+
reader is what refuses it. Prefer the callback.
|
|
91
|
+
|
|
92
|
+
### 1.2 The mixin: new classes, never a patched prototype
|
|
93
|
+
|
|
94
|
+
`withForm(Base)` answers a NEW class — `class extends Base` carrying
|
|
95
|
+
`form()` and an overriding `meta()`. `./forms` applies it to the schema
|
|
96
|
+
pen's eight exported builder classes at module scope and hands the eight
|
|
97
|
+
results to `createFactories()`
|
|
98
|
+
([SCHEMA-PEN.md](SCHEMA-PEN.md#210-the-document-and-the-builder-itself)),
|
|
99
|
+
so the factory wiring exists exactly once and no subpath ever patches
|
|
100
|
+
another's prototype. `with()` rebuilds `new this.constructor(state)`, so
|
|
101
|
+
the subclass survives every chained method: `f.string().min(1).form({…})
|
|
102
|
+
.optional().describe('d')` is one `FormStringBuilder` throughout.
|
|
103
|
+
|
|
104
|
+
The mixin adds exactly two names, and one of them shadows a base method.
|
|
105
|
+
A sweep over all eight classes — for each method the mixin defines, is
|
|
106
|
+
that name an own property anywhere on the base's prototype chain? —
|
|
107
|
+
finds `form` free everywhere and `meta` shadowed on all eight, from
|
|
108
|
+
`SchemaBuilder`. That shadow is the intentional override of §2.2, and it
|
|
109
|
+
calls `super.meta()`; nothing else collides.
|
|
110
|
+
|
|
111
|
+
## 2. The mapping table
|
|
112
|
+
|
|
113
|
+
Every name `@jarenjs/linq/forms` exports that a caller writes, and every
|
|
114
|
+
method reachable on a builder it hands back — 31 in all. The eight
|
|
115
|
+
builder classes, the one constant and the one guard it also exports are
|
|
116
|
+
§5's, because a caller meets those through a type annotation, a subclass
|
|
117
|
+
or an `instanceof` narrow rather than by calling one.
|
|
118
|
+
|
|
119
|
+
The rows come in three kinds, and each subsection below says which:
|
|
120
|
+
|
|
121
|
+
| Kind | Count | What the row does |
|
|
122
|
+
|---|---:|---|
|
|
123
|
+
| **re-exported unchanged** | 27 | links `SCHEMA-PEN.md` §2's row and states nothing of its own — the emission, the `Infer`/`Input` reading and every refusal are the schema pen's, and the only difference is the CLASS that comes back |
|
|
124
|
+
| **re-exported and extended** | 1 | the schema pen's behaviour plus what this pen adds, stated here |
|
|
125
|
+
| **forms-only** | 3 | this pen's own, stated here in full |
|
|
126
|
+
|
|
127
|
+
That distribution is the honest shape of the pen and not an abridgement:
|
|
128
|
+
the forms pen IS the schema pen plus `x-form`, so a document that
|
|
129
|
+
restated 27 rows would be a second copy of `SCHEMA-PEN.md` §2 waiting to
|
|
130
|
+
drift from it. `test/linq/pen-docs.test.js` holds every one of the 31
|
|
131
|
+
names named somewhere in this section.
|
|
132
|
+
|
|
133
|
+
Status: **native** (emits the named keyword), **emulated** (a
|
|
134
|
+
composition with identical semantics), **refused** (a coded error naming
|
|
135
|
+
the reason).
|
|
136
|
+
|
|
137
|
+
### 2.1 Re-exported unchanged — 27 names
|
|
138
|
+
|
|
139
|
+
Each row is a family, and the link is where the family's members are
|
|
140
|
+
documented: their emission, their `Infer`/`Input` reading and the
|
|
141
|
+
`JL0101`/`JL0102`/`JL0103` each can raise. Nothing about them changes
|
|
142
|
+
here; what changes is the class of the builder that comes back, which is
|
|
143
|
+
the linked class plus `form()`.
|
|
144
|
+
|
|
145
|
+
| Names | Documented at | The class that comes back |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `string()`, `number()`, `integer()`, `boolean()`, `nil()`, `literal(v)`, `enumOf(values)`, `datetime()`, `date()`, `time()`, `duration()`, `any()`, `never()` | [SCHEMA-PEN.md §2.1](SCHEMA-PEN.md#21-primitives-literals-and-enums) | `FormStringBuilder`, `FormNumberBuilder`, `FormBuilder`, `FormNeverBuilder` |
|
|
148
|
+
| `object(props)`, `record(values)` | [SCHEMA-PEN.md §2.2](SCHEMA-PEN.md#22-objects) | `FormObjectBuilder`, `FormBuilder` |
|
|
149
|
+
| `array(items)`, `tuple(items)` | [SCHEMA-PEN.md §2.3](SCHEMA-PEN.md#23-arrays-and-tuples) | `FormArrayBuilder`, `FormTupleBuilder` |
|
|
150
|
+
| `union(options)`, `discriminated(key, options)`, `intersection(parts)`, `when(cond)` | [SCHEMA-PEN.md §2.6](SCHEMA-PEN.md#26-composition) | `FormBuilder`, `FormWhenBuilder` |
|
|
151
|
+
| `named(name, b)`, `ref(name)`, `lazy(thunk)`, `from(json)` | [SCHEMA-PEN.md §2.7](SCHEMA-PEN.md#27-references-and-defs) | `FormBuilder` (a `named()` builder is one of its own) |
|
|
152
|
+
| `document(root, { draft })`, `schemaOf(value)` | [SCHEMA-PEN.md §2.10](SCHEMA-PEN.md#210-the-document-and-the-builder-itself) | — (both answer a document, not a builder) |
|
|
153
|
+
|
|
154
|
+
This is the **grouped re-export row**, the row kind
|
|
155
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §5 defines: one row per family, linking
|
|
156
|
+
the schema pen's row rather than restating it, with a third column
|
|
157
|
+
carrying the one thing that IS different here. For this pen that column
|
|
158
|
+
is the class, because nothing else differs — the emission is byte for
|
|
159
|
+
byte the schema pen's. [MODEL-PEN.md
|
|
160
|
+
§2.5](MODEL-PEN.md#25-the-schema-pens-vocabulary-re-exported) uses the
|
|
161
|
+
same row kind with a status in that column instead, because there the
|
|
162
|
+
re-exports do gain behaviour.
|
|
163
|
+
|
|
164
|
+
Every builder METHOD the schema pen documents is reachable on these too
|
|
165
|
+
and behaves identically — the string constraints of
|
|
166
|
+
[§2.4](SCHEMA-PEN.md#24-strings), the number constraints of
|
|
167
|
+
[§2.5](SCHEMA-PEN.md#25-numbers), the annotations of
|
|
168
|
+
[§2.8](SCHEMA-PEN.md#28-annotations-and-messages) except `meta()`, and
|
|
169
|
+
the validation extensions of
|
|
170
|
+
[§2.9](SCHEMA-PEN.md#29-validation-extensions). Every one of the 26
|
|
171
|
+
factories, exercised with its own constraints and composed through both
|
|
172
|
+
pens, emits a byte-identical document — measured over all 26 while this
|
|
173
|
+
section was written, and the reason the links above are a claim rather
|
|
174
|
+
than a hope. Two gates hold the halves of it that can be gated:
|
|
175
|
+
`test/linq/forms-pen.test.js` rebuilds the forms README's own
|
|
176
|
+
hand-written JSON Schemas through this pen and asserts BYTE equality,
|
|
177
|
+
and `test/consumer/linq-app.ts` pins `Infer<>` over a ruled document
|
|
178
|
+
equal to the schema pen's reading of a rule-free one.
|
|
179
|
+
|
|
180
|
+
Two of the 27 carry a wrinkle worth reading before you meet it:
|
|
181
|
+
|
|
182
|
+
- **`never()`** answers `false`, and `false` carries no keywords at all —
|
|
183
|
+
so `form()` on it is `JL0102` exactly as `meta()`, `describe()` and
|
|
184
|
+
`message()` are ([§4.2](#42-jl0102--preview-and-the-boolean-schema)).
|
|
185
|
+
It does not compile either: the factory answers `FormNeverBuilder`,
|
|
186
|
+
which declares neither. `nullable()` is the way out and the message
|
|
187
|
+
says so.
|
|
188
|
+
- **`when()`** answers a `FormWhenBuilder`, whose `then()`/`else()` take
|
|
189
|
+
schemas — and which carries `form()` like every other class, because a
|
|
190
|
+
conditional used as a member is a field whose rules evaluate
|
|
191
|
+
([§5.5](#55-the-two-refusals-the-types-close-and-the-one-node-that-carries-a-rule-anyway)).
|
|
192
|
+
|
|
193
|
+
### 2.2 Re-exported and extended — `meta()`
|
|
194
|
+
|
|
195
|
+
One name whose behaviour this pen changes, and the reason it had to.
|
|
196
|
+
|
|
197
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
198
|
+
|---|---|---|---|
|
|
199
|
+
| `.meta(annotations)` | the keys verbatim, in the order first set — exactly [SCHEMA-PEN.md §2.8](SCHEMA-PEN.md#28-annotations-and-messages)'s behaviour | — | native; a schema-pen-owned keyword is `JL0104` there, and `'x-form'` is `JL0104` HERE: the forms pen owns that keyword, and the fix is to spell it through `form()` |
|
|
200
|
+
|
|
201
|
+
`meta()` is the one re-exported name whose own behaviour differs. The
|
|
202
|
+
schema pen owns the structural keywords, the constraints and the
|
|
203
|
+
annotations it writes itself; the forms pen owns one more, `x-form`, and
|
|
204
|
+
a rule written through `meta()` would bypass every check `form()` makes
|
|
205
|
+
— the member set, the message shape, the capture, the `preview`
|
|
206
|
+
refusal. The override closes that door and names the one that is open.
|
|
207
|
+
|
|
208
|
+
### 2.3 Forms-only — three names
|
|
209
|
+
|
|
210
|
+
The pen's own surface: the method that writes a rule, the call that turns
|
|
211
|
+
the rules into their submit-time twin, and the door a third pen comes in
|
|
212
|
+
by.
|
|
213
|
+
|
|
214
|
+
| Method | Emits | `Infer` / `Input` | Status |
|
|
215
|
+
|---|---|---|---|
|
|
216
|
+
| `.form({ visible?, enabled?, assert?, computed?, message? })` | one `x-form` annotation, its members in the README's own order whatever order the author wrote; a second call MERGES into the same annotation rather than replacing it | `this` — the builder's phantoms are untouched, because a rule is an annotation | native; a member `x-form` does not define, or a `message` that is neither a string nor a MessageSpec, `JL0101`; `preview` `JL0102`; a name the context does not bind `JL0104` |
|
|
217
|
+
| `withForm(Base)` | nothing: a NEW class, `Base` plus `form()` and the overriding `meta()`. The route by which a third pen — or a project's own vocabulary — carries `x-form` (§3.5) | `B` (the base class's own type) | native; a non-constructor argument is JavaScript's own `TypeError` from the `extends` clause, not a `LinqBuildError` |
|
|
218
|
+
| `assertOnSubmit(root)` | the root document with one `allOf` branch `{ $query, errorMessage }` per `x-form.assert` in it; a document with no assert answers ITSELF, because a needless `allOf` would be a second spelling of the same schema | `JsonSchema` | native; a value that is neither a builder nor an object schema is `JL0101` |
|
|
219
|
+
|
|
220
|
+
The four query-valued members of `form()`, and what a reader is deciding
|
|
221
|
+
between:
|
|
222
|
+
|
|
223
|
+
| Member | Kind | Emits | The reader's question |
|
|
224
|
+
|---|---|---|---|
|
|
225
|
+
| `visible` | EBV query | `x-form.visible` | should the field be shown? A broken rule fails **open** — it must never hide data |
|
|
226
|
+
| `enabled` | EBV query | `x-form.enabled` | should the field accept input? Fails **open**, same reason |
|
|
227
|
+
| `assert` | EBV query | `x-form.assert` | a cross-field preemptive assertion. Fails **closed**: an assertion that cannot be computed has not been satisfied |
|
|
228
|
+
| `computed` | query | `x-form.computed` | the field's derived value, mapped to plain JSON. A failure leaves the value absent |
|
|
229
|
+
| `message` | string or MessageSpec | `x-form.message`, verbatim | what an `assert` failure renders — an inline template, or `{ $msgid, message?, params? }` for the catalog |
|
|
230
|
+
|
|
231
|
+
The EBV rule and the three failure policies are the reader's, not the
|
|
232
|
+
pen's: the pen emits the query and `evaluateFormRules` applies them
|
|
233
|
+
(the forms README, layer 2). A rule on an array ITEM template lands on
|
|
234
|
+
the item schema, which is where `buildFormModel` expects it — it
|
|
235
|
+
compiles once at `/lines/-/amount` and evaluates per element, keyed by
|
|
236
|
+
the expanded pointer (§3.3).
|
|
237
|
+
|
|
238
|
+
### 2.4 What the submit twin keeps
|
|
239
|
+
|
|
240
|
+
`assertOnSubmit()` walks the structural spine `buildFormModel` walks —
|
|
241
|
+
`properties`, `prefixItems`, `items`, `allOf` — and copies every
|
|
242
|
+
`x-form.assert` onto the ROOT as its own branch. It does NOT resolve
|
|
243
|
+
`$ref`s: a definition's data location depends on its use site, and a
|
|
244
|
+
`$query` branch has to name one.
|
|
245
|
+
|
|
246
|
+
Four rules travel with each copy, and each is a place a naive copy went
|
|
247
|
+
wrong (the README's own list):
|
|
248
|
+
|
|
249
|
+
- **an absent field binds `null`**, through `$default`, so `$ne` and
|
|
250
|
+
`$eq` cannot mean opposite things on the two sides;
|
|
251
|
+
- **an item-template assert quantifies over the ELEMENTS** (`$every` /
|
|
252
|
+
`$satisfies` over `[*]`, one level per array crossed) rather than over
|
|
253
|
+
the selected leaves, so an element missing the member is evaluated
|
|
254
|
+
with `null` exactly as the keystroke path evaluates it;
|
|
255
|
+
- **an assert on a field that also declares `visible` is guarded by it**
|
|
256
|
+
— `{ $or: [{ $not: visible }, assert] }` — so it holds vacuously while
|
|
257
|
+
the field is hidden, which is what the keystroke path already does
|
|
258
|
+
since `buildFormViewModel` drops hidden nodes;
|
|
259
|
+
- **the message travels with the branch**, as
|
|
260
|
+
`errorMessage.$query` with `params` merged over `{ pointer }`, so
|
|
261
|
+
submit renders the same text in every locale. A rule with no message
|
|
262
|
+
gets the catalog default, `{ $msgid: 'x-form/assert' }`.
|
|
263
|
+
|
|
264
|
+
The branches land on the ROOT, where `$` is the instance root the rule
|
|
265
|
+
context expects. §3.4 shows one end to end.
|
|
266
|
+
|
|
267
|
+
### 2.5 What the pen does not judge
|
|
268
|
+
|
|
269
|
+
What the pen does **not** judge is the reader's: a root-level `visible`
|
|
270
|
+
(a `TypeError` out of `compileFormRules` — hiding the whole form would
|
|
271
|
+
null the render tree and its dirty summary), the rules' own operators,
|
|
272
|
+
the EBV of a multi-item sequence (`JQ2003` at evaluation), and the
|
|
273
|
+
schema's own semantics. It emits those documents and `@jarenjs/forms`
|
|
274
|
+
refuses them; the pen test builds each through the pen and asserts the
|
|
275
|
+
reader's behaviour.
|
|
276
|
+
|
|
277
|
+
## 3. Worked examples
|
|
278
|
+
|
|
279
|
+
Every `js` fence exports exactly one document, and the `json` fence that
|
|
280
|
+
follows is what the pen emits — executed by
|
|
281
|
+
`test/linq/pen-docs.test.js`. The forms README's own layer-2 document
|
|
282
|
+
and its opening usage schema are rebuilt the same way and held
|
|
283
|
+
BYTE-equal to their fences by `test/linq/forms-pen.test.js`.
|
|
284
|
+
|
|
285
|
+
### 3.1 One field, one rule
|
|
286
|
+
|
|
287
|
+
**The checkout's terms box.** The smallest thing the pen writes that the schema pen cannot: a
|
|
288
|
+
checkbox that must be ticked, with the message its failure renders.
|
|
289
|
+
Everything except `x-form` is the schema pen's document.
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
import * as f from '@jarenjs/linq/forms';
|
|
293
|
+
|
|
294
|
+
export const consent = f.object({
|
|
295
|
+
newsletter: f.boolean().default(false),
|
|
296
|
+
terms: f.boolean().form({
|
|
297
|
+
assert: (c) => c.value.eq(true),
|
|
298
|
+
message: { $msgid: 'checkout.terms-required', message: 'Please accept the terms' },
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"type": "object",
|
|
305
|
+
"properties": {
|
|
306
|
+
"newsletter": { "type": "boolean", "default": false },
|
|
307
|
+
"terms": {
|
|
308
|
+
"type": "boolean",
|
|
309
|
+
"x-form": {
|
|
310
|
+
"assert": { "$eq": ["$value", true] },
|
|
311
|
+
"message": { "$msgid": "checkout.terms-required", "message": "Please accept the terms" }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
"required": ["newsletter", "terms"],
|
|
316
|
+
"additionalProperties": false
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`required` still lists both members and `additionalProperties` is still
|
|
321
|
+
`false`: the annotation moved nothing. Drop the `x-form` node from the
|
|
322
|
+
emitted document and what is left is byte-identical to the same object
|
|
323
|
+
built through `@jarenjs/linq/schema` — which is what
|
|
324
|
+
`test/linq/forms-pen.test.js` asserts, member for member.
|
|
325
|
+
|
|
326
|
+
### 3.2 The rule context: `root`, `value`, `pointer`, and nothing else
|
|
327
|
+
|
|
328
|
+
**The shipping address.** All three names in one document, and each in the position a reader will
|
|
329
|
+
want it: `c.root` for a cross-field read, `c.value` for the field's own,
|
|
330
|
+
`c.pointer` for a rule that needs to name itself.
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
import * as f from '@jarenjs/linq/forms';
|
|
334
|
+
|
|
335
|
+
export const shipping = f.object({
|
|
336
|
+
country: f.string().enumOf(['NL', 'BE', 'DE']),
|
|
337
|
+
postcode: f.string().form({
|
|
338
|
+
visible: (c) => c.root.country.ne(''),
|
|
339
|
+
enabled: (c) => c.root.country.eq('NL'),
|
|
340
|
+
assert: (c) => c.value.matches('^[0-9]{4} ?[A-Z]{2}$'),
|
|
341
|
+
message: { $msgid: 'address.postcode' },
|
|
342
|
+
}),
|
|
343
|
+
audit: f.string().optional().form({ computed: (c) => c.pointer }),
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
```json
|
|
347
|
+
{
|
|
348
|
+
"type": "object",
|
|
349
|
+
"properties": {
|
|
350
|
+
"country": { "type": "string", "enum": ["NL", "BE", "DE"] },
|
|
351
|
+
"postcode": {
|
|
352
|
+
"type": "string",
|
|
353
|
+
"x-form": {
|
|
354
|
+
"visible": { "$ne": ["$.country", ""] },
|
|
355
|
+
"enabled": { "$eq": ["$.country", "NL"] },
|
|
356
|
+
"assert": { "$match": ["$value", "^[0-9]{4} ?[A-Z]{2}$"] },
|
|
357
|
+
"message": { "$msgid": "address.postcode" }
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
"audit": { "type": "string", "x-form": { "computed": "$pointer" } }
|
|
361
|
+
},
|
|
362
|
+
"required": ["country", "postcode"],
|
|
363
|
+
"additionalProperties": false
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`c.root.country` captured as `"$.country"` — a path into the whole
|
|
368
|
+
document, not into the field. `c.value` and `c.pointer` captured as the
|
|
369
|
+
two externals. A fourth name is `JL0104` at this line, not at
|
|
370
|
+
`compileFormRules` time: `(c) => c.previous.ne(c.value)` never reaches a
|
|
371
|
+
document.
|
|
372
|
+
|
|
373
|
+
The members come out in the README's order — `visible`, `enabled`,
|
|
374
|
+
`assert`, `computed`, `message` — whatever order the author wrote them
|
|
375
|
+
in, so two authors writing the same rule write the same bytes.
|
|
376
|
+
|
|
377
|
+
### 3.3 A cross-field form: visibility, an assert, an item template and a computed total
|
|
378
|
+
|
|
379
|
+
**The invoice block.** A VAT id visible only for companies and required when one is
|
|
380
|
+
named, a per-line assert on the item TEMPLATE, and a total derived from
|
|
381
|
+
the lines.
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
import * as f from '@jarenjs/linq/forms';
|
|
385
|
+
|
|
386
|
+
export const invoice = f.object({
|
|
387
|
+
company: f.string().optional(),
|
|
388
|
+
vatId: f.string().optional().form({
|
|
389
|
+
visible: (c) => c.root.company.ne(''),
|
|
390
|
+
assert: (c) => c.root.company.eq('').or(c.value.ne('')),
|
|
391
|
+
message: { $msgid: 'checkout.vat-required', message: 'A VAT id is required for companies' },
|
|
392
|
+
}),
|
|
393
|
+
lines: f.array(f.object({
|
|
394
|
+
amount: f.number().form({ assert: (c) => c.value.gt(0), message: 'Every line must be positive' }),
|
|
395
|
+
})).default([]),
|
|
396
|
+
total: f.number().optional().form({ computed: (c) => c.root.lines.all().amount.sum() }),
|
|
397
|
+
});
|
|
398
|
+
```
|
|
399
|
+
```json
|
|
400
|
+
{
|
|
401
|
+
"type": "object",
|
|
402
|
+
"properties": {
|
|
403
|
+
"company": { "type": "string" },
|
|
404
|
+
"vatId": { "type": "string", "x-form": {
|
|
405
|
+
"visible": { "$ne": ["$.company", ""] },
|
|
406
|
+
"assert": { "$or": [{ "$eq": ["$.company", ""] }, { "$ne": ["$value", ""] }] },
|
|
407
|
+
"message": { "$msgid": "checkout.vat-required", "message": "A VAT id is required for companies" } } },
|
|
408
|
+
"lines": { "type": "array", "items": {
|
|
409
|
+
"type": "object",
|
|
410
|
+
"properties": { "amount": { "type": "number", "x-form": {
|
|
411
|
+
"assert": { "$gt": ["$value", 0] },
|
|
412
|
+
"message": "Every line must be positive" } } },
|
|
413
|
+
"required": ["amount"],
|
|
414
|
+
"additionalProperties": false }, "default": [] },
|
|
415
|
+
"total": { "type": "number", "x-form": { "computed": { "$sum": "$.lines[*].amount" } } }
|
|
416
|
+
},
|
|
417
|
+
"required": ["lines"],
|
|
418
|
+
"additionalProperties": false
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Three things a reader should take from this document:
|
|
423
|
+
|
|
424
|
+
- **The item rule sits on the item schema**, once, at
|
|
425
|
+
`/lines/-/amount`. `buildFormModel` finds it there and
|
|
426
|
+
`compileFormRules` compiles it once; `evaluateFormRules` binds
|
|
427
|
+
`$value`/`$pointer` per element and keys the results by the expanded
|
|
428
|
+
pointer (`/lines/2/amount`). Writing the rule on the ARRAY instead
|
|
429
|
+
would bind `$value` to the whole list.
|
|
430
|
+
- **`assert` and `visible` are one rule, not two.** The assert is
|
|
431
|
+
written as if the field were always shown; §3.4 is what makes it
|
|
432
|
+
vacuous while it is hidden, and the keystroke path already does the
|
|
433
|
+
same by dropping hidden nodes from the view model.
|
|
434
|
+
- **`computed` reads the document, not the field.**
|
|
435
|
+
`c.root.lines.all().amount.sum()` is a chain over `$`, captured as
|
|
436
|
+
`{ "$sum": "$.lines[*].amount" }` — the field it sits on contributes
|
|
437
|
+
only where the result lands.
|
|
438
|
+
|
|
439
|
+
### 3.4 The same rule on submit
|
|
440
|
+
|
|
441
|
+
**The same invoice block, on submit.** One call, over the document the pen just wrote. The branch the validator
|
|
442
|
+
enforces carries the `visible` guard, the `null` binding and the
|
|
443
|
+
message:
|
|
444
|
+
|
|
445
|
+
```js
|
|
446
|
+
import * as f from '@jarenjs/linq/forms';
|
|
447
|
+
import { assertOnSubmit } from '@jarenjs/linq/forms';
|
|
448
|
+
|
|
449
|
+
export const submit = assertOnSubmit(f.object({
|
|
450
|
+
company: f.string().optional(),
|
|
451
|
+
vatId: f.string().optional().form({
|
|
452
|
+
visible: (c) => c.root.company.ne(''),
|
|
453
|
+
assert: (c) => c.root.company.eq('').or(c.value.ne('')),
|
|
454
|
+
message: 'VAT id is required for companies',
|
|
455
|
+
}),
|
|
456
|
+
}));
|
|
457
|
+
```
|
|
458
|
+
```json
|
|
459
|
+
{
|
|
460
|
+
"type": "object",
|
|
461
|
+
"properties": {
|
|
462
|
+
"company": { "type": "string" },
|
|
463
|
+
"vatId": { "type": "string", "x-form": {
|
|
464
|
+
"visible": { "$ne": ["$.company", ""] },
|
|
465
|
+
"assert": { "$or": [{ "$eq": ["$.company", ""] }, { "$ne": ["$value", ""] }] },
|
|
466
|
+
"message": "VAT id is required for companies" } }
|
|
467
|
+
},
|
|
468
|
+
"additionalProperties": false,
|
|
469
|
+
"allOf": [
|
|
470
|
+
{ "$query": {
|
|
471
|
+
"$let": { "value": { "$default": ["$['vatId']", { "$const": null }] },
|
|
472
|
+
"pointer": { "$const": "/vatId" } },
|
|
473
|
+
"$return": { "$or": [
|
|
474
|
+
{ "$not": { "$ne": ["$.company", ""] } },
|
|
475
|
+
{ "$or": [{ "$eq": ["$.company", ""] }, { "$ne": ["$value", ""] }] }] } },
|
|
476
|
+
"errorMessage": { "$query": {
|
|
477
|
+
"message": "VAT id is required for companies",
|
|
478
|
+
"params": { "pointer": "/vatId" } } } }
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Read the branch outward: `$let` binds the two externals the keystroke
|
|
484
|
+
path binds — `$value` through `$default` so an absent `vatId` is `null`,
|
|
485
|
+
`$pointer` as a constant — and `$return` is the `visible` guard
|
|
486
|
+
`$or`-ed with the authored assert. The `x-form` node is untouched, so
|
|
487
|
+
one document serves both paths, and `assertOnSubmit()` over a document
|
|
488
|
+
with no assert answers that document unchanged.
|
|
489
|
+
|
|
490
|
+
### 3.5 `withForm()` over your own builder class
|
|
491
|
+
|
|
492
|
+
**The shop's own VAT string kind.** The rules ride on every builder because `./forms` built its eight
|
|
493
|
+
classes with the mixin. A project that wants its own vocabulary BESIDE
|
|
494
|
+
them takes the same route: subclass a schema-pen class, wrap the eight
|
|
495
|
+
in `withForm()`, and hand them to `createFactories()`.
|
|
496
|
+
|
|
497
|
+
```js
|
|
498
|
+
import { withForm } from '@jarenjs/linq/forms';
|
|
499
|
+
import {
|
|
500
|
+
ArrayBuilder, NeverBuilder, NumberBuilder, ObjectBuilder, SchemaBuilder,
|
|
501
|
+
StringBuilder, TupleBuilder, WhenBuilder, createFactories,
|
|
502
|
+
} from '@jarenjs/linq/schema';
|
|
503
|
+
|
|
504
|
+
/** the project's own string kind — one method, spelled once */
|
|
505
|
+
class VatStringBuilder extends StringBuilder {
|
|
506
|
+
vat() { return this.pattern('^[A-Z]{2}[0-9A-Z]{2,12}$'); }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const p = createFactories({
|
|
510
|
+
Base: withForm(SchemaBuilder), String: withForm(VatStringBuilder),
|
|
511
|
+
Number: withForm(NumberBuilder), Array: withForm(ArrayBuilder),
|
|
512
|
+
Tuple: withForm(TupleBuilder), Object: withForm(ObjectBuilder),
|
|
513
|
+
When: withForm(WhenBuilder), Never: withForm(NeverBuilder),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
export const supplier = p.object({
|
|
517
|
+
company: p.string().optional(),
|
|
518
|
+
vatId: p.string().vat().optional().form({ visible: (c) => c.root.company.ne('') }),
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
```json
|
|
522
|
+
{
|
|
523
|
+
"type": "object",
|
|
524
|
+
"properties": {
|
|
525
|
+
"company": { "type": "string" },
|
|
526
|
+
"vatId": {
|
|
527
|
+
"type": "string",
|
|
528
|
+
"pattern": "^[A-Z]{2}[0-9A-Z]{2,12}$",
|
|
529
|
+
"x-form": { "visible": { "$ne": ["$.company", ""] } }
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
"additionalProperties": false
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
`.vat()` and `.form()` chain in either order and the class survives
|
|
537
|
+
both, because every schema-pen method is written in terms of `with()`,
|
|
538
|
+
which rebuilds `new this.constructor(state)`. This is also exactly how
|
|
539
|
+
`./forms` and `./model` exist — neither patches a prototype, and
|
|
540
|
+
`createFactories` is the wiring both call.
|
|
541
|
+
|
|
542
|
+
The mixin is a class expression, so `withForm(42)` is JavaScript's own
|
|
543
|
+
`TypeError` from the `extends` clause rather than a `LinqBuildError`:
|
|
544
|
+
it is a build-your-own-pen call, not part of a document's authoring
|
|
545
|
+
path.
|
|
546
|
+
|
|
547
|
+
### 3.6 A format that DERIVES its preview
|
|
548
|
+
|
|
549
|
+
**The delivery parcel, drawn on a map.** `preview` is not something an author writes. The registry carries it
|
|
550
|
+
against the FORMAT, and `buildFormModel` reads it from there — so what
|
|
551
|
+
this pen emits for a previewable field is a plain `format`:
|
|
552
|
+
|
|
553
|
+
```js
|
|
554
|
+
import * as f from '@jarenjs/linq/forms';
|
|
555
|
+
|
|
556
|
+
export const parcel = f.object({
|
|
557
|
+
label: f.string(),
|
|
558
|
+
area: f.string().format('geojson').describe('Draw the parcel'),
|
|
559
|
+
});
|
|
560
|
+
```
|
|
561
|
+
```json
|
|
562
|
+
{
|
|
563
|
+
"type": "object",
|
|
564
|
+
"properties": {
|
|
565
|
+
"label": { "type": "string" },
|
|
566
|
+
"area": { "type": "string", "format": "geojson", "description": "Draw the parcel" }
|
|
567
|
+
},
|
|
568
|
+
"required": ["label", "area"],
|
|
569
|
+
"additionalProperties": false
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
No `x-form` at all — and the field descriptor `buildFormModel` answers
|
|
574
|
+
for `/area` carries the hint anyway, out of `getFormatInfo('geojson')`:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{
|
|
578
|
+
"pointer": "/area",
|
|
579
|
+
"control": "textarea",
|
|
580
|
+
"placeholder": "{\"type\":\"Point\",\"coordinates\":[4.9,52.4]}",
|
|
581
|
+
"preview": { "kind": "map" }
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
(The second `json` fence is engine OUTPUT, not the pen's emission; the
|
|
586
|
+
docs gate pairs the first with the `js` fence above and reads this one
|
|
587
|
+
only as JSON.) `form({ preview: … })` is `JL0102`
|
|
588
|
+
([§4.2](#42-jl0102--preview-and-the-boolean-schema)) because a hand-written
|
|
589
|
+
`preview` would be a keyword nothing reads: `buildFormModel` never looks
|
|
590
|
+
in `x-form` for one. Spell the format, and a host that understands
|
|
591
|
+
`preview.kind === 'map'` draws the map beside the control while a host
|
|
592
|
+
that does not gets exactly the textarea it always had. `geojson` is the
|
|
593
|
+
only format in the registry carrying a preview today.
|
|
594
|
+
|
|
595
|
+
## 4. Refusals
|
|
596
|
+
|
|
597
|
+
The forms pen raises these `LinqBuildError` codes and no others —
|
|
598
|
+
`test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
599
|
+
to the codes `packages/linq/src/forms/` throws. The full condition each
|
|
600
|
+
code states across every pen is the binder's,
|
|
601
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
602
|
+
|
|
603
|
+
| Code | What this pen raises it for |
|
|
604
|
+
|---|---|
|
|
605
|
+
| `JL0101` | a value this pen cannot spell, or a name → value map it cannot read |
|
|
606
|
+
| `JL0102` | a construct the format cannot carry |
|
|
607
|
+
| `JL0104` | a pen-owned keyword written through `meta()`, or an external a captured rule did not declare |
|
|
608
|
+
|
|
609
|
+
Thirteen sites, the fewest of any pen — which is the same fact §2 states
|
|
610
|
+
from the other side: 27 of 31 names refuse what
|
|
611
|
+
[SCHEMA-PEN.md §4](SCHEMA-PEN.md#4-refusals) says they refuse, and only
|
|
612
|
+
four names have refusals of their own. Every message below is the one
|
|
613
|
+
the pen raised when the spelling beside it was run, with the code prefix
|
|
614
|
+
removed. `docPath`, where the refusal carries one, is the JSON pointer
|
|
615
|
+
of the node being assembled and is appended to the message text as well
|
|
616
|
+
(`… at /message`).
|
|
617
|
+
|
|
618
|
+
### 4.1 `JL0101` — the value, the member and the message
|
|
619
|
+
|
|
620
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
621
|
+
|---|---|---|
|
|
622
|
+
| `f.string().form(42)`, `.form(null)`, `.form([])` | `form() takes { visible?, enabled?, assert?, computed?, message? }, got 42` | a plain object of rule members |
|
|
623
|
+
| `f.string().form({ nope: 1 })` | `form() does not take 'nope' — x-form defines visible, enabled, assert, computed, message` | one of the five |
|
|
624
|
+
| `f.string().form({ message: 42 })`, `{ message: [] }` | `form() message is an inline string or a MessageSpec { $msgid?, message?, params? }, got 42` | `'text'`, or `{ $msgid, message?, params? }` |
|
|
625
|
+
| `f.string().form({ message: {} })`, `{ message: { params: {} } }` | `form() message as a MessageSpec needs '$msgid' and/or 'message'` | give the spec a `$msgid`, a `message`, or both |
|
|
626
|
+
| `f.string().form({ visible: { a: undefined } })` | `form() visible received a Object instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a JSON value, or a callback |
|
|
627
|
+
| `f.assertOnSubmit(42)`, `([])`, `(true)` | `assertOnSubmit() takes the document's root builder or its schema object, got 42` | the root builder, or its `schema` |
|
|
628
|
+
|
|
629
|
+
The message rule is worth stating in full, because a reader meets it the
|
|
630
|
+
first time they reach for a translated string. `message` is the ONE
|
|
631
|
+
member of `form()` that is not a query: it is copied verbatim, either as
|
|
632
|
+
an inline template string or as a MessageSpec object, and a spec must
|
|
633
|
+
carry at least one of `$msgid` and `message` — a spec with only `params`
|
|
634
|
+
names no text to render, and rendering nothing is not a message. Every
|
|
635
|
+
other member is captured or copied through the JSON boundary, so the
|
|
636
|
+
`requireJson` refusal above is reachable under all four of them.
|
|
637
|
+
|
|
638
|
+
The `__proto__` case is the binder's rule 5 and is unchanged here —
|
|
639
|
+
`f.object({ __proto__: f.string() })` is
|
|
640
|
+
`object() received a map whose prototype was replaced: …`, and the
|
|
641
|
+
spelling that works is the computed key `{ ['__proto__']: … }`. The full
|
|
642
|
+
paragraph is [SCHEMA-PEN.md §4.1](SCHEMA-PEN.md#41-jl0101--the-value-or-the-map);
|
|
643
|
+
`test/linq/pen-docs.test.js` proves it on this pen's `object()` as well.
|
|
644
|
+
|
|
645
|
+
### 4.2 `JL0102` — `preview`, and the boolean schema
|
|
646
|
+
|
|
647
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
648
|
+
|---|---|---|
|
|
649
|
+
| `f.string().form({ preview: { kind: 'map' } })` | `form() cannot write 'preview' — a field's preview hint is DERIVED from its format by the registry (getFormatInfo(format).preview), never authored, and buildFormModel reads it from there; spell the format instead, and a host that understands the hint draws it beside the control` | `f.string().format('geojson')` |
|
|
650
|
+
| `f.never().form({ visible: … })` | `never() is the boolean schema false, which carries no 'x-form' — annotate the member that holds it, or nullable() it first` | rule the member that HOLDS the `never()`, or `f.never().nullable().form({ … })` |
|
|
651
|
+
|
|
652
|
+
**`preview` is a derivation, and the refusal is where a reader learns
|
|
653
|
+
that.** The registry answers `getFormatInfo('geojson').preview` as
|
|
654
|
+
`{ kind: 'map' }`; `buildFormModel` copies it onto the field and
|
|
655
|
+
`buildFormViewModel` onto the view node. `x-form` has no `preview`
|
|
656
|
+
member and nothing reads one, so a hand-written `preview` would be
|
|
657
|
+
silently inert — the worst failure a form can have, because the field
|
|
658
|
+
renders and the map does not and no error says why. The pen refuses it
|
|
659
|
+
instead and names the fix: set the `format`. §3.6 shows both halves.
|
|
660
|
+
|
|
661
|
+
**`never()` is `false`, and `false` carries nothing.** The refusal is
|
|
662
|
+
the schema pen's `annotate()` rule reaching through the mixin, and it
|
|
663
|
+
fires for `form()` for the same reason it fires for `meta()`,
|
|
664
|
+
`describe()`, `title()`, `example()` and `message()`: there is no object
|
|
665
|
+
to hang a keyword on. `nullable()` widens the node to
|
|
666
|
+
`{ anyOf: [false, { type: 'null' }] }` — an object — and every
|
|
667
|
+
annotation is legal again from there. Order matters:
|
|
668
|
+
`f.never().form({ … }).nullable()` still refuses, because the refusal is
|
|
669
|
+
raised at the `form()` call.
|
|
670
|
+
|
|
671
|
+
Typed code does not reach either refusal: `never()` answers a
|
|
672
|
+
`FormNeverBuilder`, which declares no `form()` and inherits a `meta()`
|
|
673
|
+
nothing is assignable to, so both spellings are compile errors as well
|
|
674
|
+
([§5.5](#55-the-two-refusals-the-types-close-and-the-one-node-that-carries-a-rule-anyway)).
|
|
675
|
+
|
|
676
|
+
### 4.3 `JL0104` — the owned keyword, and the unbound name
|
|
677
|
+
|
|
678
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
679
|
+
|---|---|---|
|
|
680
|
+
| `f.string().meta({ 'x-form': { visible: '$.a' } })` | `meta() cannot write 'x-form' — the forms pen owns that keyword; spell it through form()` | `f.string().form({ visible: … })` |
|
|
681
|
+
| `f.string().form({ assert: (c) => c.nope })` | `a form() assert rule cannot bind 'nope' — its query evaluates with exactly 2 externals, 'value' and 'pointer'; anything else has nothing to bind to — the document being edited is the context's root (c.root), the field's own value c.value and its pointer c.pointer` | `c.root`, `c.value`, `c.pointer` |
|
|
682
|
+
|
|
683
|
+
The unbound-name message opens with the shared capture's own sentence —
|
|
684
|
+
"exactly 2 externals" is a count of the EXTERNALS `compileFormRules`
|
|
685
|
+
binds, which is two — and then names all three spellings the context
|
|
686
|
+
answers, because `c.root` is the document proxy rather than an external
|
|
687
|
+
and a reader counting names would otherwise be one short. The refusal
|
|
688
|
+
fires under all four query members, with the member's name in the
|
|
689
|
+
message (`a form() visible rule …`, `a form() computed rule …`).
|
|
690
|
+
|
|
691
|
+
The schema pen's own `meta()` refusal is still here beside this one:
|
|
692
|
+
`f.string().meta({ type: 'x' })` is
|
|
693
|
+
`meta() cannot write 'type' — the pen owns that keyword; …`. The two
|
|
694
|
+
messages differ by one word — "the forms pen" against "the pen" — which
|
|
695
|
+
is how a reader tells which pen refused them.
|
|
696
|
+
|
|
697
|
+
## 5. The types
|
|
698
|
+
|
|
699
|
+
§3.3's invoice block again, this time with the `Doc` annotation that types
|
|
700
|
+
its three context names — and read through both readers, so the schema
|
|
701
|
+
half and the rule half are visible together.
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
import { buildFormModel, compileFormRules, evaluateFormRules } from '@jarenjs/forms';
|
|
705
|
+
import { JarenValidator } from '@jarenjs/validate';
|
|
706
|
+
import * as f from '@jarenjs/linq/forms';
|
|
707
|
+
import { assertOnSubmit, isSchemaBuilder, SCHEMA_BUILDER } from '@jarenjs/linq/forms';
|
|
708
|
+
import type { FormRules, MessageSpec, Rule, RuleContext } from '@jarenjs/linq/forms';
|
|
709
|
+
import type { Infer, Input } from '@jarenjs/linq/schema';
|
|
710
|
+
|
|
711
|
+
type Doc = { company?: string; vatId?: string; lines: { amount: number }[] };
|
|
712
|
+
|
|
713
|
+
const invoice = f.object({
|
|
714
|
+
company: f.string().optional(),
|
|
715
|
+
vatId: f.string().optional().form<Doc>({
|
|
716
|
+
visible: (c) => c.root.company.ne(''),
|
|
717
|
+
assert: (c) => c.root.company.eq('').or(c.value.ne('')),
|
|
718
|
+
message: 'VAT id is required for companies',
|
|
719
|
+
}),
|
|
720
|
+
lines: f.array(f.object({
|
|
721
|
+
amount: f.number().form<Doc>({ assert: (c) => c.value.gt(0), message: 'positive' }),
|
|
722
|
+
})).default([]),
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
type Invoice = Infer<typeof invoice>; // the schema pen's reading, unchanged
|
|
726
|
+
type InvoiceIn = Input<typeof invoice>; // and so is the accepted twin
|
|
727
|
+
|
|
728
|
+
const model = buildFormModel(invoice.schema);
|
|
729
|
+
evaluateFormRules(compileFormRules(model), data); // per keystroke
|
|
730
|
+
new JarenValidator().compile(assertOnSubmit(invoice)); // on submit
|
|
731
|
+
|
|
732
|
+
const annotated = (c: RuleContext<Doc, string>) => c.root.company.ne('');
|
|
733
|
+
const rules: FormRules<Doc, string> = { visible: annotated };
|
|
734
|
+
const msg: MessageSpec = { $msgid: 'address.postcode' };
|
|
735
|
+
const branded: boolean = isSchemaBuilder(invoice);
|
|
736
|
+
const brand: symbol = SCHEMA_BUILDER;
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### 5.1 `Infer<>` and `Input<>` are the schema pen's
|
|
740
|
+
|
|
741
|
+
`Infer<typeof invoice>` is
|
|
742
|
+
`{ company?: string; vatId?: string; lines: { amount: number }[] }` —
|
|
743
|
+
the same reading `@jarenjs/linq/schema` gives the same builders, with no
|
|
744
|
+
trace of the rules. That is the type-level statement of the annotation
|
|
745
|
+
rule: a rule cannot make a member optional, cannot narrow it and cannot
|
|
746
|
+
add one, so it cannot appear in the inferred shape. The eight classes
|
|
747
|
+
re-declare `optional()`, `nullable()`, `default()`, `coerce()` and the
|
|
748
|
+
kind methods only to keep their OWN class through the chain — the
|
|
749
|
+
phantoms they pass along are `SchemaBuilder`'s, unchanged, and
|
|
750
|
+
[SCHEMA-PEN.md §5.1](SCHEMA-PEN.md#51-the-phantoms-and-the-flags)
|
|
751
|
+
documents them.
|
|
752
|
+
|
|
753
|
+
`test/consumer/linq-app.ts` pins both halves: `Infer<typeof Invoice>`
|
|
754
|
+
EQUAL (not merely assignable) to the hand-written shape, and the same
|
|
755
|
+
equality for a plain schema-pen object beside it.
|
|
756
|
+
|
|
757
|
+
### 5.2 The rule context, typed
|
|
758
|
+
|
|
759
|
+
`RuleContext<Doc, Value>` carries the three names §1.1 lists:
|
|
760
|
+
`root: MemberExpr<Doc>`, `value: MemberExpr<Value>` and
|
|
761
|
+
`pointer: StringExpr`. `Value` comes from the builder the rule sits on,
|
|
762
|
+
so `f.number().form({ assert: (c) => c.value.gt(0) })` types `c.value`
|
|
763
|
+
as a number expression with no annotation at all.
|
|
764
|
+
|
|
765
|
+
`Doc` does not, and cannot. A member builder is written before the
|
|
766
|
+
object that will hold it exists, so `c.root` is the honest top until the
|
|
767
|
+
callback is annotated — the same limit
|
|
768
|
+
[FLOW-PEN.md](FLOW-PEN.md)'s `context` meets, and TypeScript's own.
|
|
769
|
+
Two spellings do it, and the first is shorter:
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
f.string().form<Doc>({ visible: (c) => c.root.company.ne('') });
|
|
773
|
+
f.string().form({ visible: (c: RuleContext<Doc, string>) => c.root.company.ne('') });
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
Annotated, a member the document does not declare stops compiling:
|
|
777
|
+
`c.root.firm` is `Property 'firm' does not exist`, which
|
|
778
|
+
`test/consumer/linq-app.ts` pins with a `@ts-expect-error`.
|
|
779
|
+
|
|
780
|
+
`Rule<Doc, Value>` is the union of the callback and a hand-written query
|
|
781
|
+
document; `FormRules<Doc, Value>` is the five-member spec, and it has no
|
|
782
|
+
`preview` member on purpose — so `f.string().form({ preview })` fails to
|
|
783
|
+
compile as well as raising `JL0102`, which is the pin two lines below
|
|
784
|
+
that one in the same file.
|
|
785
|
+
|
|
786
|
+
### 5.3 The eight builder classes, the constant and the guard
|
|
787
|
+
|
|
788
|
+
Ten exports are surface a caller does not CALL, which is why none of
|
|
789
|
+
them is in §2:
|
|
790
|
+
|
|
791
|
+
| Export | What a caller meets it as |
|
|
792
|
+
|---|---|
|
|
793
|
+
| `FormBuilder` | the base every untyped kind is built from — `literal()`, `enumOf()`, `record()`, `union()`, `discriminated()`, `intersection()`, `ref()`, `lazy()`, `any()`, `from()`, `boolean()`, `nil()` all answer one. A type annotation, and the class a pen built over this one subclasses |
|
|
794
|
+
| `FormStringBuilder`, `FormNumberBuilder`, `FormArrayBuilder`, `FormTupleBuilder`, `FormObjectBuilder`, `FormWhenBuilder`, `FormNeverBuilder` | the seven kinds with their own methods; annotations, `instanceof` narrows, and the classes `createFactories` was handed |
|
|
795
|
+
| `SCHEMA_BUILDER` | the brand key, re-exported from the schema pen — a `Symbol.for` registry symbol, so a forms builder is a schema builder to every consumer that reads the brand |
|
|
796
|
+
| `isSchemaBuilder(value)` | the same guard, re-exported: `true` for any builder of either pen, `false` for a data object that merely carries a `toJSON` member |
|
|
797
|
+
|
|
798
|
+
The brand and the guard are RE-EXPORTS, not twins. A `FormObjectBuilder`
|
|
799
|
+
IS a `SchemaBuilder` — `isSchemaBuilder` from either subpath answers
|
|
800
|
+
`true` for it, and `schemaOf()` from either reads its document — which is
|
|
801
|
+
what lets a form schema be handed to anything that takes a schema-pen
|
|
802
|
+
builder, `defineApp({ state })` included.
|
|
803
|
+
|
|
804
|
+
All ten are VALUES, exported at run time and declared as one;
|
|
805
|
+
`test/linq/types.test.js` holds this pen's two export sets equal, in
|
|
806
|
+
both directions. `FormBooleanBuilder`, `FormNullBuilder` and
|
|
807
|
+
`FormNamedBuilder` are declared but NOT exported, exactly as
|
|
808
|
+
`BooleanBuilder`, `NullBuilder` and `NamedBuilder` are on the schema pen
|
|
809
|
+
— `boolean()`, `nil()` and `named()` each build a plain base builder, so
|
|
810
|
+
there is no class to export and importing one as a value does not
|
|
811
|
+
compile.
|
|
812
|
+
|
|
813
|
+
### 5.4 What the pins hold
|
|
814
|
+
|
|
815
|
+
`test/consumer/linq-app.ts` carries this pen's compile-level half beside
|
|
816
|
+
the app pen's — one file, because they are the two pens a form-shaped
|
|
817
|
+
application uses together. It holds:
|
|
818
|
+
|
|
819
|
+
- `Infer<typeof Invoice>` equal to the hand-written document shape, and
|
|
820
|
+
the same equality for a rule-free schema-pen object, so a regression
|
|
821
|
+
that let a rule leak into the inferred type fails the build;
|
|
822
|
+
- a rule annotated with `form<Doc>()` reading a member the document
|
|
823
|
+
declares, and a `@ts-expect-error` on one it does not;
|
|
824
|
+
- `@ts-expect-error` on `f.string().form({ preview })` and on
|
|
825
|
+
`f.string().meta({ 'x-form': … })` — the two refusals of §4 that the
|
|
826
|
+
types also close;
|
|
827
|
+
- `f.never() instanceof FormNeverBuilder`, which is the runtime half of
|
|
828
|
+
§5.3's "declared and exported are one set".
|
|
829
|
+
|
|
830
|
+
### 5.5 The two refusals the types close, and the one node that carries a rule anyway
|
|
831
|
+
|
|
832
|
+
- **`f.never().form({ … })` does not compile**, and neither does
|
|
833
|
+
`f.never().meta({ … })`. `never()` answers a `FormNeverBuilder` — the
|
|
834
|
+
class the runtime really builds — which declares no `form()` at all
|
|
835
|
+
and inherits a `meta()` whose parameter is `never`, so nothing is
|
|
836
|
+
assignable to it. Both are `JL0102` at run time (§4.2) and neither
|
|
837
|
+
reaches run time from typed code. `nullable()` widens the node and
|
|
838
|
+
hands back `FormBuilder`, where both are legal again — the remedy the
|
|
839
|
+
message names, and `test/consumer/linq-app.ts` compiles it.
|
|
840
|
+
- **`f.when(cond).then(…).form({ … })` compiles and runs**, because a
|
|
841
|
+
conditional carries a rule like any other node. `buildFormModel` reads
|
|
842
|
+
`x-form` off whatever schema it builds a field for, so a `when()` used
|
|
843
|
+
as an object MEMBER answers a field whose rules evaluate — with
|
|
844
|
+
`visible: (c) => c.root.kind.eq('a')` on such a member,
|
|
845
|
+
`evaluateFormRules` reports `{ '/gate': { visible: true } }` for a
|
|
846
|
+
matching document and `{ visible: false }` for the rest.
|
|
847
|
+
`test/linq/forms-pen.test.js` is that twin. `Value` is `unknown` there,
|
|
848
|
+
as on the object and tuple builders: the node describes a shape rather
|
|
849
|
+
than a value, so annotate `Doc` and read the document through
|
|
850
|
+
`c.root`.
|
|
851
|
+
|
|
852
|
+
One thing a rule on a conditional does NOT reach: a rule written on a
|
|
853
|
+
member INSIDE `then()` or `else()`. `buildFormModel` resolves `$ref`s and
|
|
854
|
+
merges `allOf`, and descends `properties`, `prefixItems` and `items` — it
|
|
855
|
+
does not descend `if`/`then`/`else`, so a field only that branch declares
|
|
856
|
+
is never built and its rule never evaluates. §6 states it as the limit it
|
|
857
|
+
is.
|
|
858
|
+
|
|
859
|
+
## 6. What it cannot spell
|
|
860
|
+
|
|
861
|
+
The forms pen's limits are short, because the pen adds one keyword to a
|
|
862
|
+
format that already exists. What it cannot carry is what `x-form` does
|
|
863
|
+
not define, and the list is closed at five members: `visible`,
|
|
864
|
+
`enabled`, `assert`, `computed`, `message`. A sixth is `JL0101` naming
|
|
865
|
+
the five (§4.1).
|
|
866
|
+
|
|
867
|
+
Two limits are worth naming for the reader who will otherwise go looking
|
|
868
|
+
for a member that is not there:
|
|
869
|
+
|
|
870
|
+
- **`preview`** — a field's preview hint is DERIVED from its `format` by
|
|
871
|
+
the registry and read from there by `buildFormModel`; there is no
|
|
872
|
+
authored spelling, and the pen refuses the attempt rather than writing
|
|
873
|
+
a keyword nothing reads. Set the `format` (§3.6, §4.2). Anything the
|
|
874
|
+
registry does not carry a hint for has no preview at all, and adding
|
|
875
|
+
one is a change to `@jarenjs/forms`' format registry rather than to a
|
|
876
|
+
document.
|
|
877
|
+
- **A rule's own failure policy** — `visible`/`enabled` fail open,
|
|
878
|
+
`assert` fails closed, `computed` leaves the value absent. Those are
|
|
879
|
+
the reader's, fixed in `evaluateFormRules`, and no member of `x-form`
|
|
880
|
+
overrides them. A rule that must not fail open is an `assert`.
|
|
881
|
+
- **A rule on a member inside `then()` or `else()`** — the pen writes it
|
|
882
|
+
and nothing reads it. `buildFormModel` resolves `$ref`s, merges `allOf`
|
|
883
|
+
and descends `properties`, `prefixItems` and `items`; it does not
|
|
884
|
+
descend `if`/`then`/`else`, so a field only a conditional branch
|
|
885
|
+
declares is never built and its rule never evaluates. A rule on the
|
|
886
|
+
conditional NODE itself does evaluate
|
|
887
|
+
([§5.5](#55-the-two-refusals-the-types-close-and-the-one-node-that-carries-a-rule-anyway)),
|
|
888
|
+
which is where a gate over a whole branch belongs; a per-field rule
|
|
889
|
+
belongs on a member the model walks.
|
|
890
|
+
|
|
891
|
+
Everything else a reader might expect to be missing is present and
|
|
892
|
+
belongs to the schema pen: the constructs THAT pen cannot spell are
|
|
893
|
+
[SCHEMA-PEN.md §6](SCHEMA-PEN.md#6-what-it-cannot-spell) and are
|
|
894
|
+
unchanged here, because 27 of this pen's 31 names are its names. §6.1
|
|
895
|
+
closes the section with the cases where the honest answer is not to reach
|
|
896
|
+
for this pen at all.
|
|
897
|
+
|
|
898
|
+
### 6.1 When not to reach for this pen
|
|
899
|
+
|
|
900
|
+
- **The schema carries no rules.** A form over a document with no
|
|
901
|
+
`visible`, `enabled`, `assert` or `computed` is the schema pen's
|
|
902
|
+
document and `buildFormModel` builds a form from it perfectly well.
|
|
903
|
+
This subpath's whole addition is `x-form`; where there is none, taking
|
|
904
|
+
it costs a bundle and buys nothing (§7 has the figure).
|
|
905
|
+
- **You already have the schema, from anywhere else.** `x-form` is an
|
|
906
|
+
annotation keyword: a hand-written schema, one from `@jarenjs/emit`, or
|
|
907
|
+
one this pen never touched can carry it, and `form()` is a convenience
|
|
908
|
+
rather than the only way in. A schema you do not author is a schema you
|
|
909
|
+
should annotate where it lives, not re-author here.
|
|
910
|
+
- **The rule is about the world, not the document.** A context is
|
|
911
|
+
`root`, `value` and `pointer` and nothing else (§3.2, §4.3) — no clock,
|
|
912
|
+
no session, no server lookup. A field that is required only for users
|
|
913
|
+
in a country the server decides is a field whose rule reads a member
|
|
914
|
+
the server put in the document; put it there first.
|
|
915
|
+
- **The rules must run somewhere that does not read `x-form`.** The
|
|
916
|
+
keyword is safe to ignore, which cuts both ways: a form rendered by a
|
|
917
|
+
library that has never heard of it renders every field and asserts
|
|
918
|
+
nothing. `assertOnSubmit()` (§3.4) is the answer where the rules have
|
|
919
|
+
to hold on a plain validator, and it is worth deciding that before the
|
|
920
|
+
rules are written.
|
|
921
|
+
- **The validation is the schema's job.** `assert` is for what
|
|
922
|
+
`minLength` cannot say — a rule across two members, or one that depends
|
|
923
|
+
on a third. A rule that restates a constraint the schema already
|
|
924
|
+
carries is a second place to change it.
|
|
925
|
+
|
|
926
|
+
## 7. Cost
|
|
927
|
+
|
|
928
|
+
`@jarenjs/linq/forms` builds to **<!--fact:bundle.forms-->36,587<!--/fact--> bytes** as a minified,
|
|
929
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
930
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
931
|
+
beside the other nine subpath prices in
|
|
932
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md). It carries the schema
|
|
933
|
+
pen it subclasses, and no chain module, no `@jarenjs/forms` byte and no
|
|
934
|
+
model pen.
|
|
935
|
+
|
|
936
|
+
Most of that figure is the schema pen: `@jarenjs/linq/schema` alone
|
|
937
|
+
is <!--fact:bundle.schema-->32,427<!--/fact--> bytes, so the whole `x-form` vocabulary — the mixin, the rule
|
|
938
|
+
capture, the submit transform and their refusal messages — is about 4 kB
|
|
939
|
+
on top of a pen a form-shaped consumer usually already carries. A
|
|
940
|
+
consumer importing both subpaths pays the schema pen once.
|