@maroonedog/luq 2.1.0 → 2.2.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 (62) hide show
  1. package/README.md +269 -52
  2. package/dist/builder/builder-surface.types.d.ts +1 -0
  3. package/dist/builder/create-builder.js +9 -0
  4. package/dist/builder/create-builder.mjs +9 -0
  5. package/dist/builder/field-builder.types.d.ts +13 -0
  6. package/dist/chain/bundle-paths.types.d.ts +26 -0
  7. package/dist/chain/bundle-paths.types.js +2 -0
  8. package/dist/chain/bundle-paths.types.mjs +1 -0
  9. package/dist/chain/chain-method.types.d.ts +8 -3
  10. package/dist/chain/resolve-args.types.d.ts +4 -2
  11. package/dist/compile/compile-array-node.d.ts +0 -7
  12. package/dist/compile/compile-array-node.js +4 -0
  13. package/dist/compile/compile-array-node.mjs +4 -0
  14. package/dist/compile/compile-field.js +10 -1
  15. package/dist/compile/compile-field.mjs +10 -1
  16. package/dist/compile/split-rules-by-kind.js +31 -7
  17. package/dist/compile/split-rules-by-kind.mjs +31 -7
  18. package/dist/compile/validation-plan.types.d.ts +23 -0
  19. package/dist/json-schema/flatten-schema.js +1 -1
  20. package/dist/json-schema/flatten-schema.mjs +1 -1
  21. package/dist/plugin-kit/marker.types.d.ts +18 -0
  22. package/dist/plugins/index.generated.d.ts +1 -0
  23. package/dist/plugins/index.generated.js +4 -2
  24. package/dist/plugins/index.generated.mjs +1 -0
  25. package/dist/plugins/manifest.generated.js +1 -0
  26. package/dist/plugins/manifest.generated.mjs +1 -0
  27. package/dist/plugins/stitch/stitch.d.ts +28 -8
  28. package/dist/plugins/stitch-with/index.d.ts +2 -0
  29. package/dist/plugins/stitch-with/index.js +5 -0
  30. package/dist/plugins/stitch-with/index.mjs +1 -0
  31. package/dist/plugins/stitch-with/stitch-with.d.ts +12 -0
  32. package/dist/plugins/stitch-with/stitch-with.js +94 -0
  33. package/dist/plugins/stitch-with/stitch-with.mjs +91 -0
  34. package/dist/plugins/stitchWith.d.ts +1 -0
  35. package/dist/plugins/stitchWith.js +2 -0
  36. package/dist/plugins/stitchWith.mjs +1 -0
  37. package/dist/plugins/string-min/string-min.js +21 -3
  38. package/dist/plugins/string-min/string-min.mjs +22 -4
  39. package/dist/presets/index.d.ts +1 -0
  40. package/dist/presets/index.js +9 -0
  41. package/dist/presets/index.mjs +1 -0
  42. package/dist/presets/presets.d.ts +165 -0
  43. package/dist/presets/presets.js +80 -0
  44. package/dist/presets/presets.mjs +77 -0
  45. package/dist/runtime/create-field-validator.js +24 -8
  46. package/dist/runtime/create-field-validator.mjs +24 -8
  47. package/dist/runtime/create-validator.js +38 -8
  48. package/dist/runtime/create-validator.mjs +38 -8
  49. package/dist/runtime/field-rule-context.d.ts +32 -0
  50. package/dist/runtime/field-rule-context.js +48 -0
  51. package/dist/runtime/field-rule-context.mjs +44 -0
  52. package/dist/runtime/index-stack.d.ts +29 -4
  53. package/dist/runtime/index-stack.js +76 -15
  54. package/dist/runtime/index-stack.mjs +76 -15
  55. package/dist/runtime/run-array-node.js +22 -10
  56. package/dist/runtime/run-array-node.mjs +22 -10
  57. package/dist/runtime/run-field.js +47 -17
  58. package/dist/runtime/run-field.mjs +47 -17
  59. package/dist/runtime/run-plan.js +5 -1
  60. package/dist/runtime/run-plan.mjs +5 -1
  61. package/dist/types/index.d.ts +12 -0
  62. package/package.json +35 -19
package/README.md CHANGED
@@ -16,10 +16,11 @@ schema of record. protobuf and GraphQL codegen generate them for services in
16
16
  four languages at once. Increasingly, a model generates the code that uses them.
17
17
 
18
18
  A validator whose schema is the source of truth assumes you are the one who
19
- decides the shape. When you are not, it asks you to write that shape a second
20
- time and keep the copy in step by hand and nothing checks that the two still
21
- agree. They drift, and the first sign is a value that passed the copy and does
22
- not fit the original.
19
+ decides the shape. When that assumption holds, it is the better arrangement and
20
+ this README will say so again below. When it does not, you end up maintaining a
21
+ second description of a shape you did not choose. You can have the compiler check
22
+ the copy against the original — zod's `satisfies z.ZodType<Order>` does exactly
23
+ that — but you still author it, update it, and remember to write the check.
23
24
 
24
25
  Luq runs the other way. It takes the type you already have and lets you declare
25
26
  rules against its field paths. What makes those declarations worth writing is
@@ -43,9 +44,9 @@ an array wildcard gets a red squiggle, not a validator that passes everything.
43
44
  Every rule you can call is a plugin you imported by name, so the bundle contains
44
45
  what you used and nothing else.
45
46
 
