@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.
Files changed (77) hide show
  1. package/ARCHITECTURE.md +217 -0
  2. package/README.md +559 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1217 -0
  5. package/docs/DB-CLIENT.md +814 -0
  6. package/docs/FLOW-PEN.md +1026 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +771 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1083 -0
  12. package/docs/QUERY-PEN.md +1636 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +255 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +260 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +329 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +9 -4
  24. package/src/contract/define.js +269 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +342 -0
  28. package/src/db/handle.js +86 -0
  29. package/src/db/include.js +316 -0
  30. package/src/db/index.js +19 -0
  31. package/src/db/live.js +43 -0
  32. package/src/db/membership.js +37 -0
  33. package/src/db/open.js +82 -0
  34. package/src/document.js +143 -13
  35. package/src/effect.js +65 -0
  36. package/src/errors.js +69 -6
  37. package/src/expression.js +437 -36
  38. package/src/flow/capture.js +33 -0
  39. package/src/flow/dag.js +302 -0
  40. package/src/flow/fsm.js +328 -0
  41. package/src/flow/index.js +22 -0
  42. package/src/forms/index.js +43 -0
  43. package/src/forms/rules.js +170 -0
  44. package/src/forms/submit.js +177 -0
  45. package/src/index.js +4 -2
  46. package/src/jslt/body.js +226 -0
  47. package/src/jslt/index.js +18 -0
  48. package/src/jslt/rules.js +207 -0
  49. package/src/json-boundary.js +90 -0
  50. package/src/migration/define.js +323 -0
  51. package/src/migration/index.js +15 -0
  52. package/src/migration/steps.js +248 -0
  53. package/src/model/collection.js +171 -0
  54. package/src/model/define.js +125 -0
  55. package/src/model/entity.js +307 -0
  56. package/src/model/index.js +47 -0
  57. package/src/model/relation.js +85 -0
  58. package/src/provider.js +137 -20
  59. package/src/schema/brand.js +31 -0
  60. package/src/schema/builders.js +526 -0
  61. package/src/schema/check.js +29 -0
  62. package/src/schema/emit.js +394 -0
  63. package/src/schema/factories.js +239 -0
  64. package/src/schema/index.js +37 -0
  65. package/src/schema-of.js +24 -0
  66. package/src/sequence.js +233 -103
  67. package/src/sources.js +10 -3
  68. package/types/app.d.ts +293 -0
  69. package/types/contract.d.ts +371 -0
  70. package/types/db.d.ts +188 -0
  71. package/types/flow.d.ts +285 -0
  72. package/types/forms.d.ts +253 -0
  73. package/types/index.d.ts +231 -26
  74. package/types/jslt.d.ts +193 -0
  75. package/types/migration.d.ts +201 -0
  76. package/types/model.d.ts +493 -0
  77. 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.