@nxgt/graphql-validation 0.6.0 → 0.8.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.
@@ -1,24 +1,6 @@
1
1
  import { type ValidationRule } from 'graphql';
2
- /** Options of {@link maxListCost}. */
3
- export interface MaxListCostOptions {
4
- /** The most an operation may cost (at least 0). */
5
- budget: number;
6
- /** The size of a list field whose size cannot be read (an integer of at least 0). */
7
- defaultFirst: number;
8
- /**
9
- * The argument names that size a list. Each one given, or defaulted in the
10
- * SDL, says a size (an Int, a canonical-integer string or an integral Float)
11
- * and the largest wins. Default `['first', 'last', 'limit']`.
12
- */
13
- sizeArguments?: readonly string[];
14
- /**
15
- * Do not cost `__schema` and `__type` (default `false`). Only where
16
- * introspection is disabled or the client is trusted, e.g. GraphiQL in
17
- * development: the standard introspection query is costly (on the order of
18
- * 200,000 on a one-field schema).
19
- */
20
- ignoreIntrospection?: boolean;
21
- }
2
+ import { type MaxListCostOptions } from './max-list-cost-options.js';
3
+ export type { MaxListCostOptions };
22
4
  /**
23
5
  * A graphql-js validation rule refusing an operation whose cost is more than
24
6
  * `budget`, to stop a client from exploiting list cycles (`Folder.items`,
@@ -36,15 +18,30 @@ export interface MaxListCostOptions {
36
18
  * `first`, `last`, `limit`) say. A literal Int, a String holding a canonical
37
19
  * integer (`"1000"`) or an integral Float (`1e3`) of at least 0 says that
38
20
  * number. Anything else (a negative literal, a variable, since validation has
39
- * no variable values) says the argument's SDL default when it has a usable one,
40
- * `defaultFirst` otherwise; an argument not given says its SDL default, or
41
- * nothing. When nothing is said the size is `defaultFirst`. On an interface the
42
- * largest default over its possible types is used. Hence no subtree ever costs
43
- * less than nothing: a negative `first` on one field cannot cancel the cost of
44
- * its sibling. A field whose type (once unwrapped from non-null) is a list is
45
- * one list, nested `[[X]]` included.
21
+ * no variable values) says the largest of the argument's SDL default when it
22
+ * has a usable one (`defaultFirst` otherwise), its declared bound
23
+ * (`@constraint(max)` or `exclusiveMax`) and, when some definition declares no
24
+ * bound, `variableSize`; an argument not given says its SDL default, or
25
+ * nothing. When nothing is said the size is `defaultFirst`. On an interface
26
+ * the largest default and bound over its possible types is used. Hence no
27
+ * subtree ever costs less than nothing: a negative `first` on one field cannot
28
+ * cancel the cost of its sibling. A field whose type (once unwrapped from
29
+ * non-null) is a list is one list, nested `[[X]]` included.
46
30
  *
47
- * Named fragments are followed, each costed once. Inline fragments and
31
+ * A Relay connection puts the size on a field returning an object, with the
32
+ * list below it (`items(first: 100) { edges { node { id } } }`). A field that
33
+ * is not a list and states a size (by the same reading) hands it to the lists
34
+ * selected directly on its return type, through inline fragments and fragment
35
+ * spreads on that same selection set, and only to those declaring none of the
36
+ * size arguments (`edges`, `nodes`): such a list takes it instead of
37
+ * `defaultFirst`. A list declaring a size argument (`recent(first: Int)`)
38
+ * reads its own, never the pending size. Crossing any other field (an object
39
+ * field, `pageInfo`, a wrapper) ends it. A connection stating nothing hands
40
+ * down nothing: its lists are sized `defaultFirst`.
41
+ *
42
+ * Named fragments are followed, each costed once per pending size (past eight
43
+ * distinct sizes under one fragment a size is rounded up to a power of two, so
44
+ * the work stays linear in the document and a cost only over-counts). Inline fragments and
48
45
  * fragments on an abstract type are summed branch by branch, so the cost of a
49
46
  * query on an interface or a union is overestimated, never underestimated.
50
47
  *
@@ -1 +1 @@
1
- {"version":3,"file":"max-list-cost.d.ts","sourceRoot":"","sources":["../../src/limits/max-list-cost.ts"],"names":[],"mappings":"AAAA,OAAO,EAcN,KAAK,cAAc,EACnB,MAAM,SAAS,CAAC;AASjB,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IAClC,mDAAmD;IACnD,MAAM,EAAE,MAAM,CAAC;IACf,qFAAqF;IACrF,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAuGD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,cAAc,CA+CvE"}
1
+ {"version":3,"file":"max-list-cost.d.ts","sourceRoot":"","sources":["../../src/limits/max-list-cost.ts"],"names":[],"mappings":"AAAA,OAAO,EAUN,KAAK,cAAc,EACnB,MAAM,SAAS,CAAC;AAajB,OAAO,EAEN,KAAK,kBAAkB,EACvB,MAAM,yBAAyB,CAAC;AAGjC,YAAY,EAAE,kBAAkB,EAAE,CAAC;AAoFnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,cAAc,CAmDvE"}
@@ -0,0 +1,13 @@
1
+ import type { ArgumentNode, GraphQLField, GraphQLNamedType } from 'graphql';
2
+ import { type SizeContext } from './list-size.js';
3
+ /**
4
+ * The size of the list `definition` on `parent`. `pending` is the size of the
5
+ * field whose return type this list is selected on (a connection's `first`):
6
+ * a list that declares none of the size arguments takes it, since the size
7
+ * cannot be stated on the list itself. A list that declares one reads its own
8
+ * arguments, else `defaultFirst`, and never the pending size. On an abstract
9
+ * parent some possible types may declare one and others not: the largest of
10
+ * both readings, since the query cannot say which comes back.
11
+ */
12
+ export declare function listSizeUnder(cost: SizeContext, parent: GraphQLNamedType | undefined, definition: GraphQLField<unknown, unknown>, args: readonly ArgumentNode[] | undefined, pending: number | undefined): number;
13
+ //# sourceMappingURL=pending-size.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-size.d.ts","sourceRoot":"","sources":["../../src/limits/pending-size.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAC5E,OAAO,EAAe,KAAK,WAAW,EAAc,MAAM,aAAa,CAAC;AAExE;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAC5B,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,gBAAgB,GAAG,SAAS,EACpC,UAAU,EAAE,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,EAC1C,IAAI,EAAE,SAAS,YAAY,EAAE,GAAG,SAAS,EACzC,OAAO,EAAE,MAAM,GAAG,SAAS,GACzB,MAAM,CASR"}
@@ -0,0 +1,22 @@
1
+ import type { GraphQLArgument, ValueNode } from 'graphql';
2
+ /**
3
+ * The one reading of a size, wherever it comes from (a literal, a default): a
4
+ * non-negative integral number or bigint, or a canonical-integer string,
5
+ * clamped to the ceiling. Anything else says nothing.
6
+ */
7
+ export declare function sizeOf(value: unknown): number | undefined;
8
+ /** The size a literal gives: an Int, a canonical-integer String or an integral Float (`1e3`). */
9
+ export declare function literalSize(value: ValueNode): number | undefined;
10
+ /**
11
+ * An argument's SDL default as a size. graphql 16 holds it as `defaultValue`;
12
+ * graphql 17 as `default`, `{ value }` or `{ literal }`.
13
+ */
14
+ export declare function defaultSizeOf(argument: object | undefined): number | undefined;
15
+ /**
16
+ * The bound of an argument's `@constraint` directive, as a size: what the
17
+ * client may send at most. The largest of `max` and `exclusiveMax` (the value
18
+ * itself, a slight over-count), a fractional one rounded up. Read from the SDL
19
+ * node, so a code-first argument (no `astNode`) declares none.
20
+ */
21
+ export declare function constraintMaxOf(argument: GraphQLArgument | undefined): number | undefined;
22
+ //# sourceMappingURL=size-reading.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"size-reading.d.ts","sourceRoot":"","sources":["../../src/limits/size-reading.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAK1D;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAczD;AAED,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAKhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC5B,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC1B,MAAM,GAAG,SAAS,CAiBpB;AAeD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,QAAQ,EAAE,eAAe,GAAG,SAAS,GACnC,MAAM,GAAG,SAAS,CAepB"}
package/docs/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  | --- | --- |
7
7
  | [Constraints](guide/constraints.md) | you write `@constraint` in your schema: setup with `withValidation`, every argument and its Zod equivalent, the formats and your own, lists and nested inputs |
