@osqd/jql 0.1.1

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 (115) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/LICENSE +102 -0
  3. package/README.md +84 -0
  4. package/bin/jql.mjs +5 -0
  5. package/conformance/cases.json +290 -0
  6. package/dist/array.d.ts +11 -0
  7. package/dist/async.d.ts +44 -0
  8. package/dist/canonical.d.ts +18 -0
  9. package/dist/cjs/array.d.ts +11 -0
  10. package/dist/cjs/async.d.ts +44 -0
  11. package/dist/cjs/canonical.d.ts +18 -0
  12. package/dist/cjs/cli.d.ts +15 -0
  13. package/dist/cjs/collections.d.ts +34 -0
  14. package/dist/cjs/core.d.ts +117 -0
  15. package/dist/cjs/errors.d.ts +17 -0
  16. package/dist/cjs/explain.d.ts +30 -0
  17. package/dist/cjs/global.d.ts +90 -0
  18. package/dist/cjs/group.d.ts +47 -0
  19. package/dist/cjs/index.d.ts +25 -0
  20. package/dist/cjs/internal/closest.d.ts +9 -0
  21. package/dist/cjs/internal/duration.d.ts +15 -0
  22. package/dist/cjs/internal/equal.d.ts +34 -0
  23. package/dist/cjs/internal/glob.d.ts +30 -0
  24. package/dist/cjs/internal/order.d.ts +24 -0
  25. package/dist/cjs/internal/path.d.ts +109 -0
  26. package/dist/cjs/internal/record.d.ts +18 -0
  27. package/dist/cjs/internal/values.d.ts +41 -0
  28. package/dist/cjs/limits.d.ts +64 -0
  29. package/dist/cjs/operators.d.ts +81 -0
  30. package/dist/cjs/package.json +3 -0
  31. package/dist/cjs/plan.d.ts +79 -0
  32. package/dist/cjs/search.d.ts +42 -0
  33. package/dist/cjs/targets/mongo.d.ts +55 -0
  34. package/dist/cjs/text/index.d.ts +12 -0
  35. package/dist/cjs/text/parse.d.ts +91 -0
  36. package/dist/cjs/text/suggest.d.ts +16 -0
  37. package/dist/cjs/text/write.d.ts +34 -0
  38. package/dist/cjs/types.d.ts +236 -0
  39. package/dist/cjs/vocabulary.d.ts +106 -0
  40. package/dist/cli.d.ts +15 -0
  41. package/dist/cli.js +2729 -0
  42. package/dist/cli.js.map +1 -0
  43. package/dist/collections.d.ts +34 -0
  44. package/dist/core.d.ts +117 -0
  45. package/dist/errors.d.ts +17 -0
  46. package/dist/explain.d.ts +30 -0
  47. package/dist/global.cjs +1953 -0
  48. package/dist/global.cjs.map +1 -0
  49. package/dist/global.d.ts +90 -0
  50. package/dist/global.js +1950 -0
  51. package/dist/global.js.map +1 -0
  52. package/dist/group.d.ts +47 -0
  53. package/dist/index.cjs +2529 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.ts +25 -0
  56. package/dist/index.js +2495 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/internal/closest.d.ts +9 -0
  59. package/dist/internal/duration.d.ts +15 -0
  60. package/dist/internal/equal.d.ts +34 -0
  61. package/dist/internal/glob.d.ts +30 -0
  62. package/dist/internal/order.d.ts +24 -0
  63. package/dist/internal/path.d.ts +109 -0
  64. package/dist/internal/record.d.ts +18 -0
  65. package/dist/internal/values.d.ts +41 -0
  66. package/dist/limits.d.ts +64 -0
  67. package/dist/mongo.cjs +357 -0
  68. package/dist/mongo.cjs.map +1 -0
  69. package/dist/mongo.js +354 -0
  70. package/dist/mongo.js.map +1 -0
  71. package/dist/operators.d.ts +81 -0
  72. package/dist/plan.d.ts +79 -0
  73. package/dist/search.d.ts +42 -0
  74. package/dist/targets/mongo.d.ts +55 -0
  75. package/dist/text/index.d.ts +12 -0
  76. package/dist/text/parse.d.ts +91 -0
  77. package/dist/text/suggest.d.ts +16 -0
  78. package/dist/text/write.d.ts +34 -0
  79. package/dist/text.cjs +674 -0
  80. package/dist/text.cjs.map +1 -0
  81. package/dist/text.js +667 -0
  82. package/dist/text.js.map +1 -0
  83. package/dist/types.d.ts +236 -0
  84. package/dist/vocabulary.d.ts +106 -0
  85. package/docs/course/01-first-query.md +217 -0
  86. package/docs/course/02-operators.md +285 -0
  87. package/docs/course/03-arrays-and-paths.md +239 -0
  88. package/docs/course/04-combining.md +221 -0
  89. package/docs/course/05-dates.md +214 -0
  90. package/docs/course/06-typed-queries.md +240 -0
  91. package/docs/course/07-requests.md +261 -0
  92. package/docs/course/08-grouping.md +210 -0
  93. package/docs/course/09-explaining.md +171 -0
  94. package/docs/course/10-vocabulary.md +276 -0
  95. package/docs/course/11-the-search-box.md +349 -0
  96. package/docs/course/12-untrusted.md +257 -0
  97. package/docs/course/13-saved-filters.md +199 -0
  98. package/docs/course/14-streams-and-cli.md +276 -0
  99. package/docs/course/15-pushdown.md +240 -0
  100. package/docs/course/16-extending.md +199 -0
  101. package/docs/course/index.md +185 -0
  102. package/docs/design/decisions.md +198 -0
  103. package/docs/design/performance.md +102 -0
  104. package/docs/guides/adopting.md +81 -0
  105. package/docs/guides/pushdown.md +147 -0
  106. package/docs/guides/typescript.md +115 -0
  107. package/docs/guides/untrusted-input.md +86 -0
  108. package/docs/index.md +102 -0
  109. package/docs/reference/api.md +266 -0
  110. package/docs/reference/cli.md +103 -0
  111. package/docs/reference/index.md +12 -0
  112. package/docs/reference/specification.md +549 -0
  113. package/docs/reference/text-syntax.md +152 -0
  114. package/docs/start/quick-start.md +84 -0
  115. package/package.json +136 -0
