@maroonedog/luq 2.1.0 → 2.3.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 (161) hide show
  1. package/README.md +92 -430
  2. package/dist/builder/builder-surface.types.d.ts +1 -0
  3. package/dist/builder/compile-declarations.d.ts +7 -1
  4. package/dist/builder/compile-declarations.js +18 -8
  5. package/dist/builder/compile-declarations.mjs +18 -8
  6. package/dist/builder/create-builder.js +8 -0
  7. package/dist/builder/create-builder.mjs +8 -0
  8. package/dist/builder/create-field-builder.js +13 -1
  9. package/dist/builder/create-field-builder.mjs +13 -1
  10. package/dist/builder/declared-calls-store.d.ts +9 -0
  11. package/dist/builder/declared-calls-store.js +19 -0
  12. package/dist/builder/declared-calls-store.mjs +15 -0
  13. package/dist/builder/field-builder.types.d.ts +13 -0
  14. package/dist/builder/field-declared-calls.types.d.ts +6 -0
  15. package/dist/builder/field-declared-calls.types.js +2 -0
  16. package/dist/builder/field-declared-calls.types.mjs +1 -0
  17. package/dist/builder/field-entry.types.d.ts +9 -3
  18. package/dist/builder/field-options.types.d.ts +26 -0
  19. package/dist/chain/bundle-paths.types.d.ts +23 -0
  20. package/dist/chain/bundle-paths.types.js +2 -0
  21. package/dist/chain/bundle-paths.types.mjs +1 -0
  22. package/dist/chain/chain-method.types.d.ts +8 -3
  23. package/dist/chain/chain-node-store.d.ts +5 -0
  24. package/dist/chain/chain-node-store.js +15 -0
  25. package/dist/chain/chain-node-store.mjs +11 -0
  26. package/dist/chain/collect-field-rules.d.ts +13 -2
  27. package/dist/chain/collect-field-rules.js +10 -3
  28. package/dist/chain/collect-field-rules.mjs +10 -3
  29. package/dist/chain/create-chain-node.js +19 -9
  30. package/dist/chain/create-chain-node.mjs +19 -9
  31. package/dist/chain/declaration-recorder.port.d.ts +31 -0
  32. package/dist/chain/declaration-recorder.port.js +17 -0
  33. package/dist/chain/declaration-recorder.port.mjs +13 -0
  34. package/dist/chain/declared-call.types.d.ts +15 -0
  35. package/dist/chain/declared-call.types.js +2 -0
  36. package/dist/chain/declared-call.types.mjs +1 -0
  37. package/dist/chain/index.d.ts +3 -1
  38. package/dist/chain/resolve-args.types.d.ts +4 -2
  39. package/dist/compile/compile-array-node.d.ts +0 -7
  40. package/dist/compile/compile-array-node.js +5 -0
  41. package/dist/compile/compile-array-node.mjs +5 -0
  42. package/dist/compile/compile-field.d.ts +1 -0
  43. package/dist/compile/compile-field.js +13 -2
  44. package/dist/compile/compile-field.mjs +13 -2
  45. package/dist/compile/compile-schema.js +4 -0
  46. package/dist/compile/compile-schema.mjs +4 -0
  47. package/dist/compile/group-array-fields.d.ts +1 -0
  48. package/dist/compile/split-rules-by-kind.js +27 -7
  49. package/dist/compile/split-rules-by-kind.mjs +27 -7
  50. package/dist/compile/validation-plan.types.d.ts +36 -0
  51. package/dist/core/type-erasure.d.ts +36 -30
  52. package/dist/core/type-erasure.js +36 -30
  53. package/dist/core/type-erasure.mjs +36 -30
  54. package/dist/json-schema/build-from-schema.js +8 -1
  55. package/dist/json-schema/build-from-schema.mjs +8 -1
  56. package/dist/json-schema/declare-additional-properties.d.ts +7 -7
  57. package/dist/json-schema/declare-additional-properties.js +7 -7
  58. package/dist/json-schema/declare-additional-properties.mjs +7 -7
  59. package/dist/json-schema/declare-object-keywords.js +4 -4
  60. package/dist/json-schema/declare-object-keywords.mjs +4 -4
  61. package/dist/json-schema/flatten-schema.js +1 -1
  62. package/dist/json-schema/flatten-schema.mjs +1 -1
  63. package/dist/json-schema/follow-json-pointer.d.ts +7 -6
  64. package/dist/json-schema/follow-json-pointer.js +24 -24
  65. package/dist/json-schema/follow-json-pointer.mjs +24 -24
  66. package/dist/json-schema/ref-resolution-error.js +3 -3
  67. package/dist/json-schema/ref-resolution-error.mjs +3 -3
  68. package/dist/json-schema/schema-registry.js +12 -11
  69. package/dist/json-schema/schema-registry.mjs +12 -11
  70. package/dist/json-schema/uri-reference.js +12 -12
  71. package/dist/json-schema/uri-reference.mjs +12 -12
  72. package/dist/path/create-value-writer.js +12 -12
  73. package/dist/path/create-value-writer.mjs +12 -12
  74. package/dist/path/reserved-segment.d.ts +16 -16
  75. package/dist/path/reserved-segment.js +17 -21
  76. package/dist/path/reserved-segment.mjs +17 -21
  77. package/dist/plugin-kit/marker.types.d.ts +18 -0
  78. package/dist/plugins/index.generated.d.ts +1 -0
  79. package/dist/plugins/index.generated.js +6 -4
  80. package/dist/plugins/index.generated.mjs +3 -2
  81. package/dist/plugins/manifest.generated.js +3 -2
  82. package/dist/plugins/manifest.generated.mjs +3 -2
  83. package/dist/plugins/object-additional-properties/select-additional-keys.d.ts +8 -8
  84. package/dist/plugins/object-additional-properties/select-additional-keys.js +16 -16
  85. package/dist/plugins/object-additional-properties/select-additional-keys.mjs +16 -16
  86. package/dist/plugins/stitch/stitch.d.ts +24 -8
  87. package/dist/plugins/stitch-with/index.d.ts +2 -0
  88. package/dist/plugins/stitch-with/index.js +5 -0
  89. package/dist/plugins/stitch-with/index.mjs +1 -0
  90. package/dist/plugins/stitch-with/stitch-with.d.ts +12 -0
  91. package/dist/plugins/stitch-with/stitch-with.js +92 -0
  92. package/dist/plugins/stitch-with/stitch-with.mjs +89 -0
  93. package/dist/plugins/stitchWith.d.ts +1 -0
  94. package/dist/plugins/stitchWith.js +2 -0
  95. package/dist/plugins/stitchWith.mjs +1 -0
  96. package/dist/plugins/string-min/string-min.js +19 -3
  97. package/dist/plugins/string-min/string-min.mjs +20 -4
  98. package/dist/presets/index.d.ts +1 -0
  99. package/dist/presets/index.js +9 -0
  100. package/dist/presets/index.mjs +1 -0
  101. package/dist/presets/presets.d.ts +164 -0
  102. package/dist/presets/presets.js +79 -0
  103. package/dist/presets/presets.mjs +76 -0
  104. package/dist/runtime/create-field-validator.js +22 -8
  105. package/dist/runtime/create-field-validator.mjs +22 -8
  106. package/dist/runtime/create-validator.js +38 -8
  107. package/dist/runtime/create-validator.mjs +38 -8
  108. package/dist/runtime/field-rule-context.d.ts +32 -0
  109. package/dist/runtime/field-rule-context.js +48 -0
  110. package/dist/runtime/field-rule-context.mjs +44 -0
  111. package/dist/runtime/index-stack.d.ts +29 -4
  112. package/dist/runtime/index-stack.js +76 -15
  113. package/dist/runtime/index-stack.mjs +76 -15
  114. package/dist/runtime/output-writer.js +5 -1
  115. package/dist/runtime/output-writer.mjs +5 -1
  116. package/dist/runtime/run-array-node.js +22 -10
  117. package/dist/runtime/run-array-node.mjs +22 -10
  118. package/dist/runtime/run-field.js +47 -18
  119. package/dist/runtime/run-field.mjs +47 -18
  120. package/dist/runtime/run-plan.js +5 -1
  121. package/dist/runtime/run-plan.mjs +5 -1
  122. package/dist/standard-schema/assemble-json-schema.d.ts +4 -0
  123. package/dist/standard-schema/assemble-json-schema.js +95 -0
  124. package/dist/standard-schema/assemble-json-schema.mjs +92 -0
  125. package/dist/standard-schema/declaration-recorder.d.ts +6 -0
  126. package/dist/standard-schema/declaration-recorder.js +30 -0
  127. package/dist/standard-schema/declaration-recorder.mjs +27 -0
  128. package/dist/standard-schema/declarations-unavailable-error.d.ts +4 -0
  129. package/dist/standard-schema/declarations-unavailable-error.js +32 -0
  130. package/dist/standard-schema/declarations-unavailable-error.mjs +28 -0
  131. package/dist/standard-schema/emit-field-schema.d.ts +9 -0
  132. package/dist/standard-schema/emit-field-schema.js +68 -0
  133. package/dist/standard-schema/emit-field-schema.mjs +65 -0
  134. package/dist/standard-schema/index.d.ts +5 -0
  135. package/dist/standard-schema/index.js +9 -1
  136. package/dist/standard-schema/index.mjs +4 -0
  137. package/dist/standard-schema/json-schema-target.d.ts +6 -0
  138. package/dist/standard-schema/json-schema-target.js +44 -0
  139. package/dist/standard-schema/json-schema-target.mjs +39 -0
  140. package/dist/standard-schema/plugin-keyword-map.d.ts +3 -0
  141. package/dist/standard-schema/plugin-keyword-map.js +93 -0
  142. package/dist/standard-schema/plugin-keyword-map.mjs +90 -0
  143. package/dist/standard-schema/split-issue-path.d.ts +6 -4
  144. package/dist/standard-schema/split-issue-path.js +15 -13
  145. package/dist/standard-schema/split-issue-path.mjs +15 -13
  146. package/dist/standard-schema/standard-schema.types.d.ts +8 -7
  147. package/dist/standard-schema/standard-schema.types.js +6 -6
  148. package/dist/standard-schema/standard-schema.types.mjs +6 -6
  149. package/dist/standard-schema/to-standard-json-schema.d.ts +19 -0
  150. package/dist/standard-schema/to-standard-json-schema.js +36 -0
  151. package/dist/standard-schema/to-standard-json-schema.mjs +33 -0
  152. package/dist/standard-schema/to-standard-schema.d.ts +16 -15
  153. package/dist/standard-schema/to-standard-schema.js +15 -22
  154. package/dist/standard-schema/to-standard-schema.mjs +15 -22
  155. package/dist/standard-schema/unrepresentable-rule-error.d.ts +15 -0
  156. package/dist/standard-schema/unrepresentable-rule-error.js +43 -0
  157. package/dist/standard-schema/unrepresentable-rule-error.mjs +38 -0
  158. package/dist/types/index.d.ts +19 -7
  159. package/dist/types/index.js +7 -7
  160. package/dist/types/index.mjs +7 -7
  161. package/package.json +35 -19
