@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,171 @@
1
+ # Lesson 9 — Why did this not match?
2
+
3
+ **Goal:** answer "why isn't this row in my list?" in one call, with the values the filter
4
+ actually read.
5
+
6
+ ← [Course](index.md) · Previous: [Counting by a field](08-grouping.md) · Next: [A vocabulary](10-vocabulary.md)
7
+
8
+ ---
9
+
10
+ ## The support question
11
+
12
+ *"Why isn't order 1002 on my screen?"*
13
+
14
+ A filter that returns nothing tells you nothing about which part of it was wrong. Without a
15
+ tool, answering that means commenting clauses out one at a time until the row reappears.
16
+
17
+ `explain(query, item)` takes the query apart and answers for **each piece separately**, with
18
+ the values it read. It is the only function in the library that tells you about a row that did
19
+ *not* match.
20
+
21
+ ## A first explanation
22
+
23
+ ```js
24
+ import { explain } from "@osqd/jql";
25
+ import { orders } from "./orders.mjs";
26
+
27
+ const filterInUse = { status: "paid", "customer.country": "GB", total: { $gte: 20 } };
28
+ const order = orders.find((o) => o.id === "o-1002");
29
+
30
+ const result = explain(filterInUse, order);
31
+
32
+ console.log(result.matched, "—", result.because);
33
+ for (const part of result.parts) console.log(" ", part.matched ? "yes" : "no ", part.at, "—", part.because);
34
+ ```
35
+
36
+ ```
37
+ false — 1 of 3 parts hold
38
+ no status — status is "open"
39
+ no customer.country — customer.country is "US"
40
+ yes total — total is 30
41
+ ```
42
+
43
+ There is the answer, and it is not the clause anybody would have guessed. The order is missing
44
+ because it is **open and American**; whoever asked was almost certainly looking at the total.
45
+
46
+ ## What an explanation holds
47
+
48
+ | | |
49
+ | --- | --- |
50
+ | `matched` | whether this clause holds |
51
+ | `at` | where it is in the query, written as you would reach it in code: `$or[1].tags` |
52
+ | `clause` | the JSON fragment that was evaluated |
53
+ | `because` | one sentence: what was read, or how the parts came out |
54
+ | `parts` | the clauses this one is made of |
55
+
56
+ `parts` nests, so a useful way to look at an explanation is to walk it:
57
+
58
+ ```js
59
+ import { explain } from "@osqd/jql";
60
+ import { orders } from "./orders.mjs";
61
+
62
+ const draw = (node, depth = 0) => {
63
+ const where = node.at === "" ? "the query" : node.at;
64
+ console.log(`${" ".repeat(depth)}${node.matched ? "yes" : "no "} ${where} — ${node.because}`);
65
+ for (const part of node.parts ?? []) draw(part, depth + 2);
66
+ };
67
+
68
+ const order = orders.find((o) => o.id === "o-1002");
69
+
70
+ draw(explain({ $or: [{ "lines.sku": "desk-lamp" }, { tags: "gift" }] }, order));
71
+ ```
72
+
73
+ ```
74
+ no $or — 0 of 2 parts hold, and one is enough
75
+ no $or[0].lines.sku — lines.sku is "pad-a5"
76
+ no $or[1].tags — tags is []
77
+ ```
78
+
79
+ Keep that `draw` function; the rest of the lesson uses it.
80
+
81
+ Notice `$or[0].lines.sku` — the path into the query is exact enough to find the clause in a
82
+ long filter by search, and `because` says how the branch came out as well as what each side
83
+ read.
84
+
85
+ ## The values it read
86
+
87
+ This is where the time is saved:
88
+
89
+ ```js
90
+ import { explain } from "@osqd/jql";
91
+ import { orders } from "./orders.mjs";
92
+
93
+ const draw = (node, depth = 0) => {
94
+ const where = node.at === "" ? "the query" : node.at;
95
+ console.log(`${" ".repeat(depth)}${node.matched ? "yes" : "no "} ${where} — ${node.because}`);
96
+ for (const part of node.parts ?? []) draw(part, depth + 2);
97
+ };
98
+
99
+ draw(explain({ tags: "gift" }, orders.find((o) => o.id === "o-1002")));
100
+ draw(explain({ note: "x" }, orders.find((o) => o.id === "o-1002")));
101
+ draw(explain({ "lines.sku": "desk-lamp" }, orders.find((o) => o.id === "o-1001")));
102
+ ```
103
+
104
+ ```
105
+ no tags — tags is []
106
+ no note — note is missing
107
+ no lines.sku — lines.sku is each of "pen-fine", "ink-blue"
108
+ ```
109
+
110
+ Three different failures, each described differently:
111
+
112
+ - `tags is []` — the array was there and empty, not missing;
113
+ - a missing field says so, rather than being reported as some falsy value;
114
+ - when a path reaches **several** values you get `each of …`, listing them. That last line is
115
+ usually the moment somebody realises their query means what lesson 3 said it meant rather
116
+ than what they read it as.
117
+
118
+ ## It runs the real engine
119
+
120
+ Every clause is compiled on its own and tested, rather than interpreted by a second reading of
121
+ the language. That is worth stating plainly, because the alternative is tempting and wrong: a
122
+ second reading would be a second set of semantics, and the only thing worse than a filter you
123
+ cannot explain is an explanation that disagrees with what the filter did.
124
+
125
+ The cost is that it compiles once per clause, so `explain` is for **one row at a time** —
126
+ `compile` is for the collection. That is a deliberate trade: an explanation exists for a person
127
+ asking about one row.
128
+
129
+ ## Where to put it
130
+
131
+ - **In a support console**, behind a row: "why is this not in the current view?".
132
+ - **In a test**, when a filter breaks.
133
+ `expect(explain(filter, row).parts.map((p) => [p.at, p.matched]))` fails with the name of the
134
+ clause rather than with `expected [] to have length 1`.
135
+
136
+ ## Exercise
137
+
138
+ `o-1007` is not on the "needs chasing" list from lesson 7. Explain it against that request's
139
+ `where`, and say in one sentence what the operator should tell the customer.
140
+
141
+ <details>
142
+ <summary>Answer</summary>
143
+
144
+ ```js
145
+ import { explain } from "@osqd/jql";
146
+ import { orders } from "./orders.mjs";
147
+
148
+ const result = explain(
149
+ { paid: 0, status: { $nin: ["cancelled", "refunded"] } },
150
+ orders.find((o) => o.id === "o-1007"),
151
+ );
152
+
153
+ console.log(result.because);
154
+ for (const part of result.parts) console.log(" ", part.matched ? "yes" : "no ", part.at, "—", part.because);
155
+ ```
156
+
157
+ ```
158
+ 1 of 2 parts hold
159
+ yes paid — paid is 0
160
+ no status — status is "cancelled"
161
+ ```
162
+
163
+ Nobody owes anything: the order was cancelled, so the unpaid balance is not a debt. The
164
+ explanation names the clause that decided it, and that clause is the sentence the operator
165
+ repeats back.
166
+ </details>
167
+
168
+ ## Related
169
+
170
+ - [Library API](../reference/api.md) — `explain` and `Explanation`
171
+ - [The command line](../reference/cli.md) — `jql --explain` prints this tree for the first line of a file
@@ -0,0 +1,276 @@
1
+ # Lesson 10 — A vocabulary
2
+
3
+ **Goal:** give your data its names, once, so that queries stop knowing where things are
4
+ stored.
5
+
6
+ ← [Course](index.md) · Previous: [Why did this not match?](09-explaining.md) · Next: [The search box](11-the-search-box.md)
7
+
8
+ ---
9
+
10
+ ## Every query so far has had a storage path in it
11
+
12
+ `customer.country` is where the country *lives*. It is not what anybody calls it. Once that
13
+ path is in a saved filter, in a URL and in three dashboards, moving the field is a migration.
14
+
15
+ A **vocabulary** is the one place that says what your data is called:
16
+
17
+ | | |
18
+ | --- | --- |
19
+ | **names** | one per field, with the storage path behind it |
20
+ | **aliases** | because people type `at`, and `placed`, and `order`, and `id` |
21
+ | **kinds** | how the search box should read what somebody types after the colon |
22
+ | **computed fields** | names for things the data does not store at all |
23
+ | **`text`** | which fields a bare word searches |
24
+
25
+ One vocabulary serves both the JSON engine and the text front end, deliberately. Two lists of
26
+ field names would be two dialects within a week, and a filter typed into a search box has to
27
+ mean exactly what the JSON it becomes means.
28
+
29
+ ## Harbour's vocabulary
30
+
31
+ Save this as `harbour/vocabulary.mjs`. Every example below imports it.
32
+
33
+ ```js
34
+ import { defineVocabulary } from "@osqd/jql";
35
+
36
+ /** The names Harbour's data has — for the JSON engine and the search box alike. */
37
+ export const ORDERS = defineVocabulary()({
38
+ fields: {
39
+ id: { kind: "word", aliases: ["order"] },
40
+ status: { kind: "exact", values: ["open", "paid", "refunded", "cancelled"] },
41
+ channel: { kind: "exact", values: ["web", "app", "phone"] },
42
+ who: { path: "customer.name" },
43
+ country: { path: "customer.country", kind: "exact", values: ["GB", "US", "DE", "FR"] },
44
+ email: { path: "customer.email", kind: "word" },
45
+ sku: { path: "lines.sku", kind: "exact" },
46
+ total: { kind: "number" },
47
+ placed: { kind: "date", aliases: ["at"] },
48
+ tag: { path: "tags", kind: "exact" },
49
+ note: {},
50
+ outstanding: { kind: "number", get: (order) => order.total - order.paid },
51
+ },
52
+ text: ["id", "who", "email", "sku", "note"],
53
+ });
54
+ ```
55
+
56
+ Note the empty parentheses in `defineVocabulary()({ … })`. It is curried, so that you can give
57
+ it the document type — `defineVocabulary<Order>()({ … })` — and still have the field names
58
+ inferred from the object rather than having to write them twice.
59
+
60
+ ## Querying by name
61
+
62
+ ```js
63
+ import { filter } from "@osqd/jql";
64
+ import { orders } from "./orders.mjs";
65
+ import { ORDERS } from "./vocabulary.mjs";
66
+
67
+ const ids = (query) => filter(orders, query, { vocabulary: ORDERS }).map((o) => o.id);
68
+
69
+ console.log("a name for a nested path:", ids({ country: "GB" }));
70
+ console.log("a name through a list :", ids({ sku: "desk-lamp" }));
71
+ console.log("an alias :", ids({ at: { $gte: { $date: "2026-09-18" } } }));
72
+ ```
73
+
74
+ ```
75
+ a name for a nested path: [ 'o-1001', 'o-1003', 'o-1005' ]
76
+ a name through a list : [ 'o-1005', 'o-1007' ]
77
+ an alias : [ 'o-1006', 'o-1007', 'o-1008' ]
78
+ ```
79
+
80
+ Not one of those queries mentions `customer`, `lines` or `placed`. The vocabulary is the only
81
+ thing that knows where those values are kept, so moving one is an edit to a single file.
82
+
83
+ ## Computed fields
84
+
85
+ `outstanding` is not in the order book at all. It is `total - paid`, worked out when the query
86
+ runs:
87
+
88
+ ```js
89
+ import { filter } from "@osqd/jql";
90
+ import { orders } from "./orders.mjs";
91
+ import { ORDERS } from "./vocabulary.mjs";
92
+
93
+ const ids = (query) => filter(orders, query, { vocabulary: ORDERS }).map((o) => o.id);
94
+
95
+ console.log("still owing anything:", ids({ outstanding: { $gt: 0 } }));
96
+ console.log("owing £100 or more :", ids({ outstanding: { $gte: 100 } }));
97
+ ```
98
+
99
+ ```
100
+ still owing anything: [ 'o-1002', 'o-1004', 'o-1005', 'o-1007' ]
101
+ owing £100 or more : [ 'o-1005', 'o-1007' ]
102
+ ```
103
+
104
+ From the outside it is an ordinary field, and that is the point — it works everywhere a name
105
+ works:
106
+
107
+ ```js
108
+ import { group, search } from "@osqd/jql";
109
+ import { orders } from "./orders.mjs";
110
+ import { ORDERS } from "./vocabulary.mjs";
111
+
112
+ const worst = search(orders, {
113
+ where: { outstanding: { $gt: 0 } },
114
+ sort: { outstanding: -1 },
115
+ fields: ["id", "outstanding"],
116
+ }, { vocabulary: ORDERS });
117
+
118
+ console.log("sorted by it:");
119
+ for (const row of worst) console.log(" ", row.id, row.outstanding);
120
+
121
+ console.log("grouped by country, where it is owed:");
122
+ for (const held of group(orders, "country", { vocabulary: ORDERS, where: { outstanding: { $gt: 0 } } })) {
123
+ console.log(" ", held.key, held.count);
124
+ }
125
+ ```
126
+
127
+ ```
128
+ sorted by it:
129
+ o-1007 280
130
+ o-1005 170
131
+ o-1004 36
132
+ o-1002 30
133
+ grouped by country, where it is owed:
134
+ US 2
135
+ DE 1
136
+ GB 1
137
+ ```
138
+
139
+ Lesson 7's exercise handed back `total` and `paid` and let the screen do the subtraction. Now
140
+ the screen asks for what it actually means.
141
+
142
+ Two things to know about `get`:
143
+
144
+ - It is called once per document per field it is tested against, and **it must not throw**. It
145
+ is code running inside a filter; treat it as you would a comparison function.
146
+ - A computed field **cannot be pushed to a database** (lesson 15), because a store cannot run
147
+ a function that lives in this process. `plan` knows that, and keeps those clauses here.
148
+
149
+ ## Kinds
150
+
151
+ A `kind` is for the **text** front end only. A JSON query already says what it means with its
152
+ operator, so the engine ignores the kind entirely; it is how `total:>70` in a search box knows
153
+ to compare rather than to look for the characters `>70`.
154
+
155
+ | | |
156
+ | --- | --- |
157
+ | `text` | contains it, ignoring case. The default, and right for prose |
158
+ | `word` | contains it as a whole component — for identifiers and addresses |
159
+ | `exact` | equals it, ignoring case — for closed sets like a status |
160
+ | `number` | compares: `>70`, `10..20`, `42` |
161
+ | `date` | compares instants: `>2026-09-01`, `>-7d` |
162
+ | `boolean` | `true`/`false`, `yes`/`no`, `1`/`0` |
163
+
164
+ `values` lists what a closed set contains, which lesson 11 turns into completions.
165
+
166
+ ## `strict`, for a public contract
167
+
168
+ ```js
169
+ import { defineVocabulary, validate } from "@osqd/jql";
170
+
171
+ const PUBLIC = defineVocabulary()({
172
+ fields: {
173
+ status: { kind: "exact" },
174
+ country: { path: "customer.country", kind: "exact" },
175
+ placed: { kind: "date" },
176
+ },
177
+ strict: true,
178
+ });
179
+
180
+ const byName = validate({ country: "GB" }, { vocabulary: PUBLIC });
181
+ const byPath = validate({ "customer.email": "ada@example.com" }, { vocabulary: PUBLIC });
182
+
183
+ console.log("a name it knows :", byName.valid ? "accepted" : byName.error.message);
184
+ console.log("a path it does not:", byPath.valid ? "accepted" : byPath.error.message);
185
+ ```
186
+
187
+ ```
188
+ a name it knows : accepted
189
+ a path it does not: at customer.email: "customer.email" is not a field here
190
+ ```
191
+
192
+ **With `strict`, the vocabulary is the whole contract.** A query may name those fields and
193
+ nothing else. That is what belongs in front of a public endpoint: documents grow fields nobody
194
+ promised to keep, and a query language that can reach every one of them turns every internal
195
+ field into part of your API.
196
+
197
+ Without `strict`, storage paths keep working alongside the names, which is what you want for
198
+ your own console.
199
+
200
+ ## A vocabulary that would mean two things is refused
201
+
202
+ ```js
203
+ import { defineVocabulary, JqlError } from "@osqd/jql";
204
+
205
+ const attempts = [
206
+ ["a name that collides", () => defineVocabulary()({ fields: { a: {}, b: { aliases: ["A"] } } })],
207
+ ["a path and a getter", () => defineVocabulary()({ fields: { a: { path: "x", get: () => 1 } } })],
208
+ ["a text field that is not a field", () => defineVocabulary()({ fields: { a: {} }, text: ["b"] })],
209
+ ];
210
+
211
+ for (const [why, build] of attempts) {
212
+ try {
213
+ build();
214
+ console.log(`${why}: accepted`);
215
+ } catch (error) {
216
+ console.log(`${why}:`);
217
+ console.log(` ${error instanceof JqlError ? error.message : error}`);
218
+ }
219
+ }
220
+ ```
221
+
222
+ ```
223
+ a name that collides:
224
+ at vocabulary: "A" names both "a" and "b", so a query using it could mean either
225
+ a path and a getter:
226
+ at vocabulary.a: "a" has both a path and a getter; one of them would be ignored, so say which
227
+ a text field that is not a field:
228
+ at vocabulary.text: the text field "b" is not a field of this vocabulary, so a bare word would never search it
229
+ ```
230
+
231
+ All three are refused **when the vocabulary is defined**, not when a query eventually uses it.
232
+ A vocabulary that could mean two things is a bug you want at startup, not in a support ticket.
233
+
234
+ ## Exercise
235
+
236
+ Add a `days` field — how long an order has been waiting, in whole days, as of a given clock —
237
+ and find everything open for more than a week as of 25 September 2026.
238
+
239
+ <details>
240
+ <summary>Answer</summary>
241
+
242
+ A `get` receives only the document, so the clock has to come from the closure:
243
+
244
+ ```js
245
+ import { defineVocabulary, filter } from "@osqd/jql";
246
+ import { orders } from "./orders.mjs";
247
+
248
+ const asOf = Date.parse("2026-09-25T12:00:00Z");
249
+
250
+ const AGED = defineVocabulary()({
251
+ fields: {
252
+ status: { kind: "exact" },
253
+ days: { kind: "number", get: (order) => Math.floor((asOf - Date.parse(order.placed)) / 86_400_000) },
254
+ },
255
+ });
256
+
257
+ console.log(filter(orders, { status: "open", days: { $gt: 7 } }, { vocabulary: AGED }).map((o) => o.id));
258
+ ```
259
+
260
+ ```
261
+ [ 'o-1002', 'o-1005' ]
262
+ ```
263
+
264
+ Now notice what you have just built: a vocabulary whose meaning depends on **when it was
265
+ made**. That is fine for one created per request, and a trap at module level — a filter that
266
+ says "over a week old" and quietly means "over a week old as of process start" is exactly the
267
+ kind of wrongness the rest of this library works to prevent.
268
+
269
+ The alternative is a relative date from lesson 5, which resolves per compile and says what it
270
+ means: `{ placed: { $lt: { $date: { $ago: "7d" } } } }`.
271
+ </details>
272
+
273
+ ## Related
274
+
275
+ - [TypeScript guide](../guides/typescript.md#vocabularies) — vocabularies with the types
276
+ - [Text syntax](../reference/text-syntax.md) — what each `kind` does to a typed value