@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.
- package/README.md +269 -52
- package/dist/builder/builder-surface.types.d.ts +1 -0
- package/dist/builder/create-builder.js +9 -0
- package/dist/builder/create-builder.mjs +9 -0
- package/dist/builder/field-builder.types.d.ts +13 -0
- package/dist/chain/bundle-paths.types.d.ts +26 -0
- package/dist/chain/bundle-paths.types.js +2 -0
- package/dist/chain/bundle-paths.types.mjs +1 -0
- package/dist/chain/chain-method.types.d.ts +8 -3
- package/dist/chain/resolve-args.types.d.ts +4 -2
- package/dist/compile/compile-array-node.d.ts +0 -7
- package/dist/compile/compile-array-node.js +4 -0
- package/dist/compile/compile-array-node.mjs +4 -0
- package/dist/compile/compile-field.js +10 -1
- package/dist/compile/compile-field.mjs +10 -1
- package/dist/compile/split-rules-by-kind.js +31 -7
- package/dist/compile/split-rules-by-kind.mjs +31 -7
- package/dist/compile/validation-plan.types.d.ts +23 -0
- package/dist/json-schema/flatten-schema.js +1 -1
- package/dist/json-schema/flatten-schema.mjs +1 -1
- package/dist/plugin-kit/marker.types.d.ts +18 -0
- package/dist/plugins/index.generated.d.ts +1 -0
- package/dist/plugins/index.generated.js +4 -2
- package/dist/plugins/index.generated.mjs +1 -0
- package/dist/plugins/manifest.generated.js +1 -0
- package/dist/plugins/manifest.generated.mjs +1 -0
- package/dist/plugins/stitch/stitch.d.ts +28 -8
- package/dist/plugins/stitch-with/index.d.ts +2 -0
- package/dist/plugins/stitch-with/index.js +5 -0
- package/dist/plugins/stitch-with/index.mjs +1 -0
- package/dist/plugins/stitch-with/stitch-with.d.ts +12 -0
- package/dist/plugins/stitch-with/stitch-with.js +94 -0
- package/dist/plugins/stitch-with/stitch-with.mjs +91 -0
- package/dist/plugins/stitchWith.d.ts +1 -0
- package/dist/plugins/stitchWith.js +2 -0
- package/dist/plugins/stitchWith.mjs +1 -0
- package/dist/plugins/string-min/string-min.js +21 -3
- package/dist/plugins/string-min/string-min.mjs +22 -4
- package/dist/presets/index.d.ts +1 -0
- package/dist/presets/index.js +9 -0
- package/dist/presets/index.mjs +1 -0
- package/dist/presets/presets.d.ts +165 -0
- package/dist/presets/presets.js +80 -0
- package/dist/presets/presets.mjs +77 -0
- package/dist/runtime/create-field-validator.js +24 -8
- package/dist/runtime/create-field-validator.mjs +24 -8
- package/dist/runtime/create-validator.js +38 -8
- package/dist/runtime/create-validator.mjs +38 -8
- package/dist/runtime/field-rule-context.d.ts +32 -0
- package/dist/runtime/field-rule-context.js +48 -0
- package/dist/runtime/field-rule-context.mjs +44 -0
- package/dist/runtime/index-stack.d.ts +29 -4
- package/dist/runtime/index-stack.js +76 -15
- package/dist/runtime/index-stack.mjs +76 -15
- package/dist/runtime/run-array-node.js +22 -10
- package/dist/runtime/run-array-node.mjs +22 -10
- package/dist/runtime/run-field.js +47 -17
- package/dist/runtime/run-field.mjs +47 -17
- package/dist/runtime/run-plan.js +5 -1
- package/dist/runtime/run-plan.mjs +5 -1
- package/dist/types/index.d.ts +12 -0
- 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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
47
|
-
is
|
|
48
|
-
|
|
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`.
|
|
113
|
-
>
|
|
114
|
-
>
|
|
115
|
-
>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
331
|
-
|
|
|
332
|
-
|
|
|
333
|
-
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
369
|
-
|
|
|
370
|
-
|
|
|
371
|
-
|
|
|
372
|
-
|
|
|
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
|
-
|
|
381
|
-
|
|
|
382
|
-
|
|
|
383
|
-
|
|
|
384
|
-
|
|
|
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.
|
|
395
|
-
|
|
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**.
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
|
440
|
-
|
|
441
|
-
`
|
|
442
|
-
|
|
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
|
|
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
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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 @@
|
|
|
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>> :
|
|
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] : [
|
|
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))),
|