@@ -0,0 +1,349 @@
1
+ # Lesson 11 — The search box
2
+
3
+ **Goal:** let people type, and get back the same JQL you would have written yourself.
4
+
5
+ ← [Course](index.md) · Previous: [A vocabulary](10-vocabulary.md) · Next: [Queries from outside](12-untrusted.md)
6
+
7
+ ---
8
+
9
+ ## Nobody types JSON into a search box
10
+
11
+ `@osqd/jql/text` is the syntax people *do* type:
12
+
13
+ ```
14
+ status:open total:>100 -has:note
15
+ ```
16
+
17
+ It has no matcher of its own. It **compiles into a JQL document**, and the engine you have
18
+ been using for ten lessons runs that. There is one set of semantics, and the text is a second
19
+ way of writing it.
20
+
21
+ That matters more than it sounds. A search box with its own matcher drifts from the JSON one
22
+ within a week, and then a saved filter means one thing on the screen and another in the
23
+ export.
24
+
25
+ ## Typing into it
26
+
27
+ ```js
28
+ import { filter } from "@osqd/jql";
29
+ import { parseText } from "@osqd/jql/text";
30
+ import { orders } from "./orders.mjs";
31
+ import { ORDERS } from "./vocabulary.mjs";
32
+
33
+ // A fixed clock, so "the last week" means the same thing every time this runs.
34
+ const options = { vocabulary: ORDERS, now: () => Date.parse("2026-09-25T12:00:00Z") };
35
+
36
+ const box = (typed) => {
37
+ const query = parseText(typed, { vocabulary: ORDERS });
38
+ const found = filter(orders, query, options).map((o) => o.id).join(" ") || "(none)";
39
+ console.log(typed.padEnd(28), found);
40
+ };
41
+
42
+ box("status:open");
43
+ box("status:open country:gb");
44
+ box("-status:paid");
45
+ box("status:$in(open, cancelled)");
46
+ ```
47
+
48
+ ```
49
+ status:open o-1002 o-1005
50
+ status:open country:gb o-1005
51
+ -status:paid o-1002 o-1004 o-1005 o-1007
52
+ status:$in(open, cancelled) o-1002 o-1005 o-1007
53
+ ```
54
+
55
+ Adjacent terms are **and**, which is what narrowing means to the person typing. `-` is not,
56
+ and `$in(…)` is one of a set.
57
+
58
+ ### Numbers and dates
59
+
60
+ ```js
61
+ import { filter } from "@osqd/jql";
62
+ import { parseText } from "@osqd/jql/text";
63
+ import { orders } from "./orders.mjs";
64
+ import { ORDERS } from "./vocabulary.mjs";
65
+
66
+ const options = { vocabulary: ORDERS, now: () => Date.parse("2026-09-25T12:00:00Z") };
67
+
68
+ const box = (typed) => {
69
+ const found = filter(orders, parseText(typed, { vocabulary: ORDERS }), options);
70
+ console.log(typed.padEnd(28), found.map((o) => o.id).join(" ") || "(none)");
71
+ };
72
+
73
+ box("total:>100");
74
+ box("total:10..40");
75
+ box("at:>2026-09-18");
76
+ box("at:>-7d");
77
+ ```
78
+
79
+ ```
80
+ total:>100 o-1005 o-1007
81
+ total:10..40 o-1001 o-1002 o-1004 o-1006 o-1008
82
+ at:>2026-09-18 o-1006 o-1007 o-1008
83
+ at:>-7d o-1006 o-1007 o-1008
84
+ ```
85
+
86
+ `total` and `placed` were declared `number` and `date` in the vocabulary, which is what lets
87
+ `>100` and `>-7d` be read as comparisons rather than searched for as text. That is the whole
88
+ job of `kind`.
89
+
90
+ ### Presence, alternatives and bare words
91
+
92
+ ```js
93
+ import { filter } from "@osqd/jql";
94
+ import { parseText } from "@osqd/jql/text";
95
+ import { orders } from "./orders.mjs";
96
+ import { ORDERS } from "./vocabulary.mjs";
97
+
98
+ const box = (typed) => {
99
+ const found = filter(orders, parseText(typed, { vocabulary: ORDERS }), { vocabulary: ORDERS });
100
+ console.log(typed.padEnd(28), found.map((o) => o.id).join(" ") || "(none)");
101
+ };
102
+
103
+ box("has:note");
104
+ box("-has:note");
105
+ box("sku:desk-lamp $or tag:gift");
106
+ box("ada");
107
+ box('"gift wrap"');
108
+ ```
109
+
110
+ ```
111
+ has:note o-1001 o-1003 o-1004 o-1006 o-1007
112
+ -has:note o-1002 o-1005 o-1008
113
+ sku:desk-lamp $or tag:gift o-1001 o-1005 o-1007
114
+ ada o-1001 o-1008
115
+ "gift wrap" o-1006
116
+ ```
117
+
118
+ A bare word searches the `text` fields the vocabulary listed — `id`, `who`, `email`, `sku` and
119
+ `note` — so `ada` finds both Adas without anybody naming a field. A quoted phrase is one term.
120
+
121
+ ## The syntax, in one table
122
+
123
+ | Typed | Means |
124
+ | --- | --- |
125
+ | `status:open` | the field, read according to its `kind` |
126
+ | `status:open country:gb` | adjacent terms are **and** |
127
+ | `-status:paid`, `!status:paid` | not |
128
+ | `status:$in(open, cancelled)` | one of a set; `$notin(…)` for none of them |
129
+ | `total:>100`, `total:>=100`, `total:10..20` | comparisons and ranges, on a `number` or `date` field |
130
+ | `at:>2026-09-18`, `at:>-7d` | an instant, or a signed duration from now |
131
+ | `has:note`, `-has:note` | the field is set, or is not |
132
+ | `a $or b`, `$not a`, `(a $or b) c` | precedence: `$not` tightest, then and, then `$or` |
133
+ | `ada`, `"gift wrap"` | a bare word or phrase, in the vocabulary's `text` fields |
134
+
135
+ Operators carry a `$` on purpose. A bare `or` is a word that appears in notes and addresses,
136
+ and a language where an ordinary search word silently becomes an operator is a language that
137
+ lies about what it matched.
138
+
139
+ ## What it compiles to
140
+
141
+ ```js
142
+ import { parseText } from "@osqd/jql/text";
143
+ import { ORDERS } from "./vocabulary.mjs";
144
+
145
+ console.log(JSON.stringify(parseText("status:open total:>100", { vocabulary: ORDERS }), null, 2));
146
+ ```
147
+
148
+ ```
149
+ {
150
+ "$and": [
151
+ {
152
+ "status": {
153
+ "$eq": "open",
154
+ "$options": "i"
155
+ }
156
+ },
157
+ {
158
+ "total": {
159
+ "$gt": 100
160
+ }
161
+ }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ Nothing exotic: it is the query you would have written by hand. `$options: "i"` is there
167
+ because `status` was declared `exact`, which means "equals it, ignoring case".
168
+
169
+ ## The parser never throws
170
+
171
+ It runs on **every keystroke**, so half-typed input is the normal state rather than an error:
172
+
173
+ ```js
174
+ import { count } from "@osqd/jql";
175
+ import { parseText } from "@osqd/jql/text";
176
+ import { orders } from "./orders.mjs";
177
+ import { ORDERS } from "./vocabulary.mjs";
178
+
179
+ const halfTyped = parseText("status:open $and (total:>", { vocabulary: ORDERS });
180
+
181
+ console.log(JSON.stringify(halfTyped));
182
+ console.log("matches", count(orders, halfTyped, { vocabulary: ORDERS }), "of 8");
183
+ ```
184
+
185
+ ```
186
+ {"$and":[{"status":{"$eq":"open","$options":"i"}},{"total":{"$in":[]}}]}
187
+ matches 0 of 8
188
+ ```
189
+
190
+ An unclosed bracket, a dangling `$or`, a `$in(` with nothing after it: each parses to the best
191
+ reading available. Look at what the unfinished half became — `{ "$in": [] }`, a term that
192
+ matches **nothing**.
193
+
194
+ That direction is chosen deliberately. A half-typed filter that quietly *widened* to everything
195
+ would show the operator a screen full of rows that are not the ones they are looking for, and
196
+ nothing on the screen would say the filter was incomplete.
197
+
198
+ ## Putting a saved filter back in the box
199
+
200
+ A dashboard that stores filters as JSON has to show one in the box when somebody opens it to
201
+ edit. That is `toText`:
202
+
203
+ ```js
204
+ import { toText } from "@osqd/jql/text";
205
+ import { ORDERS } from "./vocabulary.mjs";
206
+
207
+ const stored = [
208
+ { status: { $eq: "open", $options: "i" } },
209
+ { $and: [{ country: { $eq: "GB", $options: "i" } }, { total: { $gt: 100 } }] },
210
+ { placed: { $gte: { $date: { $ago: "7d" } } } },
211
+ { $not: { tag: { $eq: "gift", $options: "i" } } },
212
+ { total: { $gte: 10, $lte: 40 } },
213
+ ];
214
+
215
+ for (const query of stored) console.log(JSON.stringify(toText(query, { vocabulary: ORDERS }).text));
216
+ ```
217
+
218
+ ```
219
+ "status:open"
220
+ "country:GB total:>100"
221
+ "placed:>=-7d"
222
+ "-tag:gift"
223
+ "total:10..40"
224
+ ```
225
+
226
+ Parsed back with the same vocabulary, each of those means precisely the clauses it carries —
227
+ a property the test suite holds the writer to on thousands of generated queries.
228
+
229
+ ### When the box cannot say it
230
+
231
+ `toText` is deliberately **not** total. The text syntax is smaller than JQL, and some queries
232
+ have no spelling in it:
233
+
234
+ ```js
235
+ import { toText } from "@osqd/jql/text";
236
+ import { ORDERS } from "./vocabulary.mjs";
237
+
238
+ const hard = { status: { $eq: "open", $options: "i" }, tag: { $size: 2 } };
239
+ const form = toText(hard, { vocabulary: ORDERS });
240
+
241
+ console.log("text :", JSON.stringify(form.text));
242
+ console.log("complete :", form.complete);
243
+ console.log("unexpressed:", JSON.stringify(form.unexpressed, null, 2));
244
+ ```
245
+
246
+ ```
247
+ text : "status:open"
248
+ complete : false
249
+ unexpressed: [
250
+ {
251
+ "at": "tag.$size",
252
+ "clause": {
253
+ "$size": 2
254
+ },
255
+ "why": "\"$size\" has no spelling in the text syntax"
256
+ }
257
+ ]
258
+ ```
259
+
260
+ This is the contract, and it is worth reading twice. The text holds only the part the box
261
+ *could* express — `status:open` — and says nothing at all about the `$size` constraint that is
262
+ still in force. So `complete` is `false`, and `unexpressed` names the clause that got left
263
+ behind and why.
264
+
265
+ A caller that shows `form.text` and ignores `form.complete` has just shown somebody a filter
266
+ **wider than the one that is running**. The signature returns all three so that the check is
267
+ one line:
268
+
269
+ ```js
270
+ import { toText } from "@osqd/jql/text";
271
+ import { ORDERS } from "./vocabulary.mjs";
272
+
273
+ const show = (query) => {
274
+ const form = toText(query, { vocabulary: ORDERS });
275
+ console.log(form.complete ? "shows it all " : "⚠ incomplete ", JSON.stringify(form.text));
276
+ };
277
+
278
+ show({ status: { $eq: "open", $options: "i" } });
279
+ show({ status: { $eq: "open", $options: "i" }, tag: { $size: 2 } });
280
+ ```
281
+
282
+ ```
283
+ shows it all "status:open"
284
+ ⚠ incomplete "status:open"
285
+ ```
286
+
287
+ ## Completions
288
+
289
+ ```js
290
+ import { suggest } from "@osqd/jql/text";
291
+ import { ORDERS } from "./vocabulary.mjs";
292
+
293
+ const caretAt = [["stat", 4], ["status:", 7], ["status:op", 9], ["-co", 3], ["status:open c", 13], ["$", 1]];
294
+
295
+ for (const [typed, caret] of caretAt) {
296
+ const { options, from, to } = suggest(typed, caret, ORDERS);
297
+ console.log(`${JSON.stringify(typed).padEnd(16)} replace [${from},${to}] with: ${options.join(" ") || "(nothing)"}`);
298
+ }
299
+ ```
300
+
301
+ ```
302
+ "stat" replace [0,4] with: status:
303
+ "status:" replace [0,7] with: status:open status:paid status:refunded status:cancelled
304
+ "status:op" replace [0,9] with: status:open
305
+ "-co" replace [0,3] with: -country:
306
+ "status:open c" replace [12,13] with: channel: country:
307
+ "$" replace [0,1] with: $and $or $not $in $notin
308
+ ```
309
+
310
+ `from` and `to` are the span to replace, so inserting a completion does not disturb the rest
311
+ of the input.
312
+
313
+ The values come from the `values` you listed in the vocabulary. A field that can hold anything
314
+ offers none, because guessing there would be inventing options rather than completing them.
315
+
316
+ ## Exercise
317
+
318
+ Harbour's console has a saved filter: `{ status: { $eq: "open", $options: "i" }, outstanding: { $gt: 100 } }`.
319
+ Put it in the box and say what the operator sees.
320
+
321
+ <details>
322
+ <summary>Answer</summary>
323
+
324
+ ```js
325
+ import { toText } from "@osqd/jql/text";
326
+ import { ORDERS } from "./vocabulary.mjs";
327
+
328
+ const form = toText({ status: { $eq: "open", $options: "i" }, outstanding: { $gt: 100 } }, { vocabulary: ORDERS });
329
+
330
+ console.log(form);
331
+ ```
332
+
333
+ ```
334
+ {
335
+ text: 'status:open outstanding:>100',
336
+ complete: true,
337
+ unexpressed: []
338
+ }
339
+ ```
340
+
341
+ A computed field is a name like any other here — the box neither knows nor cares that
342
+ `outstanding` is worked out rather than stored. Type that text back in and you get the same
343
+ query.
344
+ </details>
345
+
346
+ ## Related
347
+
348
+ - [Text syntax](../reference/text-syntax.md) — every term, and what each `kind` does with it
349
+ - [Library API](../reference/api.md) — `parseText`, `toText`, `suggest`
@@ -0,0 +1,257 @@
1
+ # Lesson 12 — Queries from outside
2
+
3
+ **Goal:** accept a query from a URL or a request body without accepting *every* query.
4
+
5
+ ← [Course](index.md) · Previous: [The search box](11-the-search-box.md) · Next: [Saved filters](13-saved-filters.md)
6
+
7
+ ---
8
+
9
+ ## A filter in a URL is a lovely API
10
+
11
+ …right up until it is a way to read every field of every row, and spend a minute of CPU doing
12
+ it. A query is data, which is what lets it travel — and what lets one arrive from somebody you
13
+ have never met.
14
+
15
+ Three independent things make that safe, and a fourth ties them together:
16
+
17
+ | | |
18
+ | --- | --- |
19
+ | **the untrusted limits** | bound **cost** — size, depth, patterns |
20
+ | **an allowlist** | bounds **capability** — the operators this endpoint answers |
21
+ | **a strict vocabulary** | bounds **surface** — the fields it will talk about |
22
+ | **`validate`** | one call that answers 400 with a sentence instead of 500 with a stack |
23
+
24
+ ## The endpoint
25
+
26
+ Save this as `harbour/public-api.mjs`; the examples below use it.
27
+
28
+ ```js
29
+ import { UNTRUSTED_LIMITS, defineVocabulary, validate } from "@osqd/jql";
30
+ import { orders } from "./orders.mjs";
31
+
32
+ /** The fields a stranger may ask about — and, because of `strict`, no others. */
33
+ const PUBLIC = defineVocabulary()({
34
+ fields: {
35
+ status: { kind: "exact", values: ["open", "paid", "refunded", "cancelled"] },
36
+ country: { path: "customer.country", kind: "exact" },
37
+ total: { kind: "number" },
38
+ placed: { kind: "date" },
39
+ },
40
+ strict: true,
41
+ });
42
+
43
+ /** The untrusted caps, plus the short menu of operators this endpoint answers. */
44
+ const limits = {
45
+ ...UNTRUSTED_LIMITS,
46
+ allowOperators: ["$eq", "$ne", "$in", "$gt", "$gte", "$lt", "$lte", "$and", "$or"],
47
+ };
48
+
49
+ /** One request: JSON in, a status line out. */
50
+ export function endpoint(body) {
51
+ const checked = validate(JSON.parse(body), { vocabulary: PUBLIC, limits });
52
+ if (!checked.valid) return `400 ${checked.error.message}`;
53
+ return `200 ${orders.filter(checked.test).map((o) => o.id).join(" ") || "(no matches)"}`;
54
+ }
55
+ ```
56
+
57
+ ## What it accepts
58
+
59
+ ```js
60
+ import { endpoint } from "./public-api.mjs";
61
+
62
+ console.log(endpoint('{"status":"open"}'));
63
+ console.log(endpoint('{"country":"GB","total":{"$gte":20}}'));
64
+ console.log(endpoint('{"placed":{"$gte":{"$date":"2026-09-18"}}}'));
65
+ console.log(endpoint('{"status":{"$in":[]}}'));
66
+ ```
67
+
68
+ ```
69
+ 200 o-1002 o-1005
70
+ 200 o-1001 o-1005
71
+ 200 o-1006 o-1007 o-1008
72
+ 200 (no matches)
73
+ ```
74
+
75
+ Ordinary queries, named through the vocabulary. The last one matches nothing and is still a
76
+ perfectly valid request — "no results" is an answer, not an error.
77
+
78
+ ## What it refuses, and what it says
79
+
80
+ ```js
81
+ import { endpoint } from "./public-api.mjs";
82
+
83
+ for (const body of [
84
+ '{"customer.email":"ada@example.com"}',
85
+ '{"note":{"$contains":"gift"}}',
86
+ '{"total":{"$gtt":20}}',
87
+ '{"status":{"$regex":"^o"}}',
88
+ '{"$text":"ada"}',
89
+ ]) {
90
+ console.log(body);
91
+ console.log(" ", endpoint(body));
92
+ }
93
+ ```
94
+
95
+ ```
96
+ {"customer.email":"ada@example.com"}
97
+ 400 at customer.email: "customer.email" is not a field here
98
+ {"note":{"$contains":"gift"}}
99
+ 400 at note: "note" is not a field here
100
+ {"total":{"$gtt":20}}
101
+ 400 at total.$gtt: "$gtt" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
102
+ {"status":{"$regex":"^o"}}
103
+ 400 at status.$regex: "$regex" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
104
+ {"$text":"ada"}
105
+ 400 at $text: "$text" is not an operator this query may use ($eq, $ne, $in, $gt, $gte, $lt, $lte, $and, …)
106
+ ```
107
+
108
+ Every refusal is a sentence you can hand straight back to the caller. It quotes only the query
109
+ they sent, and it names the place in it — so a partner integrating against your API can fix
110
+ their request without opening a support ticket.
111
+
112
+ ## Why `validate` and not `try`/`compile`
113
+
114
+ `validate` **hands back the predicate it compiled**. That is not a convenience; it closes a
115
+ real hole. The obvious alternative has a gap in the middle of it:
116
+
117
+ ```ts
118
+ function handler(body: string) {
119
+ const query = JSON.parse(body);
120
+ if (!isValid(query, { limits: UNTRUSTED_LIMITS })) return respond(400);
121
+ const test = compile(query); // ← the untrusted limits are gone
122
+ return respond(200, orders.filter(test));
123
+ }
124
+ ```
125
+
126
+ ```
127
+ !! THREW
128
+ file:///tmp/claude-1000/-home-micha-Documents-OSQD/b2e0526c-49d7-4fed-9959-41234adfc826/scratchpad/harbour/authoring-3.mjs:1
129
+ if (!isValid(query, { limits: UNTRUSTED_LIMITS })) return respond(400);
130
+ ^^^^^^
131
+
132
+ SyntaxError: Illegal return statement
133
+ at compileSourceTextModule (node:internal/modules/esm/utils:346:16)
134
+ ```
135
+
136
+ Two compiles, two sets of options, and the check no longer describes the thing that runs.
137
+ Nothing in that code looks wrong. With `validate`, the query you checked *is* the query you
138
+ run.
139
+
140
+ ## The limits
141
+
142
+ ```js
143
+ import { DEFAULT_LIMITS, UNTRUSTED_LIMITS } from "@osqd/jql";
144
+
145
+ for (const [name, limits] of [["default", DEFAULT_LIMITS], ["untrusted", UNTRUSTED_LIMITS]]) {
146
+ console.log(name);
147
+ for (const [key, value] of Object.entries(limits)) console.log(" ", key.padEnd(18), JSON.stringify(value));
148
+ }
149
+ ```
150
+
151
+ ```
152
+ default
153
+ maxDepth 32
154
+ maxNodes 10000
155
+ maxPatternLength 1024
156
+ maxGlobLength 1024
157
+ maxTextDepth 16
158
+ allowRegex true
159
+ allowOperators "all"
160
+ untrusted
161
+ maxDepth 16
162
+ maxNodes 512
163
+ maxPatternLength 0
164
+ maxGlobLength 256
165
+ maxTextDepth 8
166
+ allowRegex false
167
+ allowOperators "all"
168
+ ```
169
+
170
+ `UNTRUSTED_LIMITS` halves the depth, caps the query at 512 fields and operators, and **turns
171
+ `$regex` off** — `maxPatternLength: 0`.
172
+
173
+ That last one is the important one. No engine can tell a pattern that backtracks for a minute
174
+ (`(a+)+$` against a long run of `a`s) from one that returns immediately, without running it.
175
+ The string operators from lesson 2 answer almost everything a pattern would, in linear time,
176
+ and they stay available.
177
+
178
+ Every cap **refuses** rather than truncates. A query quietly cut short answers a different
179
+ question, and nobody downstream can tell that it did.
180
+
181
+ ## The allowlist
182
+
183
+ `$regex` being off is a decision this library makes for you. Which of the *rest* your endpoint
184
+ wants is a decision only you can make: `$text` walks a whole document, `$elemMatch` carries a
185
+ query of its own. An endpoint that needs neither says so once, in `allowOperators`, rather
186
+ than finding out later which of them somebody used.
187
+
188
+ ```js
189
+ import { validate } from "@osqd/jql";
190
+
191
+ const typo = validate({ a: 1 }, { limits: { allowOperators: ["$eq", "$gte", "$grater"] } });
192
+
193
+ console.log(typo.valid ? "accepted" : typo.error.message);
194
+ ```
195
+
196
+ ```
197
+ at limits.allowOperators: "$grater" is not an operator, so allowing it allows nothing
198
+ ```
199
+
200
+ A typo in the allowlist is refused too. An allowlist that allows nothing while looking like it
201
+ allows something is precisely the failure this library exists to prevent.
202
+
203
+ ## The strict vocabulary
204
+
205
+ This is the defence people skip and regret. Without it a query can reach **every field of
206
+ every row you run it over** — including the ones you added last week and never meant to
207
+ publish. `customer.email` and `note` were refused above not because they are secret, but
208
+ because they were never promised.
209
+
210
+ ## What this is not
211
+
212
+ **Not an access control layer.** A query runs over the rows you hand it. If a caller must not
213
+ see a row, do not put the row in the collection — filter first, then run their query over what
214
+ is left.
215
+
216
+ **Not a bound on the request.** A `$in` of a million values is a legitimate query, and `$in`
217
+ lists deliberately do not count towards `maxNodes`, because looking one item up in a set costs
218
+ the same however long the list is. Cap the body where you read it.
219
+
220
+ ## Exercise
221
+
222
+ Harbour's public API should let a partner ask about their own orders by status and date, but
223
+ never about money. Write the vocabulary, and prove it by showing what happens to
224
+ `{"total":{"$gte":100}}`.
225
+
226
+ <details>
227
+ <summary>Answer</summary>
228
+
229
+ Leave `total` out of the vocabulary entirely. `strict` does the rest:
230
+
231
+ ```js
232
+ import { UNTRUSTED_LIMITS, defineVocabulary, validate } from "@osqd/jql";
233
+
234
+ const PARTNER = defineVocabulary()({
235
+ fields: { status: { kind: "exact" }, placed: { kind: "date" } },
236
+ strict: true,
237
+ });
238
+
239
+ const asked = validate({ total: { $gte: 100 } }, { vocabulary: PARTNER, limits: UNTRUSTED_LIMITS });
240
+
241
+ console.log(asked.valid ? "accepted" : asked.error.message);
242
+ ```
243
+
244
+ ```
245
+ at total: "total" is not a field here
246
+ ```
247
+
248
+ A field that is not in the vocabulary cannot be filtered on — *and* cannot be sorted by or
249
+ projected with, because `fields`, `omit` and `sort` resolve their names through the same
250
+ vocabulary. One list, one contract.
251
+ </details>
252
+
253
+ ## Related
254
+
255
+ - [Queries from outside](../guides/untrusted-input.md) — the same ground as a reference page
256
+ - [Security](../../SECURITY.md) — what this does and does not promise
257
+ - [Specification §10](../reference/specification.md#10-limits) — what the standard says about caps