46
- Every number on this page was measured on this repository. Where a measurement
47
- is worse than the 1.x release, it is written down as worse. The provenance of
48
- each figure is named next to it.
47
+ Every number on this page was measured on this repository, and the file it came
48
+ from is named next to it. Where a measurement is worse than 1.x and some are —
49
+ it is written down as worse.
49
50
 
50
51
  ## Install
51
52
 
@@ -109,10 +110,155 @@ carries `data`, both branches carry `issues`, and each issue is
109
110
  `{ path, code, message, severity }`.
110
111
 
111
112
  > Every code block on this page is extracted and typechecked against the built
112
- > package by `npm run check:docs`. The 1.x README's quick start called `build()`'s
113
- > return value as a function, read a `result.issues` member 1.x's `Result` did not
114
- > have, and imported a subpath the exports map did not contain. That is what the
115
- > gate exists to prevent.
113
+ > package by `npm run check:docs`. It exists because documentation drifts from
114
+ > the API it documents unless something compiles it 1.x's quick start had drifted
115
+ > in three places at once (a `build()` result called as a function, a `result.issues`
116
+ > member that did not exist, and a subpath missing from the exports map), and each
117
+ > was the readable kind of mistake that nobody reads.
118
+
119
+ ## It patches onto the types you already have
120
+
121
+ This is the practical consequence of being type-first, and it is the main reason
122
+ to reach for Luq: **your type definitions do not change.** Not re-authored as a
123
+ schema, not replaced by an inferred one, not moved. `.for<Order>()` takes the
124
+ `Order` you already have, exactly as it is, and every rule is declared against
125
+ it.
126
+
127
+ So Luq asks you to describe the **rules**, not the shape — the shape is already
128
+ written down. Adopting a schema-first validator on an existing codebase means
129
+ producing a second description of the same shape for every type you cover, and
130
+ then keeping the two in agreement; that is per-type work whether you author the
131
+ schema alongside the type or switch the type to be inferred from it. Luq skips
132
+ that step because it never needs the second description.
133
+
134
+ Which is what makes adoption a patch rather than a migration:
135
+
136
+ **Declare only the fields you care about.** A path you did not declare is not
137
+ validated, not required, and not read. There is no "unknown key" behaviour to
138
+ opt out of, so a partly-covered type is a normal state and not a half-finished
139
+ one.
140
+
141
+ ```ts
142
+ import { Builder } from "@maroonedog/luq";
143
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
144
+ import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
145
+
146
+ type Order = {
147
+ id: string;
148
+ customerNote: string;
149
+ legacyBlob: unknown;
150
+ };
151
+
152
+ // One field of three.
153
+ const orderValidator = Builder()
154
+ .use(requiredPlugin)
155
+ .use(stringMinPlugin)
156
+ .for<Order>()
157
+ .v("id", (b) => b.string.required().min(3))
158
+ .build();
159
+
160
+ // `customerNote` and `legacyBlob` are never read, so anything goes there —
161
+ // including being absent.
162
+ console.error(orderValidator.validate({ id: "abc" } as Order).valid); // true
163
+ console.error(orderValidator.validate({ id: "ab" } as Order).valid); // false
164
+ ```
165
+
166
+ **Validate one field at a time.** `pick(path)` gives back a validator for a
167
+ single declared path, which is what a form needs on blur. It takes the field's
168
+ own value, and optionally its siblings for cross-field rules.
169
+
170
+ ```ts
171
+ import { Builder } from "@maroonedog/luq";
172
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
173
+ import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
174
+
175
+ type Order = { id: string; customerNote: string };
176
+
177
+ const id = Builder()
178
+ .use(requiredPlugin)
179
+ .use(stringMinPlugin)
180
+ .for<Order>()
181
+ .v("id", (b) => b.string.required().min(3))
182
+ .build()
183
+ .pick("id");
184
+
185
+ console.error(id.validate("ab").valid); // false
186
+ console.error(id.validate("abc").valid); // true
187
+ ```
188
+
189
+ `pickAll(["a", "b"])` does the same for a named subset and hands back exactly
190
+ those paths, keyed by the strings you asked for.
191
+
192
+ **Keep what you already have.** Luq implements Standard Schema v1, so a Luq
193
+ validator and a zod schema are interchangeable at any boundary that accepts one.
194
+ Adding Luq to one route does not commit the next one, and does not remove zod
195
+ from the routes it is already in.
196
+
197
+ None of this needs a migration step, because there is nothing global to migrate:
198
+ no registry, no plugin installation, no shared configuration object. A validator
199
+ is a value in a module, declared against a type that was there before it.
200
+
201
+ **And when you want the opposite, ask for it: `.strict()`.** Partial coverage is
202
+ the default because that is what makes a patch possible, but a builder that
203
+ declares `.strict()` will not compile until every leaf path of `T` is declared —
204
+ and the error names the ones you missed:
205
+
206
+ ```ts
207
+ import { Builder } from "@maroonedog/luq";
208
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
209
+
210
+ type Order = { id: string; customerNote: string; nested: { deep: number } };
211
+
212
+ export const incomplete = Builder()
213
+ .use(requiredPlugin)
214
+ .for<Order>()
215
+ .v("id", (b) => b.string.required())
216
+ .strict()
217
+ // @ts-expect-error strict() returned
218
+ // MissingFieldsError<"customerNote" | "nested.deep">, which has no build().
219
+ .build();
220
+ ```
221
+
222
+ It counts leaves, so an optional property, a `Date`, an array's elements
223
+ (`tags[*]`) and a field inside an array of objects (`items[*].sku`) are each
224
+ required in their own right. It has no run-time effect at all — the obligation
225
+ is discharged by the compiler.
226
+
227
+ So the choice between "cover one field" and "cover everything" is a single call,
228
+ made per builder, and reported at compile time with the missing names rather than
229
+ at run time as a value that quietly passed. What `.strict()` does **not** cover is
230
+ properties that are not in the type; rejecting those is a run-time rule and
231
+ belongs to `additionalProperties(false)`.
232
+
233
+ ## When schema-first is the right answer
234
+
235
+ Worth stating plainly, because it is a real tension and not a debating point:
236
+ **if the schema genuinely is your single source of truth, schema-first is the
237
+ coherent arrangement, and zod, valibot or TypeBox are the right tools.** You
238
+ write one artefact, your types come out of it, and there is nothing to keep in
239
+ step. That is a better position than Luq's, and Luq cannot give it to you.
240
+
241
+ Luq is for the case where that artefact already exists somewhere else and is not
242
+ yours to move — an OpenAPI document you consume, a Prisma schema, a `.proto`
243
+ shared with three other services, a type someone generated last week. There, the
244
+ schema-first arrangement asks you to author a *second* source of truth, and the
245
+ question stops being which library is nicer and becomes which copy is right.
246
+
247
+ Two things follow that are easy to miss:
248
+
249
+ - **Luq contains both directions.** `fromJsonSchema(document)` is schema-first —
250
+ the document decides, and Luq builds the rules from it. That is not a
251
+ contradiction to be argued away; it is the same principle applied to a
252
+ different upstream. What Luq declines to do is make you *hand-write* the second
253
+ copy.
254
+ - **You do not have to pick a side per project, only per boundary.** Standard
255
+ Schema means a zod schema and a Luq validator are interchangeable where they
256
+ meet, so "the schema is the truth here, the type is the truth there" is a
257
+ workable arrangement rather than an unresolved argument.
258
+
259
+ If you are starting from nothing and you will own the shape, use zod. It is
260
+ mature, it is everywhere, and every question you will have is already answered
261
+ somewhere.
116
262
 