8
8
  | [Answering invalid input as one error](guide/invalid-input.md) | your API answers invalid input as one 400 error with a list of fields (`invalidInputError`, `fieldIssueOf` for a Zod parse), or a refusal such as a 409 names the fields at fault (`fieldsError`) |
9
- | [Limiting the depth and cost of a query](guide/query-limits.md) | a schema has list cycles and you want to refuse a query nested too deep or too costly (`maxDepth`, `maxListCost`), with graphql-js `validate` or graphql-yoga |
9
+ | [Limiting the depth and cost of a query](guide/query-limits.md) | a schema has list cycles and you want to refuse a query nested too deep, too costly or too wide (`maxDepth`, `maxListCost`, `maxFields`), with graphql-js `validate` or graphql-yoga |
10
10
  | [Errors](guide/errors.md) | you handle the `BAD_USER_INPUT` error in a client, build it yourself with `badUserInput`, or add rules a directive cannot say with `validated` |
11
11
 
12
12
  ## Reference
@@ -1,6 +1,6 @@
1
1
  # Limiting the depth and cost of a query
2
2
 
3
- `maxDepth` and `maxListCost` are graphql-js validation rules. They refuse an
3
+ `maxDepth`, `maxListCost` and `maxFields` are graphql-js validation rules. They refuse an
4
4
  operation before any resolver runs, so a hostile query costs a walk of its