package/README.md CHANGED
@@ -8,28 +8,32 @@
8
8
 
9
9
  **A TypeScript validation library where the wrong rule does not compile.**
10
10
 
11
+ [Documentation](https://luq.dev) · [Getting started](https://luq.dev/docs/getting-started) · [Plugins](https://luq.dev/plugins) · [JSON Schema](https://luq.dev/json-schema)
12
+
11
13
  </div>
12
14
 
15
+ ## Why this exists
16
+
13
17
  Your types were probably not written by you. `openapi-typescript` generates
14
18
  them from a spec you do not own. Prisma and Drizzle generate them from the
15
19
  schema of record. protobuf and GraphQL codegen generate them for services in
16
20
  four languages at once. Increasingly, a model generates the code that uses them.
17
21
 
18
22
  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.
23
+ decides the shape. When that assumption holds, it is the better arrangement and
24
+ this page says so again below. When it does not, you end up maintaining a second
25
+ description of a shape you did not choose. You can have the compiler check the
26
+ copy against the original — zod's `satisfies z.ZodType<Order>` does exactly that
27
+ — but you still author it, update it, and remember to write the check.
23
28
 
24
29
  Luq runs the other way. It takes the type you already have and lets you declare
25
- rules against its field paths. What makes those declarations worth writing is
26
- that the compiler checks them against the type: a rule that does not apply to
27
- the field it is written on is a compile error, not a rule that quietly never
28
- fires.
30
+ rules against its field paths. **Your type definitions do not change**: not
31
+ re-authored as a schema, not replaced by an inferred one, not moved. `.for<Order>()`
32
+ takes the `Order` you already have, exactly as it is.
29
33
 
30
- That matters most when the code calling this library is generated rather than
31
- typed by hand. A generator that picks the wrong rule, misspells a path or drops
32
- an array wildcard gets a red squiggle, not a validator that passes everything.
34
+ What makes those declarations worth writing is that the compiler checks them
35
+ against the type: a rule that does not apply to the field it is written on is a
36
+ compile error, not a rule that quietly never fires.
33
37
 
34
38
  | Mistake | Result |
35
39
  |---|---|
@@ -40,12 +44,50 @@ an array wildcard gets a red squiggle, not a validator that passes everything.
40
44
  | A JSON Schema keyword bound to a chain method that does not exist | compile error |
41
45
  | A documented example drifting from the API | fails CI |
42
46
 
43
- Every rule you can call is a plugin you imported by name, so the bundle contains
44
- what you used and nothing else.
47
+ That matters most when the code calling this library is generated rather than
48
+ typed by hand. A generator that picks the wrong rule, misspells a path or drops
49
+ an array wildcard gets a red squiggle, not a validator that passes everything.
45
50
 
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.
51
+ Two consequences worth knowing before you read further:
52
+
53
+ - **Adoption is a patch, not a migration.** A path you did not declare is not
54
+ validated, not required, and not read, so a partly-covered type is a normal
55
+ state rather than a half-finished one. There is nothing global to migrate: no
56
+ registry, no plugin installation, no shared configuration object.
57
+ - **Every rule you can call is a plugin you imported by name**, so the bundle
58
+ contains what you used and nothing else — an unimported plugin's method does
59
+ not even typecheck. What each configuration costs is measured on every build:
60
+ [luq.dev/benchmarks](https://luq.dev/benchmarks).
61
+
62
+ ## When schema-first is the right answer
63
+
64
+ Worth stating plainly, because it is a real tension and not a debating point:
65
+ **if the schema genuinely is your single source of truth, schema-first is the
66
+ coherent arrangement, and zod, valibot or TypeBox are the right tools.** You
67
+ write one artefact, your types come out of it, and there is nothing to keep in
68
+ step. That is a better position than Luq's, and Luq cannot give it to you.
69
+
70
+ Luq is for the case where that artefact already exists somewhere else and is not
71
+ yours to move — an OpenAPI document you consume, a Prisma schema, a `.proto`
72
+ shared with three other services, a type someone generated last week. There, the
73
+ schema-first arrangement asks you to author a *second* source of truth, and the
74
+ question stops being which library is nicer and becomes which copy is right.
75
+
76
+ Two things follow that are easy to miss:
77
+
78
+ - **Luq contains both directions.** `fromJsonSchema(document)` is schema-first —
79
+ the document decides, and Luq builds the rules from it. That is not a
80
+ contradiction to be argued away; it is the same principle applied to a
81
+ different upstream. What Luq declines to do is make you *hand-write* the second
82
+ copy.
83
+ - **You do not have to pick a side per project, only per boundary.** Standard
84
+ Schema means a zod schema and a Luq validator are interchangeable where they
85
+ meet, so "the schema is the truth here, the type is the truth there" is a
86
+ workable arrangement rather than an unresolved argument.
87
+
88
+ If you are starting from nothing and you will own the shape, use zod. It is
89
+ mature, it is everywhere, and every question you will have is already answered
90
+ somewhere.
49
91
 
50
92
  ## Install
51
93
 
@@ -53,426 +95,46 @@ each figure is named next to it.
53
95
  npm install @maroonedog/luq
54
96
  ```
55
97
 
56
- ## Quick start
57
-
58
- ```ts
59
- import { Builder } from "@maroonedog/luq";
60
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
61
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
62
- import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
63
- import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
64
-
65
- type User = {
66
- name: string;
67
- age: number;
68
- email: string;
69
- };
70
-
71
- const userValidator = Builder()
72
- .use(requiredPlugin)
73
- .use(stringMinPlugin)
74
- .use(stringEmailPlugin)
75
- .use(numberMinPlugin)
76
- .for<User>()
77
- .v("name", (b) => b.string.required().min(3))
78
- .v("age", (b) => b.number.required().min(18))
79
- .v("email", (b) => b.string.required().email())
80
- .build();
81
-
82
- const result = userValidator.validate({
83
- name: "Jo",
84
- age: 25,
85
- email: "jo@example.com",
86
- });
87
-
88
- if (result.valid) {
89
- // `data` exists only on this branch — no cast, no non-null assertion.
90
- console.error(result.data.name);
91
- } else {
92
- for (const issue of result.issues) {
93
- console.error(`${issue.path}: ${issue.message} (${issue.code})`);
94
- }
95
- }
96
- ```
97
-
98
- `build()` returns an **object**, not a function. It has four members:
99
-
100
- | Member | What it gives back |
101
- |---|---|
102
- | `validate(value, options?)` | `ValidationResult<T>` holding the **original** value |
103
- | `parse(value, options?)` | `ValidationResult<TParsed>` holding the value **after transforms** |
104
- | `pick(path)` | a single-field validator for one declared path |
105
- | `pickAll(paths)` | a validator returning exactly those paths, keyed by the path string |
106
-
107
- A `ValidationResult<T>` is a discriminated union on `valid`: the success branch
108
- carries `data`, both branches carry `issues`, and each issue is
109
- `{ path, code, message, severity }`.
110
-
111
- > 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.
116
-
117
- ## Field paths
118
-
119
- A path is a string literal that TypeScript resolves against your type. Nested
120
- fields use dots; array elements use `[*]`, and the issue you get back names the
121
- **real index**.
122
-
123
- ```ts
124
- import { Builder } from "@maroonedog/luq";
125
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
126
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
127
-
128
- type Order = {
129
- customer: { name: string };
130
- items: { productId: string }[];
131
- };
132
-
133
- const orderValidator = Builder()
134
- .use(requiredPlugin)
135
- .use(stringMinPlugin)
136
- .for<Order>()
137
- .v("customer.name", (b) => b.string.required().min(2))
138
- .v("items[*].productId", (b) => b.string.required().min(5))
139
- .build();
140
-
141
- const rejected = orderValidator.validate({
142
- customer: { name: "Acme" },
143
- items: [{ productId: "PROD-1" }, { productId: "X" }],
144
- });
145
-
146
- // -> ["items[1].productId"], never "items[*].productId"
147
- export const failedPaths = rejected.issues.map((issue) => issue.path);
148
- ```
149
-
150
- A path that does not exist on the type is a compile error, not a silent no-op.
151
- So is choosing a slot the field's type cannot be: `b.number` on a `string`
152
- field fails to compile.
153
-
154
- ## Plugins are imports
155
-
156
- There is no plugin registry to populate and no barrel you have to pay for.
157
- `.use()` puts a plugin in the builder's bag, and the bag decides which methods
158
- exist on which slots — so an unimported plugin is not merely absent at runtime,
159
- its method does not typecheck.
160
-
161
- ```ts
162
- import { Builder } from "@maroonedog/luq";
163
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
164
-
165
- type Draft = { title: string };
166
-
167
- const draftValidator = Builder()
168
- .use(requiredPlugin)
169
- .for<Draft>()
170
- // `.min(3)` is not offered here: stringMinPlugin was never `use`d.
171
- .v("title", (b) => b.string.required())
172
- .build();
173
-
174
- export const isTitled = draftValidator.validate({ title: "x" }).valid;
175
- ```
176
-
177
- 77 plugin objects ship across 76 subpaths, plus one deprecated alias kept from
178
- 1.x. The complete table — subpath, symbol, chain method, slots — is generated
179
- from the built package: **[docs/guide/plugin-reference.md](docs/guide/plugin-reference.md)**.
180
-
181
- A convenience barrel exists at `@maroonedog/luq/plugins`. It is measurably free
182
- when a bundler can tree-shake (three plugins via the barrel gzip to 7,986 B
183
- against 7,987 B via three subpaths — 0.01%), but the per-plugin subpaths are the
184
- supported route.
185
-
186
- ## JSON Schema
187
-
188
- ```ts
189
- import { fromJsonSchema } from "@maroonedog/luq/plugins/jsonSchemaFullFeature";
190
-
191
- type Account = { email: string; age?: number };
192
-
193
- const accountValidator = fromJsonSchema<Account>({
194
- type: "object",
195
- properties: {
196
- email: { type: "string", format: "email" },
197
- age: { type: "number", minimum: 18 },
198
- },
199
- required: ["email"],
200
- });
201
-
202
- export const accepted = accountValidator.validate({
203
- email: "a@example.com",
204
- }).valid;
205
- ```
206
-
207
- Measured Draft-07 conformance against the official
208
- [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
209
- (required tests only, skipped cases counted as **failures**):
210
-
211
- **929 / 929 = 100.00%.** Read that against the floor, not against zero: a
212
- validator that returned `true` unconditionally scores 551 / 929 = 59.31% on
213
- this corpus. The skip list is empty, and its `cause` union is `never`, so
214
- excluding a case again means adding a name to a type. Full breakdown, including
215
- what closed each cause and what is still bounded:
216
- [docs/json-schema-conformance.md](docs/json-schema-conformance.md).
217
-
218
- External `$ref` resolves against a map of documents **you** already have —
219
- `jsonSchemaFullFeature(document, { externalDocuments })`. Luq never fetches, so
220
- a URI written in a schema cannot make the process open a socket, conversion
221
- stays synchronous, and nothing is evaluated.
222
-
223
- `jsonSchemaFullFeature` bundles 49 plugins so one import covers a whole
224
- document. `@maroonedog/luq/plugins/jsonSchema` adds a chain method instead, so a
225
- single declared field can be constrained by a document; it takes the plugin bag
226
- explicitly. It is billed as the tree-shakeable half, and measured below, it is
227
- not — use it for the chain method, not to save bytes.
228
-
229
- ## Standard Schema
230
-
231
- Luq implements [Standard Schema v1](https://standardschema.dev). Anything that
232
- accepts a Standard Schema — tRPC, TanStack Form, Hono, t3-env — accepts a Luq
233
- validator wherever it accepts a zod schema.
234
-
235
- ```ts
236
- import { Builder } from "@maroonedog/luq";
237
- import { requiredPlugin } from "@maroonedog/luq/plugins/required";
238
- import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
239
- import { toStandardSchema } from "@maroonedog/luq/standard-schema";
240
-
241
- type Account = { handle: string };
242
-
243
- const standard = toStandardSchema(
244
- Builder()
245
- .use(requiredPlugin)
246
- .use(stringMinPlugin)
247
- .for<Account>()
248
- .v("handle", (b) => b.string.required().min(2))
249
- .build()
250
- );
251
-
252
- // `standard` is still the Validator — `validate`, `parse`, `pick` and `pickAll`
253
- // are all there — and it now also satisfies Standard Schema v1, so it can be
254
- // handed to tRPC, TanStack Form, Hono or t3-env unchanged.
255
- const outcome = standard["~standard"].validate({ handle: "j" });
256
-
257
- if (outcome.issues === undefined) {
258
- console.error(outcome.value.handle);
259
- } else {
260
- for (const issue of outcome.issues) {
261
- console.error(issue.message, issue.path);
262
- }
263
- }
264
- ```
265
-
266
- Three decisions the spec leaves open, made explicit here:
267
-
268
- - `validate` calls Luq's `parse()`, not `validate()`. The spec's success result
269
- is `{ value: Output }`, and `Output` is the value *after* validation — so a
270
- `transform` has to be applied, and only `parse()` applies it.
271
- - It collects every issue rather than stopping at the first. The consumer of
272
- this seam is a form, and returning one issue at a time produces a UI where
273
- fixing an error reveals the next one. Callers who want the fast path use the
274
- `Validator` directly.
275
- - `InferInput` is the type you wrote in `.for<T>()`, not a type inferred back
276
- out of a schema value.
277
-
278
- It is a subpath, not part of `build()`. Measured: the core gzips to 7,420 B and
279
- carrying `~standard` on every validator adds 312 B — 4.2% charged to everyone,
280
- including the people who never pass a validator to tRPC. Importing the subpath
281
- costs those 312 B only when you import it, and nothing when you don't.
282
-
283
- ## Your own rules
284
-
285
- ```ts
286
- import { definePlugin, check, PASS, fail } from "@maroonedog/luq/plugin-kit";
287
- import type { Unchanged } from "@maroonedog/luq/plugin-kit";
288
-
289
- export const productCodePlugin = definePlugin<{
290
- args: readonly [];
291
- out: Unchanged;
292
- context: { readonly prefix: string };
293
- }>()({
294
- name: "productCode",
295
- method: "productCode",
296
- slots: ["string"] as const,
297
- build: (ctx) =>
298
- check({
299
- code: ctx.code,
300
- messageFactory: ctx.messageFactory,
301
- severity: ctx.severity,
302
- run: (value) =>
303
- typeof value === "string" && value.startsWith("PROD-")
304
- ? PASS
305
- : fail({ actual: value }),
306
- describe: (_detail, messageContext) =>
307
- `${messageContext.path} must start with PROD-`,
308
- buildMessageContext: () => ({ prefix: "PROD-" }),
309
- }),
310
- });
311
- ```
312
-
313
- The `out` marker is the whole contract: it decides what `build()` is allowed to
314
- return and what the chain's type becomes afterwards. Eleven argument markers and
315
- four output markers make up the vocabulary — see
316
- **[docs/guide/writing-a-plugin.md](docs/guide/writing-a-plugin.md)**.
317
-
318
- ## Measured numbers
319
-
320
- ### Bundle size
321
-
322
- esbuild 0.25.5, `bundle + minify + esm + es2020 + platform:neutral + treeShaking`,
323
- then `zlib.gzipSync` — the same options 1.x's own `bundle-size-comparison` used,
324
- so the two columns are comparable. Recorded in
325
- [config/size-budget.json](config/size-budget.json) and re-measured by
326
- `npm run check:size` on every build.
327
-
328
- | Entry | gzip | 1.x, same method |
329
- |---|---:|---:|
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.
341
-
342
- Two lines that are **not** wins:
343
-
344
- - "all 76 plugins at 24,040 B" is larger than the 23,015 B 1.x published for its
345
- `complex` case. The two are not comparable — 1.x's figure was one schema's
346
- plugin set, not its whole catalogue — so it is not counted either way here.
347
- - The 18,992 B for `jsonSchema` measures the plugin **without a bag**, which is
348
- not a configuration you can actually validate with. Supplying a working bag
349
- costs 21,371 B — 12 bytes, 0.06%, **less** than just importing
350
- `jsonSchemaFullFeature`. The tree-shakeable JSON Schema route saves nothing
351
- worth having today; use `jsonSchemaFullFeature` unless you need the chain
352
- method for one field.
353
-
354
- ### Speed
355
-
356
- `npm run bench:record`, recorded verbatim in
357
- [config/perf-baseline.json](config/perf-baseline.json). Machine: AMD Ryzen 7
358
- 5825U, 16 logical cores, Node v23.11.0, Windows. Subject is `src/` transpiled by
359
- ts-node, not the bundle. `abortEarly: true`, input accepted, so no rule is
360
- skipped. Every subject rotates over a pool of at least four distinct values —
361
- one frozen input let V8 delete a subject outright, which is the artefact
362
- described below. Each figure is the median of the fastest half of 9 samples; the
363
- spread quoted alongside is the full range over that figure, and on these ten it
364
- is 2.9–8.6%.
365
-
366
- | Shape | `validate` ops/sec | `parse` ops/sec |
367
- |---|---:|---:|
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 |
373
-
374
- **This rewrite is slower than 1.x on flat and nested shapes.** Measured side by
375
- side, in one process on one machine, 1.x source against this source, sample by
376
- sample interleaved so a drift in the machine hits both halves of every ratio:
377
-
378
- | Shape | 1.x | this | ratio |
379
- |---|---:|---:|---:|
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 |
385
-
386
- 1.x carried a directory of specialised fast paths that this implementation has
387
- no equivalent of. The comparison was checked for the ways it could be wrong: 1.x
388
- demonstrably rejects bad values on all five shapes, so it is not winning by
389
- doing less work, and both halves are asserted to accept the accepted pool and
390
- reject the rejected pool before either is timed.
391
-
392
- Also worth stating plainly: **neither figure 1.x's README published reproduces
393
- 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.
396
-
397
- `build()` costs 14–662 µs depending on shape, against sub-microsecond
398
- `validate()` calls — so one `build()` pays for itself after 35–100 `validate()`
399
- calls on four of the five shapes, and after 2 calls on the 50-element array
400
- (where `validate()` itself costs ~33 µs).
401
-
402
- CI does not gate on any absolute number. It gates on the ratio between Luq and a
403
- hand-written validator measured in the same process, so the runner's speed
404
- cancels out. Fifteen pairings are gated: five shapes × `validate` on accepted
405
- input, `parse` on accepted input, and `validate` on rejected input — the
406
- rejected path is a different program under `abortEarly` (early exit, issue
407
- construction, path strings) and was previously not measured at all.
408
-
409
- The gate's resolution is recorded rather than assumed. Slowing every shape's
410
- validator by a fixed factor and re-running (`gateSensitivity` in
411
- config/perf-baseline.json, one run per level): **+35% is caught** on 14 of the
412
- 15 pairings, +25% on 3, and **+15% is missed** on all 15. So the gate sees
413
- roughly a third-slower regression and does not see a sixth-slower one.
414
-
415
- The reference implementations are held to two conditions of their own, both
416
- asserted before any timing. `bench/assert-reference-agreement.ts` requires the
417
- hand-written reference and Luq to agree on every value in both pools, which is
418
- what stops the denominator drifting into a cheaper check than the one Luq
419
- performs — the email, UUID and date-time references were rewritten to the
420
- plugins' own semantics after this was added, and the rejected pool carries one
421
- value per known difference so reverting any of them fails the assertion.
422
- `bench/measure-reference-work.ts` requires each reference to be slower than 0.95
423
- of an empty loop over the same pool, which is how the deleted-subject artefact
424
- is caught: when V8 removes the work the ratio sits at 1.00 or above, and the ten
425
- figures recorded here span 0.01–0.86.
426
-
427
- ### CSP-safe
428
-
429
- No `eval`, no `new Function`. Checked mechanically over all 762 emitted `.js`
430
- 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.
434
-
435
- ### Package
436
-
437
- 84 keys in `exports`, every one resolving to files that exist: 7 fixed keys
438
- (`.`, `./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/`,
443
- 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
445
- `bundler` resolution, and an unpublished subpath is proven to fail.
446
-
447
- 1.x's `createPluginRegistry` / `useField` / `createFieldRule` are published at
448
- `@maroonedog/luq/field-rule`; `useField` is a free function now. See
449
- [docs/migration/breaking-changes.md](docs/migration/breaking-changes.md#15-createpluginregistry-usefield-and-createfieldrule-moved-to-maroonedogluqfield-rule).
98
+ Zero runtime dependencies. TypeScript 5.0 or later.
450
99
 
451
100
  ## Documentation
452
101
 
453
- - **[Getting started](docs/guide/getting-started.md)** — the builder, what
454
- `build()` returns, reading a result, `parse` vs `validate`, options, defaults
455
- - **[Field paths](docs/guide/field-paths.md)** — what a path may be, and what
456
- changed from 1.x
457
- - **[Presence and conditionals](docs/guide/presence-and-conditionals.md)**
458
- `required` / `optional` / `nullable` / `requiredIf` and the order rules run in
459
- - **[JSON Schema](docs/guide/json-schema.md)** the two front doors, and the
460
- keywords that are not supported
461
- - **[Writing a plugin](docs/guide/writing-a-plugin.md)** markers, `out`,
462
- message factories, and the isolation rule
463
- - **[Plugin reference](docs/guide/plugin-reference.md)** generated table of
464
- every subpath, method and slot
465
- - **[Breaking changes from 1.x](docs/migration/breaking-changes.md)** every
466
- incompatibility with the fix beside it
467
- - **[Draft-07 conformance](docs/json-schema-conformance.md)** — the 100% and
468
- what closed each of the ten causes that used to fail
102
+ ### **[luq.dev](https://luq.dev)**
103
+
104
+ | | |
105
+ |---|---|
106
+ | [Getting started](https://luq.dev/docs/getting-started) | the builder, defaults, `normalize`, reading a result |
107
+ | [Core concepts](https://luq.dev/docs/core-concepts) | field paths, slots, presence, transforms |
108
+ | [Plugins](https://luq.dev/plugins) | every subpath, symbol, chain method and slot |
109
+ | [JSON Schema](https://luq.dev/json-schema) | reading a document in, writing one back out, measured Draft-07 conformance |
110
+ | [Standard Schema](https://luq.dev/standard-schema) | tRPC, TanStack Form, Hono, react-hook-form — and what does not cross that boundary |
111
+ | [Benchmarks](https://luq.dev/benchmarks) | bundle size and throughput, with the method |
112
+ | [Luq or zod?](https://luq.dev/luq-or-zod) | when schema-first is the better answer |
113
+
114
+ The same documentation ships in `docs/` inside this repository, so the copy at
115
+ any tag describes that release.
116
+
117
+ ## Status, and how this gets changed
118
+
119
+ The 2.x API is stable and gated, but the production track record is still short.
120
+ Breaking changes happen in a major and nowhere else, an API being removed is
121
+ deprecated one major ahead, and each major ships with the codemod needed to
122
+ cross it.
123
+
124
+ - **[CONTRIBUTING.md](CONTRIBUTING.md)** — `npm run verify` is the whole
125
+ contract; the gates and what each one refuses
126
+ - **[SECURITY.md](SECURITY.md)** — reporting, zero runtime dependencies, the
127
+ prototype-pollution and SSRF positions, and what is *not* protected against
128
+ - **[docs/RELEASING.md](docs/RELEASING.md)** — the release steps, the versioning
129
+ policy, what each CI workflow watches, and what is still decided by hand
469
130
 
470
131
  ## About the "universal platform" goal
471
132
 
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.
133
+ 1.x described a `.luq` DSL that would generate validators for other languages,
134
+ against dated milestones. Those dates have passed and none of it shipped, so the
135
+ plan has been withdrawn rather than moved: no part of it is in this package, and
136
+ this release makes no claim about when any of it will exist. What is in the box
137
+ is the TypeScript validation library described above.
476
138
 
477
139
  ## License
478
140
 
@@ -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
  }
@@ -1,4 +1,10 @@
1
1
  import type { ValidationPlan } from "../compile/validation-plan.types";
2
2
  import type { GlobalConfig } from "../types/global-config";
3
3
  import type { FieldEntry } from "./field-entry.types";
4
- export declare function compileDeclarations(entries: readonly FieldEntry[], configOverride: GlobalConfig | undefined): ValidationPlan;
4
+ import type { FieldDeclaredCalls } from "./field-declared-calls.types";
5
+ /** What build() makes in one pass: the plan to run, and what was declared. */
6
+ export interface CompiledDeclarations {
7
+ readonly plan: ValidationPlan;
8
+ readonly declaredCalls: readonly FieldDeclaredCalls[];
9
+ }
10
+ export declare function compileDeclarations(entries: readonly FieldEntry[], configOverride: GlobalConfig | undefined): CompiledDeclarations;
@@ -26,15 +26,25 @@ const global_config_store_1 = require("./global-config-store");
26
26
  function compileDeclarations(entries, configOverride) {
27
27
  const config = (0, global_config_1.resolveGlobalConfig)(configOverride, (0, global_config_store_1.getGlobalConfig)());
28
28
  const childKeysOf = (0, declared_child_keys_1.indexDeclaredChildKeys)(entries.map((entry) => entry.path));
29
- const declarations = entries.map((entry) => ({
30
- path: entry.path,
31
- rules: entry.collectRules({
29
+ const declarations = [];
30
+ const declaredCalls = [];
31
+ for (const entry of entries) {
32
+ const outcome = entry.collectRules({
32
33
  fieldPath: entry.path,
33
34
  declaredSiblingKeys: childKeysOf(entry.path),
34
35
  config,
35
- }),
36
- defaultOf: entry.defaultOf ?? undefined,
37
- applyDefaultToNull: entry.applyDefaultToNull,
38
- }));
39
- return (0, compile_schema_1.compileSchema)(declarations, (0, run_branch_1.createBranchExecutor)());
36
+ });
37
+ declarations.push({
38
+ path: entry.path,
39
+ rules: outcome.rules,
40
+ defaultOf: entry.defaultOf ?? undefined,
41
+ applyDefaultToNull: entry.applyDefaultToNull,
42
+ normalize: entry.normalize ?? undefined,
43
+ });
44
+ declaredCalls.push({ path: entry.path, calls: outcome.calls });
45
+ }
46
+ return {
47
+ plan: (0, compile_schema_1.compileSchema)(declarations, (0, run_branch_1.createBranchExecutor)()),
48
+ declaredCalls,
49
+ };
40
50
  }
@@ -23,15 +23,25 @@ import { getGlobalConfig } from "./global-config-store.mjs";
23
23
  export function compileDeclarations(entries, configOverride) {
24
24
  const config = resolveGlobalConfig(configOverride, getGlobalConfig());
25
25
  const childKeysOf = indexDeclaredChildKeys(entries.map((entry) => entry.path));
26
- const declarations = entries.map((entry) => ({
27
- path: entry.path,
28
- rules: entry.collectRules({
26
+ const declarations = [];
27
+ const declaredCalls = [];
28
+ for (const entry of entries) {
29
+ const outcome = entry.collectRules({
29
30
  fieldPath: entry.path,
30
31
  declaredSiblingKeys: childKeysOf(entry.path),
31
32
  config,
32
- }),
33
- defaultOf: entry.defaultOf ?? undefined,
34
- applyDefaultToNull: entry.applyDefaultToNull,
35
- }));
36
- return compileSchema(declarations, createBranchExecutor());
33
+ });
34
+ declarations.push({
35
+ path: entry.path,
36
+ rules: outcome.rules,
37
+ defaultOf: entry.defaultOf ?? undefined,
38
+ applyDefaultToNull: entry.applyDefaultToNull,
39
+ normalize: entry.normalize ?? undefined,
40
+ });
41
+ declaredCalls.push({ path: entry.path, calls: outcome.calls });
42
+ }
43
+ return {
44
+ plan: compileSchema(declarations, createBranchExecutor()),
45
+ declaredCalls,
46
+ };
37
47
  }