117
263
  ## Field paths
118
264
 
@@ -151,6 +297,40 @@ A path that does not exist on the type is a compile error, not a silent no-op.
151
297
  So is choosing a slot the field's type cannot be: `b.number` on a `string`
152
298
  field fails to compile.
153
299
 
300
+ **Cross-field rules read those paths back with their types intact.** `stitch`
301
+ takes the paths it needs and hands them over as a bundle keyed by the path
302
+ string — each one typed from your type, nested paths included. There is no
303
+ `unknown` to narrow and no cast to write.
304
+
305
+ ```ts
306
+ import { Builder } from "@maroonedog/luq";
307
+ import { requiredPlugin } from "@maroonedog/luq/plugins/required";
308
+ import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
309
+ import { stitchPlugin } from "@maroonedog/luq/plugins/stitch";
310
+
311
+ type Booking = { seats: number; venue: { capacity: number } };
312
+
313
+ const bookingValidator = Builder()
314
+ .use(requiredPlugin)
315
+ .use(numberMinPlugin)
316
+ .use(stitchPlugin)
317
+ .for<Booking>()
318
+ .v("venue.capacity", (b) => b.number.required().min(1))
319
+ .v("seats", (b) =>
320
+ b.number.required().stitch(["venue.capacity"], (fieldValues, value) => ({
321
+ // fieldValues["venue.capacity"] is number, and value is number.
322
+ valid: value <= fieldValues["venue.capacity"],
323
+ message: "seats must fit the venue",
324
+ }))
325
+ )
326
+ .build();
327
+ ```
328
+
329
+ Asking for a path the type does not have is a compile error, and so is using a
330
+ bundled value at the wrong type. 1.x passed this bundle as
331
+ `Record<string, unknown>`, which meant every cross-field rule opened with a
332
+ cast; the paths were already declared, so the types were always knowable.
333
+
154
334
  ## Plugins are imports
155
335
 
156
336
  There is no plugin registry to populate and no barrel you have to pay for.
@@ -174,7 +354,7 @@ const draftValidator = Builder()
174
354
  export const isTitled = draftValidator.validate({ title: "x" }).valid;