5
5
  document and nothing more. For `@constraint`, see
6
6
  [Constraints](constraints.md).
@@ -26,25 +26,31 @@ query {
26
26
  Four `items` levels of 100 each can ask for 100 million nodes in a query nine
27
27
  levels deep (`folder`, four `items`, three `folder`, `id`). `maxDepth` bounds the nesting; `maxListCost` bounds the work.
28
28
 
29
+ Neither bounds breadth: a query of 2000 aliases of `item(id: "1") { id }` is two
30
+ levels deep and costs 4000, yet selects 4000 fields for the executor to run; so does a
31
+ query of thousands of aliases of `__typename`, which neither rule counts.
32
+ `maxFields` bounds that.
33
+
29
34
  ## Use
30
35
 
31
36
  With graphql-js, add the rules to the ones `validate` already runs:
32
37
 
33
38
  ```ts
34
- import { maxDepth, maxListCost } from '@nxgt/graphql-validation';
39
+ import { maxDepth, maxFields, maxListCost } from '@nxgt/graphql-validation';
35
40
  import { parse, specifiedRules, validate } from 'graphql';
36
41
 
37
42
  const errors = validate(schema, parse(query), [
38
43
  ...specifiedRules,
39
44
  maxDepth({ depth: 8 }),
40
45
  maxListCost({ budget: 10_000, defaultFirst: 50 }),
46
+ maxFields({ limit: 500 }),
41
47
  ]);
42
48
  ```
43
49
 
44
50
  With graphql-yoga, in a plugin:
45
51
 
46
52
  ```ts
47
- import { maxDepth, maxListCost } from '@nxgt/graphql-validation';
53
+ import { maxDepth, maxFields, maxListCost } from '@nxgt/graphql-validation';
48
54
  import { createYoga } from 'graphql-yoga';
49
55
 
50
56
  createYoga({
@@ -54,6 +60,7 @@ createYoga({
54
60
  onValidate: ({ addValidationRule }) => {
55
61
  addValidationRule(maxDepth({ depth: 8 }));
56
62
  addValidationRule(maxListCost({ budget: 10_000, defaultFirst: 50 }));
63
+ addValidationRule(maxFields({ limit: 500 }));
57
64
  },
58
65
  },
59
66
  ],
@@ -61,7 +68,7 @@ createYoga({
61
68
  ```
62
69
 
63
70
  Each rule reports **one** `GraphQLError` per operation, whose `nodes` is the
64
- operation. An invalid option (`depth: 0`, a fractional `budget`) throws a
71
+ operation. An invalid option (`depth: 0`, a fractional `budget`, `limit: 0`) throws a
65
72
  `RangeError` when the rule is created.
66
73
 
67
74
  ## `maxDepth({ depth, ignoreIntrospection? })`
@@ -95,7 +102,7 @@ The refusal:
95
102
  `depth` is the operation's real depth. An anonymous operation reads
96
103
  `Operation is 9 levels deep, ...`.
97
104
 
98
- ## `maxListCost({ budget, defaultFirst, sizeArguments?, ignoreIntrospection? })`
105
+ ## `maxListCost({ budget, defaultFirst, sizeArguments?, ignoreIntrospection?, variableSize? })`
99
106
 
100
107
  ### How cost is counted
101
108
 
@@ -142,35 +149,113 @@ argument says:
142
149
  | a bigint (an SDL default held by a code-first schema, or after graphql-tools' `mapSchema`) | that number |
143
150
  | an integral Float literal (`first: 1000.0`, `first: 1e3`) | that number; `1.5` and `-1000.0` are unusable |
144
151
  | a negative literal (`first: -1000`), another string | as if unusable, see below |
145
- | a variable (`first: $n`) | as if unusable: validation has no variable values |
152
+ | a variable (`first: $n`) | as if unusable, see below: validation has no variable values |
146
153
  | not given | its SDL default, when it has one; else nothing |
147
154
 
148
155
  An SDL default is read the same way: `limit: String = "1000"` and `first:
149
156
  Float = 1000.0` are sizes of 1000. Every size is held at most
150
157
  `Number.MAX_SAFE_INTEGER`.
151
158
 
152
- An argument given but unusable counts for its SDL default when it has one in
153
- any accepted form (`items(first: Int = 100)`), and `defaultFirst` otherwise. On
154
- an abstract parent that is the largest default, or `defaultFirst` when it is
155
- larger and some possible type has no default. A
156
- field where no size argument says anything is sized `defaultFirst`.
159
+ An argument given but unusable (a negative number, a variable) counts for the
160
+ largest of its SDL default when it has one in any accepted form (`items(first:
161
+ Int = 100)`, else `defaultFirst`), its declared bound (`@constraint(max)` or
162
+ `exclusiveMax`) and, where some definition declares no bound, `variableSize`:
163
+ see [Variables](#variables). A field where no size argument says anything is
164
+ sized `defaultFirst`.
157
165
 
158
166
  On an interface or a union, the default is the **largest** over the interface's
159
167
  field and the field of every possible type (`Node.kids(first: Int = 1)` next to
160
168
  `Folder.kids(first: Int = 100000)` is sized 100,000), since the query cannot
161
- say which object comes back.
169
+ say which object comes back; `defaultFirst` counts too when it is larger and
170
+ some possible type has no default.
162
171
 
163
172
  A negative number is therefore never taken as a size: no subtree costs less than
164
173
  nothing, and `items(first: -1000)` next to an expensive sibling cannot cancel
165
174
  the sibling's cost.
166
175
 
167
- Pick `defaultFirst` knowing a variable counts for it: a client using
168
- `first: $n` is charged the default whatever it sends. If your server caps a size
169
- argument, set `defaultFirst` (or the SDL default) at that cap.
176
+ ### Variables
177
+
178
+ The client can send any value the argument accepts, so a size given as a
179
+ variable (or as an unusable literal) counts as the **largest** of:
180
+
181
+ - its SDL default, else `defaultFirst`: never less than that;
182
+ - the bound of the argument's `@constraint` (the directive `withValidation`
183
+ enforces), read from the schema's SDL: `first: Int = 20 @constraint(max: 100)`
184
+ counts 100 for `first: $n`. `exclusiveMax` is read the same way (the value
185
+ itself, a slight over-count), a fractional bound is rounded up (`max: 100.5`
186
+ counts 101), and the largest of `max` and `exclusiveMax` counts. On an
187
+ interface it is the largest over the interface's field and each possible
188
+ type's field;
189
+ - `variableSize`, where some definition declares no bound:
190
+ `maxListCost({ budget, defaultFirst, variableSize: 100 })` for a server that
191
+ caps every page at 100 in its resolvers. It applies to every size argument
192
+ (`first`, `last`, `limit`, your own `sizeArguments`) and to unusable
193
+ literals. A declared bound wins over it only when every definition (the
194
+ interface's field and each possible type's) declares one; if any lacks one,
195
+ `variableSize` applies too and the largest wins.
196
+
197
+ ```graphql
198
+ query ($n: Int) { wards(first: $n) { edges { node { rooms(first: $n) { id } } } } }
199
+ ```
200
+
201
+ With `first: Int = 20 @constraint(max: 100)` on both fields, that query is
202
+ costed as the same one written with `first: 100`, not with the defaults. The
203
+ operation's own variable default (`query ($n: Int = 5)`) is ignored: the client
204
+ can override it. An argument not given at all keeps its SDL default.
205
+
206
+ ### Relay connections
207
+
208
+ On a Relay connection the size is on a field that returns an **object**, and the
209
+ list is below it:
210
+
211
+ ```graphql
212
+ type Query { items(first: Int): ItemConnection! }
213
+ type ItemConnection { edges: [ItemEdge!]! nodes: [Item!]! pageInfo: PageInfo! }
214
+ ```
215
+
216
+ ```graphql
217
+ { items(first: 100) { edges { node { id } } nodes { id } } }
218
+ ```
219
+
220
+ A field that is not a list and states a size, read as for a list above (a size
221
+ argument given, or declared with a default; on an abstract parent the largest
222
+ over its possible types), hands it down as a **pending size** to the lists
223
+ selected **directly on its return type**:
224
+
225
+ - Only a list that **declares none of the size arguments** takes it, instead of
226
+ `defaultFirst`: `edges` and `nodes` above are both 100. A list that declares
227
+ one (`recent(first: Int)`) reads its own arguments, else `defaultFirst`,
228
+ never the pending size.
229
+ - On an interface, when some possible types declare a size argument on that
230
+ list and others do not, the larger of the pending size and the list's own
231
+ reading is used, since the query cannot say which comes back.
232
+ - It stops at the first field crossed: an object field (`owner`), `pageInfo` or
233
+ a wrapper object passes nothing on, so the lists below them are sized as
234
+ without a connection. In `items(first: 0) { owner { friends { … } } }` the
235
+ `0` does not reach `friends`. A nested field that states a size hands its own
236
+ to the lists directly on it.
237
+ - A connection field that states nothing (no size argument given, none declared
238
+ with a default) hands down nothing: its lists are sized `defaultFirst`, as
239
+ before. A variable, a negative number or an unusable value on it counts as for
240
+ a list, see [Variables](#variables).
241
+ - The rule is structural: any non-list field stating a size sizes the lists
242
+ declaring no size argument directly on its return type, whether or not it is
243
+ a connection. `user(limit: 0) { friends { … } }` sizes `friends` 0. So an
244
+ argument named in `sizeArguments` must size the lists directly under its
245
+ field: when one of yours means something else, leave its name out of
246
+ `sizeArguments` (or rename the argument).
247
+ - Inline fragments, and fragment spreads, on that same selection set pass the
248
+ pending size through. A named fragment is costed once for each distinct
249
+ pending size it is spread under (`...C` under
250
+ `first: 2` and `first: 5` costs differently). Under more than eight distinct
251
+ sizes in one document a fragment's further sizes are rounded up to the next
252
+ power of two, so the work stays linear in the document and the cost only
253
+ over-counts.
170
254
 
171
255
  ### Fragments and abstract types
172
256
 
173
- Named fragments are followed, each costed once (a fragment's type condition fixes
257
+ Named fragments are followed, each costed once per pending size (see above;
258
+ a fragment's type condition fixes
174
259
  its parent type). A fragment spread twice counts twice. Inline fragments and
175
260
  fragments on an interface or a union are **summed branch by branch**: a
176
261
  selection `... on A { ... } ... on B { ... }` costs A's plus B's, although an
@@ -186,7 +271,7 @@ never underestimated.
186
271
  }
187
272
  ```
188
273
 
189
- `budget` and `defaultFirst` must be integers below `Number.MAX_SAFE_INTEGER`
274
+ `budget`, `defaultFirst` and `variableSize` must be integers below `Number.MAX_SAFE_INTEGER`
190
275
  (a cost never exceeds it, so a budget at or over it would refuse nothing); the
191
276
  rule throws a `RangeError` otherwise.
192
277
 
@@ -194,8 +279,61 @@ Costs are plain numbers, computed from memoized fragments (no exponential work
194
279
  on a hostile query) and held at `Number.MAX_SAFE_INTEGER`: `cost` is exact below
195
280
  it and never `Infinity`.
196
281
 
282
+ ## `maxFields({ limit, ignoreIntrospection? })`
283
+
284
+ The size of an operation is the number of field selections in its document,
285
+ fragment spreads expanded. It is not what the executor runs: how often a field
286
+ runs inside a list is `maxListCost`'s business, so `maxFields` alone does not
287
+ bound execution.
288
+
289
+ - Every field node counts 1: aliases count (`a1: id a2: id` is 2), and so does
290
+ `__typename`.
291
+ - A named fragment counts **at each spread**: a fragment of 3 fields spread
292
+ twice adds 6. Each fragment is walked once, so the work stays linear in the
293
+ size of the document even when spreads multiply. A fragment cycle counts 0
294
+ (`NoFragmentCyclesRule` reports it).
295
+ - Inline fragments count their contents. No type condition prunes anything:
296
+ every branch of a union or an interface counts, so the figure can only be
297
+ over the document's field count.
298
+ - `@skip` and `@include` are not evaluated (the rules read the document, not its
299
+ variables): a field they would remove is counted anyway.
300
+ - `__schema` and `__type` and everything under them count like any field.
301
+ The standard introspection query (`getIntrospectionQuery()`) selects **220
302
+ fields** on graphql 16 and 17 (the fragments `FullType`, `InputValue` and
303
+ `TypeRef` are counted at each spread), so a limit under 220 refuses it. Where
304
+ introspection is disabled, or the client is trusted (GraphiQL in development),
305
+ pass `ignoreIntrospection: true`.
306
+
307
+ The refusal:
308
+
309
+ ```json
310
+ {
311
+ "message": "Operation \"Wide\" selects 4000 fields, more than the 500 allowed",
312
+ "extensions": { "code": "QUERY_TOO_WIDE", "fields": 4000, "limit": 500 }
313
+ }
314
+ ```
315
+
316
+ `fields` is the operation's real count, held at `Number.MAX_SAFE_INTEGER`
317
+ whatever a hostile query multiplies. An anonymous operation reads
318
+ `Operation selects 4000 fields, ...`. `limit` must be an integer from 1 to
319
+ `Number.MAX_SAFE_INTEGER - 1`, else a `RangeError`.
320
+
321
+ ### Choosing a limit
322
+
323
+ Count the fields of your largest legitimate query, spreads included (a page
324
+ that spreads a 10-field fragment on 20 list entries reads 200 plus the rest),
325
+ and add headroom: a few hundred covers most screens. Keep the introspection
326
+ query in mind (220) if you leave it counted. `maxFields` complements the other
327
+ two: `maxDepth` bounds nesting, `maxListCost` bounds list multiplication, and
328
+ `maxFields` bounds how many fields one request may name.
329
+
197
330
  ## Not covered
198
331
 
332
+ - A code-first argument has no SDL node, hence no declared bound: only
333
+ `variableSize` applies to a variable on it.
334
+ - A size bounded only by a custom scalar (`first: PageSize`) or by `maxItems`
335
+ or `maxLength` counts as having no bound: declare `@constraint(max)` on the
336
+ argument.
199
337
  - A size carried inside an input object (`items(page: { first: 1000 })`) is not
200
338
  read: the field has no size argument, so it is sized `defaultFirst`. Name the
201
339
  size as an argument, or raise `defaultFirst`.
package/docs/roadmap.md CHANGED
@@ -18,10 +18,21 @@ Nothing planned yet.
18
18
 
19
19
  ## Shipped
20
20
 
21
- - **Query depth and cost limits** — `maxDepth` and `maxListCost` are graphql-js
22
- validation rules (also for graphql-yoga's `addValidationRule`) that refuse a
23
- query nested too deep or too costly for a schema with list cycles, with the
24
- stable codes `QUERY_TOO_DEEP` and `QUERY_TOO_COSTLY`.
21
+ - **Relay connection sizing** — `maxListCost` sizes the `edges` and `nodes` of
22
+ a connection from the connection field's size argument (`items(first: 100)
23
+ { edges { … } }`), not `defaultFirst`.
24
+ - **Variable sizes counted at their bound** — `maxListCost` counts a size sent
25
+ as a variable (or an unusable literal) at the argument's declared bound
26
+ (`@constraint(max)` or `exclusiveMax`) and the new `variableSize` option. The
27
+ bound wins only when every definition declares one, and never counts below the
28
+ SDL default or `defaultFirst`, so a query cannot hide a large page behind a
29
+ variable.
30
+ - **Query depth, cost and breadth limits** — `maxDepth`, `maxListCost` and
31
+ `maxFields` are graphql-js validation rules (also for graphql-yoga's
32
+ `addValidationRule`) that refuse a query nested too deep, too costly for a
33
+ schema with list cycles, or selecting too many fields (aliases of a cheap
34
+ field), with the stable codes `QUERY_TOO_DEEP`, `QUERY_TOO_COSTLY` and
35
+ `QUERY_TOO_WIDE`.
25
36
  - **Schemas that read the context** — 0.5.0. `validated((context, info) =>
26
37
  schema, resolver)` builds the schema on each call from the request's context
27
38
  and the field's `info`, for checks that need the request (a uniqueness query
@@ -425,11 +425,35 @@ reads `Operation is 9 levels deep, ...`.
425
425
  **When:** an operation is validated with `maxListCost({ budget: 1000, ... })`.
426
426
  **Why:** `extensions` is `{ code: 'QUERY_TOO_COSTLY', cost: 5002, limit: 1000 }`.
427
427
  Each field costs 1, times the sizes of the lists enclosing it. A list without a
428
- usable `first` (a variable, a negative number, nothing) counts for its SDL
429
- default, else `defaultFirst`, so a query that is cheap with the variable's real
430
- value can still be refused.
431
- **Fix:** ask for fewer items with a literal `first`, or raise `budget`. A query
432
- on an interface or a union is overestimated (every branch is summed).
428
+ usable `first` counts for its SDL default, else `defaultFirst`; a variable or
429
+ a negative number counts for the largest of that, the declared bound and
430
+ `variableSize`, so a query that is cheap with the variable's real value can
431
+ still be refused, see [Variables](guide/query-limits.md#variables). A size
432
+ argument on a field returning an object (a connection's `first`) sizes its
433
+ `edges` and `nodes`, and any non-list field stating a size sizes the lists
434
+ declaring none directly under it, see
435
+ [Relay connections](guide/query-limits.md#relay-connections).
436
+
437
+ **Fix:** ask for fewer items with a literal `first`, lower the `@constraint(max)`
438
+ of a size argument, or raise `budget`. A query on an interface or a union is
439
+ overestimated (every branch is summed).
440
+
441
+ ### `Operation "Name" selects 4000 fields, more than the 500 allowed`
442
+
443
+ **When:** an operation is validated with `maxFields({ limit: 500 })`.
444
+ **Why:** `extensions` is `{ code: 'QUERY_TOO_WIDE', fields: 4000, limit: 500 }`.
445
+ Every field selection counts 1, aliases and `__typename` included, and a named
446
+ fragment counts at each spread; every branch of an inline fragment counts and
447
+ `@skip`/`@include` are not evaluated, so the figure can be over what runs. An
448
+ anonymous operation reads `Operation selects 4000 fields, ...`.
449
+ **Fix:** ask for fewer fields (a fragment spread many times is many fields), or
450
+ raise `limit`. See [Limiting the depth and cost of a query](guide/query-limits.md).
451
+
452
+ ### `maxFields: limit must be an integer from 1 to 9007199254740990.`
453
+
454
+ **When:** `maxFields` is called with `0`, a negative, a fractional or a too
455
+ large `limit`.
456
+ **Fix:** pass an integer in range.
433
457
 
434
458
  ### `maxDepth: depth must be an integer from 1 to 9007199254740990.`
435
459
 
@@ -448,15 +472,28 @@ budget at or over it would refuse nothing.
448
472
  **When:** `defaultFirst` is negative, fractional or too large.
449
473
  **Fix:** pass the size to assume for a list with no usable size argument.
450
474
 
475
+ ### `maxListCost: variableSize must be an integer from 0 to 9007199254740990.`
476
+
477
+ **When:** `variableSize` is negative, fractional or too large.
478
+ **Why:** it is the size assumed for a variable or unusable size argument, and
479
+ a size is an integer from 0 up to a bound a cost cannot overflow.
480
+ **Fix:** pass an integer in range, such as the page cap of your resolvers:
481
+
482
+ ```ts
483
+ maxListCost({ budget: 10_000, defaultFirst: 50, variableSize: 100 });
484
+ ```
485
+
451
486
  ### A query refused although its `first` is small, or an introspection query refused
452
487
 
453
- **When:** `QUERY_TOO_COSTLY` or `QUERY_TOO_DEEP` for GraphiQL's schema fetch or
488
+ **When:** `QUERY_TOO_COSTLY`, `QUERY_TOO_DEEP` or `QUERY_TOO_WIDE` for GraphiQL's schema fetch or
454
489
  a codegen's introspection query.
455
490
  **Why:** `__schema` is counted: the standard introspection query is 15 levels
456
- deep and costs on the order of 200,000 on a one-field schema.
491
+ deep, selects 220 fields and costs on the order of 200,000 on a one-field schema.
457
492
  **Fix:** `ignoreIntrospection: true` on the rule where introspection is
458
- disabled or the client is trusted. A `first: $n` variable counts for the SDL
459
- default, not for the value sent.
493
+ disabled or the client is trusted. A `first: $n` variable counts for its
494
+ declared bound (`@constraint(max)` or `exclusiveMax`, when every definition
495
+ declares one) and `variableSize` otherwise, never below the SDL default or
496
+ `defaultFirst`, not for the value sent.
460
497
 
461
498
  ## The bin
462
499
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/graphql-validation",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Validate GraphQL arguments and input fields with @constraint directives, checked by Zod schemas, for graphql 16 and 17",
5
5
  "license": "MIT",
6
6
  "type": "module",