175
355
  ```
176
356
 
177
- 77 plugin objects ship across 76 subpaths, plus one deprecated alias kept from
357
+ 78 plugin objects ship across 77 subpaths, plus one deprecated alias kept from
178
358
  1.x. The complete table — subpath, symbol, chain method, slots — is generated
179
359
  from the built package: **[docs/guide/plugin-reference.md](docs/guide/plugin-reference.md)**.
180
360
 
@@ -275,7 +455,7 @@ Three decisions the spec leaves open, made explicit here:
275
455
  - `InferInput` is the type you wrote in `.for<T>()`, not a type inferred back
276
456
  out of a schema value.
277
457
 
278
- It is a subpath, not part of `build()`. Measured: the core gzips to 7,420 B and
458
+ It is a subpath, not part of `build()`. Measured on the 2.0.0 core (7,420 B) and
279
459
  carrying `~standard` on every validator adds 312 B — 4.2% charged to everyone,
280
460
  including the people who never pass a validator to tRPC. Importing the subpath
281
461
  costs those 312 B only when you import it, and nothing when you don't.
@@ -327,21 +507,33 @@ so the two columns are comparable. Recorded in
327
507
 
328
508
  | Entry | gzip | 1.x, same method |
329
509
  |---|---:|---:|
330
- | `Builder` only, zero plugins | **7,420 B** | 17,423 B |
331
- | + 6 plugins (1.x's "simple" set) | **8,373 B** | 19,562 B |
332
- | all 76 plugins | **24,040 B** | |
333
- | core + `jsonSchema`, plugin alone (not usable) | **18,992 B** | — |
334
- | core + `jsonSchema` + a working 49-plugin bag | **21,371 B** | 26.06–29.08 KB |
335
- | core + `jsonSchemaFullFeature` | **21,383 B** | 31.75–32.31 KB |
336
-
337
- The claim 1.x's README made — "tree-shakeable, 19–23KB gzipped" was measuring
338
- a core bundle that cost 17.4 KB **before you used anything**: 89.1% of its
339
- "simple" figure was paid up front. Here the core is 30.9% of the all-plugins
340
- build (7,420 of 24,040 B), and adding a plugin costs 129–224 B of gzip.
510
+ <!-- generated:bundle-size -->
511
+ | `Builder` only, zero plugins | **7,954 B** | 17,423 B |
512
+ | + 6 plugins (1.x's "simple" set) | **8,879 B** | 19,562 B |
513
+ | all 77 plugins | **25,991 B** | — |
514
+ <!-- /generated:bundle-size -->
515
+
516
+ Three more entries were measured the same way on 2026-09-07 but are **not** in
517
+ `config/size-budget.json`, so nothing re-measures them and they can go stale
518
+ without anything noticing. They are kept because the JSON Schema claim needs
519
+ evidence, and marked because a figure nobody checks is worth less than one that
520
+ is checked:
521
+
522
+ | Entry (measured once, not gated) | gzip | 1.x, same method |
523
+ |---|---:|---:|
524
+ | core + `jsonSchema`, plugin alone (not usable) | 18,992 B | — |
525
+ | core + `jsonSchema` + a working 49-plugin bag | 21,371 B | 26.06–29.08 KB |
526
+ | core + `jsonSchemaFullFeature` | 21,383 B | 31.75–32.31 KB |
527
+
528
+ 1.x published "tree-shakeable, 19–23KB gzipped". Measured the same way, its
529
+ core was 17.4 KB **before any plugin was imported** — 89.1% of its "simple"
530
+ figure. Here the core is <!-- generated:bundle-core-share -->30.6% of the all-plugins build (7,954 of 25,991 B)<!-- /generated:bundle-core-share -->,
531
+ and adding a plugin costs 129–224 B of gzip. Both figures are in the table above;
532
+ the difference is where the bytes sit, not which README is right.
341
533
 
342
534
  Two lines that are **not** wins:
343
535
 
344
- - "all 76 plugins at 24,040 B" is larger than the 23,015 B 1.x published for its
536
+ - "all 77 plugins at 25,607 B" is larger than the 23,015 B 1.x published for its
345
537
  `complex` case. The two are not comparable — 1.x's figure was one schema's
346
538
  plugin set, not its whole catalogue — so it is not counted either way here.
347
539
  - The 18,992 B for `jsonSchema` measures the plugin **without a bag**, which is
@@ -361,15 +553,17 @@ skipped. Every subject rotates over a pool of at least four distinct values —
361
553
  one frozen input let V8 delete a subject outright, which is the artefact
362
554
  described below. Each figure is the median of the fastest half of 9 samples; the
363
555
  spread quoted alongside is the full range over that figure, and on these ten it
364
- is 2.98.6%.
556
+ is <!-- generated:perf-spread -->1.2–12.3%<!-- /generated:perf-spread -->.
365
557
 
366
558
  | Shape | `validate` ops/sec | `parse` ops/sec |
367
559
  |---|---:|---:|
368
- | 1 field, 1 check | 2,801,628 | 2,617,522 |
369
- | 3 fields, 6 plugins | 1,073,925 | 1,078,706 |
370
- | nested, depth 2–3 | 707,734 | 700,829 |
371
- | array of 50 elements | 29,963 | 29,774 |
372
- | JSON Schema document | 159,643 | 159,966 |
560
+ <!-- generated:perf-throughput -->
561
+ | 1 field, 1 check | 5,581,199 | 5,593,550 |
562
+ | 3 fields, 6 plugins | 2,444,308 | 1,933,180 |
563
+ | nested, depth 2–3 | 1,722,770 | 1,701,094 |
564
+ | array of 50 elements | 90,590 | 90,013 |
565
+ | JSON Schema document | 312,630 | 309,502 |
566
+ <!-- /generated:perf-throughput -->
373
567
 
374
568
  **This rewrite is slower than 1.x on flat and nested shapes.** Measured side by
375
569
  side, in one process on one machine, 1.x source against this source, sample by
@@ -377,11 +571,13 @@ sample interleaved so a drift in the machine hits both halves of every ratio:
377
571
 
378
572
  | Shape | 1.x | this | ratio |
379
573
  |---|---:|---:|---:|
380
- | 1 field | 25,660,195 | 2,724,339 | **×0.11** |
381
- | 3 fields | 3,059,093 | 1,069,167 | **×0.35** |
382
- | nested | 2,215,768 | 708,461 | **×0.32** |
383
- | array of 50 | 18,941 | 29,629 | ×1.57 |
384
- | JSON Schema | 138,809 | 152,536 | ×1.09 |
574
+ <!-- generated:perf-legacy -->
575
+ | 1 field | 26,568,111 | 5,463,953 | **×0.21** |
576
+ | 3 fields | 3,082,290 | 2,434,509 | **×0.79** |
577
+ | nested | 2,203,633 | 1,842,200 | **×0.83** |
578
+ | array of 50 | 19,610 | 84,707 | ×4.30 |
579
+ | JSON Schema | 146,283 | 306,778 | ×2.10 |
580
+ <!-- /generated:perf-legacy -->
385
581
 
386
582
  1.x carried a directory of specialised fast paths that this implementation has
387
583
  no equivalent of. The comparison was checked for the ways it could be wrong: 1.x
@@ -391,8 +587,9 @@ reject the rejected pool before either is timed.
391
587
 
392
588
  Also worth stating plainly: **neither figure 1.x's README published reproduces
393
589
  here.** It claimed 1.2M ops/sec simple and 43K complex; on this machine 1.x
394
- itself does 3.06M on the shape rebuilt from its own "simple" benchmark source,
395
- and "complex" has no reproducible definition to measure.
590
+ itself does <!-- generated:perf-legacy-simple -->3.08M<!-- /generated:perf-legacy-simple -->
591
+ on the shape rebuilt from its own "simple" benchmark source, and "complex" has
592
+ no reproducible definition to measure.
396
593
 
397
594
  `build()` costs 14–662 µs depending on shape, against sub-microsecond
398
595
  `validate()` calls — so one `build()` pays for itself after 35–100 `validate()`
@@ -428,20 +625,24 @@ figures recorded here span 0.01–0.86.
428
625
 
429
626
  No `eval`, no `new Function`. Checked mechanically over all 762 emitted `.js`
430
627
  and `.mjs` files by `npm run check:no-dynamic-code`, and over `src/` by the
431
- public-API smoke test: **0 occurrences**. 1.x made this claim in its README while
432
- carrying a live `new Function` in `src/types/array-type-analysis.ts`; this is the
433
- first release where it is enforced rather than asserted.
628
+ public-API smoke test: **0 occurrences**.
629
+
630
+ The check is there because the claim is easy to make and easy to stop being true
631
+ — 1.x's README made it while `src/types/array-type-analysis.ts` still carried a
632
+ live `new Function`. This is the first release where a script enforces it on
633
+ every build rather than a sentence asserting it.
434
634
 
435
635
  ### Package
436
636
 
437
- 84 keys in `exports`, every one resolving to files that exist: 7 fixed keys
637
+ 86 keys in `exports`, every one resolving to files that exist: 8 fixed keys
438
638
  (`.`, `./package.json`, `./result`, `./plugin-kit`, `./field-rule`, `./async`,
439
- `./plugins`) and 77 under `./plugins/` — 76 plugins plus one deprecated alias.
440
- `npm pack --dry-run`: 1,149 files, 322,079 B packed, 1,216,371 B unpacked —
441
- `LICENSE`, `README.md`, `package.json` and `dist/` (382 `.d.ts` + 382 `.js` +
442
- 382 `.mjs`), with nothing from `src/`, `test/`, `scripts/`, `bench/` or `docs/`,
639
+ `./plugins`, `./standard-schema`) and 78 under `./plugins/` — 77 plugins plus one
640
+ deprecated alias.
641
+ `npm pack --dry-run`: 1,197 files, 376.9 kB packed, 1.4 MB unpacked
642
+ `LICENSE`, `README.md`, `package.json` and `dist/` (398 `.d.ts` + 398 `.js` +
643
+ 398 `.mjs`), with nothing from `src/`, `test/`, `scripts/`, `bench/` or `docs/`,
443
644
  no raw `.ts` and no source maps. A scratch consumer typechecks **every one of
444
- the 84 keys** against the published declarations under **both** `node16` and
645
+ the 86 keys** against the published declarations under **both** `node16` and
445
646
  `bundler` resolution, and an unpublished subpath is proven to fail.
446
647
 
447
648
  1.x's `createPluginRegistry` / `useField` / `createFieldRule` are published at
@@ -455,7 +656,8 @@ the 84 keys** against the published declarations under **both** `node16` and
455
656
  - **[Field paths](docs/guide/field-paths.md)** — what a path may be, and what
456
657
  changed from 1.x
457
658
  - **[Presence and conditionals](docs/guide/presence-and-conditionals.md)** —
458
- `required` / `optional` / `nullable` / `requiredIf` and the order rules run in
659
+ `required` / `optional` / `nullable` / `requiredIf`, the order rules run
660
+ in, and the cross-field rules (`compareField` / `stitch` / `stitchWith`)
459
661
  - **[JSON Schema](docs/guide/json-schema.md)** — the two front doors, and the
460
662
  keywords that are not supported
461
663
  - **[Writing a plugin](docs/guide/writing-a-plugin.md)** — markers, `out`,
@@ -467,12 +669,27 @@ the 84 keys** against the published declarations under **both** `node16` and
467
669
  - **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 100% and
468
670
  what closed each of the ten causes that used to fail
469
671
 
672
+ ## Status, and how this gets changed
673
+
674
+ The 2.x API is stable and the surface below is gated, but the production track
675
+ record is still short. Breaking changes happen in a major and nowhere else, an
676
+ API being removed is deprecated one major ahead, and each major ships with the
677
+ codemod needed to cross it.
678
+
679
+ - **[CONTRIBUTING.md](CONTRIBUTING.md)** — `npm run verify` is the whole
680
+ contract; the gates and what each one refuses
681
+ - **[SECURITY.md](SECURITY.md)** — reporting, zero runtime dependencies, the
682
+ prototype-pollution and SSRF positions, and what is *not* protected against
683
+ - **[docs/RELEASING.md](docs/RELEASING.md)** — the release steps, the versioning
684
+ policy, what each CI workflow watches, and what is still decided by hand
685
+
470
686
  ## About the "universal platform" goal
471
687
 
472
- 1.x's README advertised a `.luq` DSL that generates validators for other
473
- languages, with dated milestones. No part of it ships in this package and this
474
- release makes no claim about when it will. What is in the box is the TypeScript
475
- validation library described above.
688
+ 1.x described a `.luq` DSL that would generate validators for other languages,
689
+ against dated milestones. Those dates have passed and none of it shipped, so the
690
+ plan has been withdrawn rather than moved: no part of it is in this package, and
691
+ this release makes no claim about when any of it will exist. What is in the box
692
+ is the TypeScript validation library described above.
476
693
 
477
694
  ## License
478
695
 
@@ -34,6 +34,7 @@ export interface FieldBuilderSurface {
34
34
  /** The erased twin of Builder<B>. `use` mutates and returns the receiver. */
35
35
  export interface BuilderSurface {
36
36
  use(plugin: AnyPlugin): BuilderSurface;
37
+ useAll(plugins: PluginBag): BuilderSurface;
37
38
  withConfig(config: GlobalConfig): BuilderSurface;
38
39
  for(): FieldBuilderSurface;
39
40
  }
@@ -50,6 +50,15 @@ function createBuilderSurface() {
50
50
  registerPlugin(registration.plugins, plugin);
51
51
  return surface;
52
52
  },
53
+ useAll(plugins) {
54
+ // 順序は Object.values の列挙順。first-wins なので、同じ名前が二度
55
+ // 来ても最初のものが残る — プリセットが既に登録したものを黙って
56
+ // 置き換えることはない。
57
+ for (const plugin of Object.values(plugins)) {
58
+ registerPlugin(registration.plugins, plugin);
59
+ }
60
+ return surface;
61
+ },
53
62
  withConfig(config) {
54
63
  registration.config = Object.assign({}, registration.config, config);
55
64
  return surface;
@@ -44,6 +44,15 @@ export function createBuilderSurface() {
44
44
  registerPlugin(registration.plugins, plugin);
45
45
  return surface;
46
46
  },
47
+ useAll(plugins) {
48
+ // 順序は Object.values の列挙順。first-wins なので、同じ名前が二度
49
+ // 来ても最初のものが残る — プリセットが既に登録したものを黙って
50
+ // 置き換えることはない。
51
+ for (const plugin of Object.values(plugins)) {
52
+ registerPlugin(registration.plugins, plugin);
53
+ }
54
+ return surface;
55
+ },
47
56
  withConfig(config) {
48
57
  registration.config = Object.assign({}, registration.config, config);
49
58
  return surface;
@@ -26,6 +26,19 @@ export interface FieldBuilder<T extends object, B extends PluginBag, TDeclared e
26
26
  }
27
27
  export interface Builder<B extends PluginBag = Record<never, never>> {
28
28
  use<P extends AnyPlugin>(plugin: P): Builder<B & BagEntry<P>>;
29
+ /**
30
+ * A whole SET of plugins at once — a preset, or any object of them.
31
+ *
32
+ * A bag is a name -> plugin map, so a preset is that value and nothing more;
33
+ * there is no registry and no preset type to learn. `use()` one at a time
34
+ * still works and still costs only what it names, which is the point of the
35
+ * subpaths — this is for the case where writing fifteen `use()` lines is the
36
+ * thing standing between you and the validator.
37
+ *
38
+ * Duplicates follow the same rule as `use()`: FIRST WINS, so a preset cannot
39
+ * quietly replace a plugin you already registered.
40
+ */
41
+ useAll<Bag extends PluginBag>(plugins: Bag): Builder<B & Bag>;
29
42
  /**
30
43
  * The per-builder override of the process-wide GlobalConfig. Merged over
31
44
  * getGlobalConfig() once, at build(), and handed to every plugin as
@@ -0,0 +1,26 @@
1
+ import type { FieldPath } from "../path/field-path.types";
2
+ import type { ValueAtPath } from "../path/value-at-path.types";
3
+ import type { AnyChain } from "./field-chain.types";
4
+ import type { FieldSlots } from "./field-slots.types";
5
+ import type { PluginBag } from "./plugin-bag.types";
6
+ /** 別名 -> ルートのパス。パスはルートに実在するものしか書けない。 */
7
+ export type BundlePaths<TRoot> = Readonly<Record<string, FieldPath<TRoot> & string>>;
8
+ /** 対応表から組み上がる束。別名がキーで、値はそのパスの値。 */
9
+ export type BundleOf<TRoot, M extends BundlePaths<TRoot>> = {
10
+ readonly [A in keyof M & string]: ValueAtPath<TRoot, M[A]>;
11
+ };
12
+ /**
13
+ * 束ひとつに対するサブチェーン。
14
+ *
15
+ * stitch の核は「複数のフィールドを **1つの判定** にまとめる」ことなので、
16
+ * 主体は束そのものであって別名ごとではない。別名ごとにルールを並べる形も
17
+ * 書けるが、それは `total === price * quantity` のような判定が書けず、
18
+ * stitch ではなくなる。
19
+ *
20
+ * 主体が束なので、束の中を `.v()` 的に見るのではなく、`b.object` などの
21
+ * スロットがそのまま開く。型は BundleOf なので、束のメンバーは補完も効くし
22
+ * 取り違えればコンパイルエラーになる — stitch が
23
+ * `Readonly<Record<string, unknown>>` を渡していたのに対する、ここの一点だけ
24
+ * が違いである。
25
+ */
26
+ export type BundleChain<TRoot, M extends BundlePaths<TRoot>, B extends PluginBag> = (b: FieldSlots<BundleOf<TRoot, M>, B, BundleOf<TRoot, M>>) => AnyChain;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ export {};
@@ -1,13 +1,18 @@
1
- import type { GuardOut, TransformOut } from "../plugin-kit/marker.types";
1
+ import type { BundleOut, GuardOut, StitchOut, TransformOut } from "../plugin-kit/marker.types";
2
+ import type { BundleChain, BundlePaths } from "./bundle-paths.types";
2
3
  import type { PluginDefinition, PluginSignature } from "../plugin-kit/plugin-definition";
3
- import type { Present, RuleOptions, TypeName } from "../types";
4
+ import type { CrossFieldOutcome, Present, RuleOptions, TypeName } from "../types";
5
+ import type { FieldPath } from "../path/field-path.types";
6
+ import type { PickPaths } from "../path/value-at-path.types";
4
7
  import type { PluginBag } from "./plugin-bag.types";
5
8
  import type { ChainState, CoverWith } from "./chain-state.types";
6
9
  import type { ResolveArgs, ResolveOut } from "./resolve-args.types";
7
10
  import type { AnyChain, FieldChain } from "./field-chain.types";
8
11
  import type { FieldSlots } from "./field-slots.types";
9
12
  /** One plugin definition -> one call signature. */
10
- export type ChainMethod<P, B extends PluginBag, S extends TypeName, TRoot, TValue, TState extends ChainState> = P extends PluginDefinition<string, string, readonly TypeName[], infer Sig extends PluginSignature> ? [Sig["out"]] extends [TransformOut] ? <R>(map: (value: Present<TValue, TState>) => R, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, R, TState> : [Sig["out"]] extends [GuardOut] ? <X extends Present<TValue, TState>>(condition: (value: Present<TValue, TState>) => value is X, define: (b: FieldSlots<TRoot, B, X>) => AnyChain, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, TValue, CoverWith<TState, X>> : (...args: [
13
+ export type ChainMethod<P, B extends PluginBag, S extends TypeName, TRoot, TValue, TState extends ChainState> = P extends PluginDefinition<string, string, readonly TypeName[], infer Sig extends PluginSignature> ? [Sig["out"]] extends [TransformOut] ? <R>(map: (value: Present<TValue, TState>) => R, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, R, TState> : [Sig["out"]] extends [GuardOut] ? <X extends Present<TValue, TState>>(condition: (value: Present<TValue, TState>) => value is X, define: (b: FieldSlots<TRoot, B, X>) => AnyChain, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, TValue, CoverWith<TState, X>> : [
14
+ Sig["out"]
15
+ ] extends [StitchOut] ? <const F extends readonly (FieldPath<TRoot> & string)[]>(fields: F, check: (fieldValues: PickPaths<TRoot, F>, value: Present<TValue, TState>, root: TRoot) => CrossFieldOutcome, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, TValue, TState> : [Sig["out"]] extends [BundleOut] ? <const M extends BundlePaths<TRoot>>(fields: M, define: BundleChain<TRoot, M, B>, options?: RuleOptions<Sig["context"]>) => FieldChain<B, S, TRoot, TValue, TState> : (...args: [
11
16
  ...ResolveArgs<Sig["args"], B, TRoot, TValue, TState>,
12
17
  options?: RuleOptions<Sig["context"]>
13
18
  ]) => ResolveOut<Sig["out"], TValue, TState> extends [
@@ -1,5 +1,5 @@
1
1
  import type { ArrayItemContext, Present } from "../types";
2
- import type { ElementChain, FieldRef, FieldRefs, GuardOut, MarkerRecord, NarrowedChain, PresenceShift, PropertyKeyChain, PropertyValueChain, RootPredicate, RootReader, SelfGuard, SelfReader, SelfValue, TransformOut, Unchanged } from "../plugin-kit/marker.types";
2
+ import type { ElementChain, FieldRef, FieldRefs, GuardOut, MarkerRecord, NarrowedChain, PresenceShift, PropertyKeyChain, PropertyValueChain, RootPredicate, RootReader, SelfGuard, SelfReader, SelfValue, BundleOut, StitchOut, TransformOut, Unchanged } from "../plugin-kit/marker.types";
3
3
  import type { ElementOf } from "../path/element-of.types";
4
4
  import type { PropertyValueOf } from "../path/property-value-of.types";
5
5
  import type { FieldPath } from "../path/field-path.types";
@@ -24,5 +24,7 @@ export type ResolveArgs<A extends readonly unknown[], B extends PluginBag, TRoot
24
24
  /** Presence shifts go through the NAMED operators over ChainState. */
25
25
  export type ResolveOut<O, TValue, TState extends ChainState> = [O] extends [
26
26
  Unchanged
27
- ] ? [TValue, TState] : [O] extends [TransformOut] ? [unknown, TState] : [O] extends [GuardOut] ? [TValue, TState] : [O] extends [PresenceShift<"excludeMissing">] ? [TValue, ExcludeMissing<TState>] : [O] extends [PresenceShift<"excludeUndefined">] ? [TValue, ExcludeUndefined<TState>] : [O] extends [PresenceShift<"excludeNull">] ? [TValue, ExcludeNull<TState>] : [O] extends [PresenceShift<"allowNull">] ? [TValue | null, AllowNull<TState>] : [O, TState];
27
+ ] ? [TValue, TState] : [O] extends [TransformOut] ? [unknown, TState] : [O] extends [GuardOut] ? [TValue, TState] : [
28
+ O
29
+ ] extends [StitchOut] ? [TValue, TState] : [O] extends [BundleOut] ? [TValue, TState] : [O] extends [PresenceShift<"excludeMissing">] ? [TValue, ExcludeMissing<TState>] : [O] extends [PresenceShift<"excludeUndefined">] ? [TValue, ExcludeUndefined<TState>] : [O] extends [PresenceShift<"excludeNull">] ? [TValue, ExcludeNull<TState>] : [O] extends [PresenceShift<"allowNull">] ? [TValue | null, AllowNull<TState>] : [O, TState];
28
30
  export {};
@@ -9,11 +9,4 @@ export interface NodeCompileContext {
9
9
  }
10
10
  /** The one adapter from a grouped declaration to compileField's request. */
11
11
  export declare function compileRelativeDeclaration(declaration: RelativeDeclaration, context: NodeCompileContext): CompiledField;
12
- /**
13
- * Nesting is a re-grouping of the members, not a second traversal strategy:
14
- * `items[*].sub[*].x` arrives here as the group `items` whose one member is
15
- * still `sub[*].x`, and that member groups again into the nested node `sub`.
16
- * `matrix[*][*]` reaches the same shape through an EMPTY nested template —
17
- * the element of the outer array is itself the inner array.
18
- */
19
12
  export declare function compileArrayNode(group: ArrayFieldGroup, context: NodeCompileContext): ArrayNode;
@@ -16,6 +16,7 @@ exports.compileArrayNode = compileArrayNode;
16
16
  // silently unvalidated.
17
17
  // ===========================================================================
18
18
  const create_array_reader_1 = require("../path/create-array-reader");
19
+ const format_issue_path_1 = require("../path/format-issue-path");
19
20
  const compile_field_1 = require("./compile-field");
20
21
  const group_array_fields_1 = require("./group-array-fields");
21
22
  /** The one adapter from a grouped declaration to compileField's request. */
@@ -37,11 +38,14 @@ function compileRelativeDeclaration(declaration, context) {
37
38
  * `matrix[*][*]` reaches the same shape through an EMPTY nested template —
38
39
  * the element of the outer array is itself the inner array.
39
40
  */
41
+ /** A grouped node template never keeps a wildcard, so rendering needs no index. */
42
+ const NO_INDICES = Object.freeze([]);
40
43
  function compileArrayNode(group, context) {
41
44
  const grouped = (0, group_array_fields_1.groupArrayFields)(group.members);
42
45
  const template = Object.freeze(group.template);
43
46
  const node = {
44
47
  template,
48
+ renderedPath: (0, format_issue_path_1.formatIssuePath)(template, NO_INDICES),
45
49
  read: (0, create_array_reader_1.createArrayReader)(template),
46
50
  elementFields: Object.freeze(grouped.direct.map((declaration) => compileRelativeDeclaration(declaration, context))),
47
51
  nested: Object.freeze(grouped.arrays.map((nested) => compileArrayNode(nested, context))),
@@ -12,6 +12,7 @@
12
12
  // silently unvalidated.
13
13
  // ===========================================================================
14
14
  import { createArrayReader } from "../path/create-array-reader.mjs";
15
+ import { formatIssuePath } from "../path/format-issue-path.mjs";
15
16
  import { compileField } from "./compile-field.mjs";
16
17
  import { groupArrayFields, } from "./group-array-fields.mjs";
17
18
  /** The one adapter from a grouped declaration to compileField's request. */
@@ -33,11 +34,14 @@ export function compileRelativeDeclaration(declaration, context) {
33
34
  * `matrix[*][*]` reaches the same shape through an EMPTY nested template —
34
35
  * the element of the outer array is itself the inner array.
35
36
  */
37
+ /** A grouped node template never keeps a wildcard, so rendering needs no index. */
38
+ const NO_INDICES = Object.freeze([]);
36
39
  export function compileArrayNode(group, context) {
37
40
  const grouped = groupArrayFields(group.members);
38
41
  const template = Object.freeze(group.template);
39
42
  const node = {
40
43
  template,
44
+ renderedPath: formatIssuePath(template, NO_INDICES),
41
45
  read: createArrayReader(template),
42
46
  elementFields: Object.freeze(grouped.direct.map((declaration) => compileRelativeDeclaration(declaration, context))),
43
47
  nested: Object.freeze(grouped.arrays.map((nested) => compileArrayNode(nested, context))),