@adonis-agora/filter 0.6.0 → 0.7.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.
@@ -0,0 +1,337 @@
1
+ ---
2
+ name: filter-definitions
3
+ description: >
4
+ Declare reusable FilterSpec policies with defineFilter from @adonis-agora/filter.
5
+ Covers the full options map — filterable (array, '*', or colocated kind map),
6
+ sortable/searchable defaults, relations whitelists translating dotted paths
7
+ into Lucid whereHas subqueries with maxDepth caps, aliases remapping client
8
+ names to real targets (one hop, cycle-free), computed virtual columns
9
+ (string vs correlated-subquery function forms, table/model requirement),
10
+ to-many aggregates ($count/$sum/$avg/$min/$max unlocked via model +
11
+ RelationSpec.aggregates), fieldTypes value coercion and the colocated
12
+ filterable map form, tenant scope, defaultFilters/defaultSort/defaultSize/
13
+ maxSize, throwOnInvalid, FilterDefinitionError at wiring time, specToFilterConfig,
14
+ and resolveFieldAlias/remap* helpers. Use when authoring a filter policy,
15
+ exposing relation/computed/aggregate fields, or renaming public field names.
16
+ metadata:
17
+ type: core
18
+ library: "@adonis-agora/filter"
19
+ library_version: "0.7.0"
20
+ framework: adonisjs
21
+ sources:
22
+ - "DavideCarvalho/adonis-filter:docs/definitions/index.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/definitions/relations.mdx"
24
+ - "DavideCarvalho/adonis-filter:docs/definitions/computed.mdx"
25
+ - "DavideCarvalho/adonis-filter:docs/definitions/aggregates.mdx"
26
+ - "DavideCarvalho/adonis-filter:docs/definitions/aliases.mdx"
27
+ ---
28
+
29
+ # Filter definitions: declare the policy once with defineFilter
30
+
31
+ A `FilterSpec` is a plain, framework-free object built once at module scope by
32
+ `defineFilter`. It captures what is filterable/sortable, which relations are
33
+ reachable, tenant scoping, and server-side defaults — then
34
+ `applyFilterFromRequest(query, spec, ctx)` applies it on every request. The same
35
+ object also drives client codegen (`generateFilterClient`).
36
+
37
+ ## Setup
38
+
39
+ ```typescript
40
+ // app/filters/user_filter.ts
41
+ import type { HttpContext } from '@adonisjs/core/http'
42
+ import { defineFilter } from '@adonis-agora/filter'
43
+ import User from '#models/user'
44
+
45
+ export const userFilter = defineFilter({
46
+ filterable: ['name', 'email', 'age', 'status'],
47
+ sortable: ['name', 'createdAt'],
48
+ searchable: ['name', 'email'],
49
+
50
+ relations: {
51
+ posts: {
52
+ filterable: ['title', 'status'],
53
+ sortable: ['createdAt'],
54
+ relations: { comments: { filterable: ['body'] } }, // posts.comments.body
55
+ },
56
+ },
57
+
58
+ tenant: {
59
+ column: 'tenantId',
60
+ resolve: (ctx) => (ctx as HttpContext).auth.user!.tenantId,
61
+ },
62
+
63
+ defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
64
+ defaultSort: [{ field: 'createdAt', direction: 'desc' }],
65
+ defaultSize: 25,
66
+ maxSize: 100,
67
+ })
68
+ ```
69
+
70
+ Apply it in the controller:
71
+
72
+ ```typescript
73
+ import { applyFilterFromRequest } from '@adonis-agora/filter'
74
+ import { userFilter } from '#filters/user_filter'
75
+ import User from '#models/user'
76
+
77
+ export default class UsersController {
78
+ async index(ctx: HttpContext) {
79
+ const query = User.query()
80
+ const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
81
+ return query.paginate(page, size)
82
+ }
83
+ }
84
+ ```
85
+
86
+ `defineFilter` validates the declaration itself: a missing `filterable` or a
87
+ negative/non-integer `maxDepth` throws `FilterDefinitionError` at wiring time —
88
+ distinct from request-time `InvalidColumnFilterError`.
89
+
90
+ ## Core patterns
91
+
92
+ ### 1. Relations — dotted request paths become whereHas subqueries
93
+
94
+ Declare each relation by its model name; columns stay bare. Every dotted path
95
+ segment but the last is a relation hop, translated into nested
96
+ `whereHas` subqueries — no manual join:
97
+
98
+ ```
99
+ ?filter[posts.title]=Release
100
+ → whereHas('posts', (sub) => sub.where('title', 'Release'))
101
+ ```
102
+
103
+ `maxDepth` bounds hops (base column = 0) and defaults to your deepest declared
104
+ nesting; an explicit smaller value caps paths even when deeper ones exist.
105
+ Relation sorting forwards through the sort stage, but ordering across a to-many
106
+ relation may need joins/aggregation — prefer relation filters, and sort by
107
+ aggregate aliases (`withCount`'s `posts_count`) where possible.
108
+
109
+ Source: `docs/definitions/relations.mdx`, `docs/guides/relations.mdx`
110
+
111
+ ### 2. Computed fields — virtual columns backed by your SQL
112
+
113
+ String sources inline verbatim; function sources receive `{ alias }` (the root
114
+ table name) for correlated subqueries. A declared alias is filterable and
115
+ sortable like any column, and its *declaration* is its allow-list — never add
116
+ it to `filterable`:
117
+
118
+ ```typescript
119
+ export const userFilter = defineFilter({
120
+ filterable: ['status'],
121
+ model: User, // supplies table: 'users' — required for the function form
122
+ computed: {
123
+ fullName: "first_name || ' ' || last_name",
124
+ postCount: ({ alias }) =>
125
+ `(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)`,
126
+ },
127
+ })
128
+ ```
129
+
130
+ Client values always ride as positional bindings; only dev-authored SQL is
131
+ inlined. Keep computed conditions at top level — see mistakes below.
132
+
133
+ Source: `docs/definitions/computed.mdx`
134
+
135
+ ### 3. To-many aggregates — `$count` / `$sum` / `$avg` / `$min` / `$max`
136
+
137
+ Aggregates are auto-generated computed fields, off until the spec can
138
+ introspect relations: pass `model` plus a `relations` entry naming the to-many
139
+ relations; column functions additionally require those child columns listed
140
+ under `aggregates` (you assert numericness — Lucid does not reflect SQL types):
141
+
142
+ ```typescript
143
+ export const userFilter = defineFilter({
144
+ filterable: ['status'],
145
+ model: User,
146
+ relations: {
147
+ posts: { filterable: ['status'], aggregates: ['views'] },
148
+ },
149
+ })
150
+ ```
151
+
152
+ This makes `posts.$count` plus `posts.$sum.views` / `$avg` / `$min` / `$max`
153
+ filterable and sortable. Discovery degrades gracefully — no introspection means
154
+ no aggregates, never a wiring-time throw. Each aggregate is a correlated
155
+ subquery evaluated per row; index the child FK (or materialise a counter) on
156
+ hot paths.
157
+
158
+ Source: `docs/definitions/aggregates.mdx`
159
+
160
+ ### 4. Aliases and field types
161
+
162
+ Aliases remap a public name onto a whitelisted target (base column or relation
163
+ path); resolution runs before allow-listing, exactly one hop:
164
+
165
+ ```typescript
166
+ export const userFilter = defineFilter({
167
+ filterable: ['status', 'name'],
168
+ relations: { posts: { filterable: ['title'] } },
169
+ aliases: {
170
+ legacyStatus: 'status',
171
+ postTitle: 'posts.title',
172
+ },
173
+ fieldTypes: { age: { kind: 'number' } },
174
+ })
175
+ ```
176
+
177
+ Prefer the colocated map form when several fields have non-string kinds —
178
+ `filterable: { advisorId: 'string', dayOfWeek: 'number', isRecurring: 'boolean' }`
179
+ desugars into the array plus `fieldTypes`. An explicit `fieldTypes` entry still
180
+ wins per field, which is how you add codegen-only richness (`enumValues`,
181
+ `typeRef`).
182
+
183
+ Source: `docs/definitions/aliases.mdx`, `docs/definitions/index.mdx`
184
+
185
+ ### 5. Reuse the spec outside HTTP
186
+
187
+ `specToFilterConfig(spec)` projects the spec onto the per-call `FilterConfig`;
188
+ pass pre-parsed input via `options.input` for jobs/tests;
189
+ `resolveFieldAlias` / `remapFilterAliases` / `remapSortAliases` are exported
190
+ for custom pipelines:
191
+
192
+ ```typescript
193
+ import { applyFilterFromRequest } from '@adonis-agora/filter'
194
+
195
+ applyFilterFromRequest(query, userFilter, ctx, {
196
+ input: { filters: [{ field: 'status', operator: 'equals', value: 'active' }] },
197
+ })
198
+ ```
199
+
200
+ Source: `docs/definitions/index.mdx` (Under the hood)
201
+
202
+ ## Common mistakes
203
+
204
+ ### [HIGH] Declaring aggregate paths without the owning `model`
205
+
206
+ Wrong:
207
+
208
+ ```typescript
209
+ defineFilter({
210
+ filterable: ['status'],
211
+ relations: { posts: { filterable: ['title'], aggregates: ['views'] } },
212
+ }) // posts.$count etc. simply don't exist — referencing requests get pruned
213
+ ```
214
+
215
+ Correct:
216
+
217
+ ```typescript
218
+ defineFilter({
219
+ filterable: ['status'],
220
+ model: User,
221
+ relations: { posts: { filterable: ['title'], aggregates: ['views'] } },
222
+ })
223
+ ```
224
+
225
+ Aggregates activate only when the spec can read relation metadata off the model
226
+ — without `model`, nothing is synthesised and no error points at the cause.
227
+
228
+ Source: docs/definitions/aggregates.mdx ("Unlocking aggregates — the model option")
229
+
230
+ ### [HIGH] Function-form computed fields without a root table
231
+
232
+ Wrong:
233
+
234
+ ```typescript
235
+ defineFilter({
236
+ filterable: ['status'],
237
+ computed: { postCount: ({ alias }) => `(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)` },
238
+ }) // alias resolves to '' → malformed SQL
239
+ ```
240
+
241
+ Correct:
242
+
243
+ ```typescript
244
+ defineFilter({
245
+ filterable: ['status'],
246
+ model: User, // or table: 'users'
247
+ computed: { postCount: ({ alias }) => `(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)` },
248
+ })
249
+ ```
250
+
251
+ Correlated subqueries splice `spec.table` as the outer alias; with neither
252
+ `table` nor `model` set, the alias is an empty string and the SQL breaks at the
253
+ database.
254
+
255
+ Source: docs/definitions/computed.mdx ("table is required for the function form")
256
+
257
+ ### [HIGH] Reading a dotted alias target as a JSON path
258
+
259
+ Wrong:
260
+
261
+ ```typescript
262
+ aliases: { tier: 'metadata.tier' }
263
+ // → whereHas('metadata') — looks for a RELATION named metadata, fails at the DB
264
+ ```
265
+
266
+ Correct:
267
+
268
+ ```typescript
269
+ computed: { tier: "metadata->>'tier'" } // JSON access needs computed SQL you write
270
+ ```
271
+
272
+ In alias targets everything before the last dot is a relation hop; there is no
273
+ JSON-path reading, and adding the path to `relations` will not make it one.
274
+
275
+ Source: docs/definitions/aliases.mdx ("A dot in the target means a relation, not a JSON path")
276
+
277
+ ### [MEDIUM] Nesting computed aliases inside AND/OR groups
278
+
279
+ Wrong:
280
+
281
+ ```
282
+ filter[OR][0][postCount][gte]=5 // treated as a normal column → dropped
283
+ ```
284
+
285
+ Correct:
286
+
287
+ ```
288
+ filter[postCount][gte]=5 // keep computed conditions top-level
289
+ ```
290
+
291
+ Computed filtering is recognized only on top-level leaf filters; inside a
292
+ group the alias falls back to the column pipeline and vanishes unless it is
293
+ also a real column.
294
+
295
+ Source: docs/definitions/computed.mdx ("How a computed filter is routed")
296
+
297
+ ### [MEDIUM] Chaining aliases expecting multi-hop resolution
298
+
299
+ Wrong:
300
+
301
+ ```typescript
302
+ resolveFieldAlias({ a: 'b', b: 'c' }, 'a') // expecting 'c' — actually returns 'b'
303
+ ```
304
+
305
+ Correct:
306
+
307
+ ```typescript
308
+ resolveFieldAlias({ a: 'c', b: 'c' }, 'a') // point aliases directly at real targets
309
+ ```
310
+
311
+ Resolution runs exactly one hop and never re-runs the target through the map,
312
+ which makes cycles structurally impossible — and multi-hop chains silently
313
+ resolve short.
314
+
315
+ Source: docs/definitions/aliases.mdx ("No cascading, no cycles")
316
+
317
+ ### [MEDIUM] Expecting date-coerced values back as Date objects
318
+
319
+ Wrong:
320
+
321
+ ```typescript
322
+ // fieldTypes: { bornOn: { kind: 'date' } } — then expecting value instanceof Date downstream
323
+ ```
324
+
325
+ Correct:
326
+
327
+ ```typescript
328
+ // the bound value stays the validated ISO string ('2026-07-15'); the driver parses it
329
+ ```
330
+
331
+ A `date` kind validates syntax but hands the string back verbatim — re-zoning
332
+ it to midnight UTC would shift days for negative-offset clients.
333
+
334
+ Source: docs/definitions/index.mdx (coercion rules)
335
+
336
+ See also: `../filter-codegen/SKILL.md` (the same spec generates the typed
337
+ client), `../filter-safety/SKILL.md` (the boundary these declarations enforce).
@@ -0,0 +1,319 @@
1
+ ---
2
+ name: filter-querying
3
+ description: >
4
+ Advanced querying with @adonis-agora/filter beyond basic offset filtering —
5
+ keyset/cursor pagination (applyCursor, applyCursorFromRequest, buildCursorPage,
6
+ buildKeyset, opaque base64url encodeCursor/decodeCursor, after/before/first/last
7
+ params), the three search modes (searchable ILIKE scan vs fullText tsvector
8
+ websearch_to_tsquery/ts_rank vs vectorSimilarity pgvector distance metrics
9
+ cosine/l2/innerProduct with threshold/topK), DISTINCT projections and their
10
+ root-table-column restriction, and the richer request surface (parseSpatieRequest
11
+ with include/sparse fieldsets/cursor params, resolveInputFromRequest sources,
12
+ normalizeInput key casing). Use when serving stable deep pages, ranking by
13
+ relevance or embeddings, projecting distinct column values, or accepting
14
+ filters from a POST body.
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/filter"
18
+ library_version: "0.7.0"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-filter:docs/pagination/index.mdx"
22
+ - "DavideCarvalho/adonis-filter:docs/search/index.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/search/full-text.mdx"
24
+ - "DavideCarvalho/adonis-filter:docs/search/vector-similarity.mdx"
25
+ - "DavideCarvalho/adonis-filter:docs/guides/lucid.mdx"
26
+ - "DavideCarvalho/adonis-filter:docs/definitions/request-input.mdx"
27
+ ---
28
+
29
+ # Structured querying: cursors, search modes, distinct
30
+
31
+ Offset pagination (`applyFilter` → `page`/`size`) drifts as rows are inserted
32
+ and gets slow on deep pages. Keyset pagination seeks from a boundary row; text
33
+ `search` routes through one of two engines you pick per policy; `distinct`
34
+ projects the values that exist under the current filters.
35
+
36
+ ## Setup
37
+
38
+ ```typescript
39
+ // app/controllers/users_controller.ts
40
+ import type { HttpContext } from '@adonisjs/core/http'
41
+ import { applyCursorFromRequest, buildCursorPage } from '@adonis-agora/filter'
42
+ import { userFilter } from '#filters/user_filter'
43
+ import User from '#models/user'
44
+
45
+ export default class UsersController {
46
+ async index(ctx: HttpContext) {
47
+ const query = User.query()
48
+
49
+ // same spec as offset flow; cursor params instead of page/size
50
+ const resolved = applyCursorFromRequest(query, userFilter, ctx, { primaryKey: 'id' })
51
+
52
+ const rows = await query.exec()
53
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
54
+
55
+ return {
56
+ data: page.items,
57
+ nextCursor: page.nextCursor,
58
+ prevCursor: page.prevCursor,
59
+ hasNext: page.hasNext,
60
+ hasPrev: page.hasPrev,
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Cursor params arrive as `after`/`before`/`first`/`last` (or JSON:API
67
+ `page[after]`…). `after` wins if both bounds are sent. Filters, search, and sort
68
+ pass through the same allow-list boundary as `applyFilter`.
69
+
70
+ ## Core patterns
71
+
72
+ ### 1. Keyset building blocks
73
+
74
+ The effective sort plus a primary-key tiebreaker forms the keyset; the query is
75
+ limited to `size + 1` so `buildCursorPage` can detect a next page:
76
+
77
+ ```typescript
78
+ import { applyCursor, buildCursorPage } from '@adonis-agora/filter'
79
+
80
+ const resolved = applyCursor(
81
+ User.query(),
82
+ { sort: [{ field: 'name', direction: 'asc' }], after: cursor, first: 20 },
83
+ { allowed: ['name'], primaryKey: 'id' },
84
+ )
85
+
86
+ const rows = await query.exec()
87
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
88
+ ```
89
+
90
+ A malformed cursor is ignored, not fatal. Backward paging (`before`) reverses
91
+ the walk internally and `buildCursorPage` restores your requested order;
92
+ `prevCursor` from a forward page is a valid `before`.
93
+
94
+ Source: `docs/pagination/index.mdx`
95
+
96
+ ### 2. Full-text search (keywords) via fullText
97
+
98
+ When the policy declares `fullText`, the request `search` term routes through
99
+ Postgres tsvector matching instead of ILIKE:
100
+
101
+ ```typescript
102
+ import { defineFilter } from '@adonis-agora/filter'
103
+
104
+ export const articleFilter = defineFilter({
105
+ filterable: ['status'],
106
+ fullText: {
107
+ column: 'search_vector', // stored, GIN-indexed tsvector column
108
+ columnKind: 'tsvector',
109
+ language: 'english',
110
+ rank: true, // ORDER BY ts_rank(...) DESC
111
+ },
112
+ })
113
+ ```
114
+
115
+ `?search=quick brown fox` becomes
116
+ `WHERE "search_vector" @@ websearch_to_tsquery('english', ?)` — raw user input
117
+ is safe because `websearch_to_tsquery` tolerates stray punctuation and the term
118
+ always travels bound. Plain text columns work too (`columnKind: 'text'`),
119
+ wrapped in `to_tsvector` at query time. `applyFullTextSearch` is exported for
120
+ direct use outside the runner.
121
+
122
+ Source: `docs/search/full-text.mdx`
123
+
124
+ ### 3. Vector similarity (embeddings) via vectorSimilarity
125
+
126
+ Rank rows nearest-first by pgvector distance to a query embedding — supplied in
127
+ code, never through the URL:
128
+
129
+ ```typescript
130
+ export const docFilter = defineFilter({
131
+ filterable: ['status'],
132
+ vectorSimilarity: { column: 'embedding', metric: 'cosine', threshold: 0.25, topK: 20 },
133
+ })
134
+
135
+ // controller — compute the embedding, then pass it in options:
136
+ const embedding = await embeddingService.embed(q)
137
+ const { page, size } = applyFilterFromRequest(query, docFilter, ctx, {
138
+ vectorSimilarity: embedding,
139
+ })
140
+ ```
141
+
142
+ Similarity ordering lands before user sort (sort becomes the tiebreaker) and
143
+ runs only when both policy and request carry an embedding. The embedding binds
144
+ as `?::vector`; NaN/Infinity components throw rather than emitting bad SQL.
145
+ `applyVectorSimilarity` is exported for direct use.
146
+
147
+ Source: `docs/search/vector-similarity.mdx`
148
+
149
+ ### 4. DISTINCT projections come free on the wire format
150
+
151
+ `?distinct=status` parses into `input.distinct`, is allow-listed like any
152
+ field, and applies itself:
153
+
154
+ ```typescript
155
+ const input = parseFilterRequest(request.qs())
156
+ applyFilter(query, input, { allowed: ['status', 'baseId'] })
157
+ const rows = await query.select('status') // DISTINCT already applied
158
+ ```
159
+
160
+ ### 5. Richer request shapes
161
+
162
+ ```typescript
163
+ import {
164
+ parseSpatieRequest,
165
+ resolveInputFromRequest,
166
+ normalizeInput,
167
+ } from '@adonis-agora/filter'
168
+
169
+ // Spatie/JSON:API superset: includes, sparse fieldsets, cursor page params
170
+ const input = parseSpatieRequest(request.qs())
171
+
172
+ // source filters from body (POST /search), auto-merging query+body on writes
173
+ const raw = resolveInputFromRequest(request, 'auto')
174
+
175
+ // snake_case API → camelCase columns (top-level keys only)
176
+ normalizeInput({ company_id: 5 }, { normalizer: 'camelCase' })
177
+ ```
178
+
179
+ Source: `docs/definitions/request-input.mdx`
180
+
181
+ ## Common mistakes
182
+
183
+ ### [HIGH] Passing model instances to buildCursorPage
184
+
185
+ Wrong:
186
+
187
+ ```typescript
188
+ const rows = await query.exec()
189
+ const page = buildCursorPage(rows, resolved) // Lucid model instances
190
+ ```
191
+
192
+ Correct:
193
+
194
+ ```typescript
195
+ const rows = await query.exec()
196
+ const page = buildCursorPage(rows.map((r) => r.serialize()), resolved)
197
+ ```
198
+
199
+ `buildCursorPage` reads keyset values off plain objects by field name; model
200
+ instances do not expose those plain keys, so boundary cursors encode garbage
201
+ and the next page silently starts from the wrong row.
202
+
203
+ Source: docs/pagination/index.mdx ("Feed buildCursorPage plain rows")
204
+
205
+ ### [HIGH] Projecting distinct on a relation path
206
+
207
+ Wrong:
208
+
209
+ ```typescript
210
+ applyFilter(User.query(), input, { allowed: ['status', 'posts.title'] })
211
+ // ?distinct=posts.title → dropped / InvalidColumnFilterError under throwOnInvalid
212
+ ```
213
+
214
+ Correct:
215
+
216
+ ```typescript
217
+ // distinct only names root-table columns: ?distinct=status
218
+ applyFilter(User.query(), input, { allowed: ['status'] })
219
+ ```
220
+
221
+ Relation filtering compiles to correlated EXISTS subqueries, so the relation is
222
+ never joined and there is no alias to project from — the emitted SQL would be a
223
+ missing-FROM error at the database. Adding the path to allow-lists cannot
224
+ change this; it is structural, not a policy decision.
225
+
226
+ Source: docs/guides/lucid.mdx ("distinct takes root-table columns only")
227
+
228
+ ### [HIGH] Expecting include/select to be applied
229
+
230
+ Wrong:
231
+
232
+ ```typescript
233
+ const input = parseSpatieRequest(request.qs())
234
+ applyFilter(query, input, { allowed: ['name'] })
235
+ return query.paginate(1, 25) // ?include=posts ignored — posts never loaded
236
+ ```
237
+
238
+ Correct:
239
+
240
+ ```typescript
241
+ const input = parseSpatieRequest(request.qs())
242
+ applyFilter(query, input, { allowed: ['name'] })
243
+ for (const relation of input.include ?? []) {
244
+ if (PRELOADABLE.includes(relation)) query.preload(relation as any)
245
+ }
246
+ return query.paginate(1, 25)
247
+ ```
248
+
249
+ `include` and sparse fieldset `select` are parsed but deliberately applied by no
250
+ runner — eager-loading which relations are safe stays your validated decision.
251
+ The client builder's `.include(...)` is likewise only a request.
252
+
253
+ Source: docs/guides/client.mdx ("include is a request, not an action"); docs/definitions/request-input.mdx
254
+
255
+ ### [HIGH] Confusing full-text search with vector similarity
256
+
257
+ Wrong:
258
+
259
+ ```typescript
260
+ defineFilter({ fullText: { column: 'embedding' } }) // tsvector @@ against a vector column
261
+ ```
262
+
263
+ Correct:
264
+
265
+ ```typescript
266
+ defineFilter({
267
+ vectorSimilarity: { column: 'embedding', metric: 'cosine' }, // embeddings rank nearest-first
268
+ // fullText: { column: 'search_vector' }, // keywords match lexemes
269
+ })
270
+ ```
271
+
272
+ Full-text matches words against a tsvector document; vector similarity ranks by
273
+ embedding distance. Configuring one while sending the other's input yields
274
+ silently wrong search semantics. They are additive — filter by keywords, rank
275
+ by embedding.
276
+
277
+ Source: docs/search/index.mdx ("Full-text search ≠ vector similarity")
278
+
279
+ ### [MEDIUM] Trusting paginate() totals alongside distinct
280
+
281
+ Wrong:
282
+
283
+ ```typescript
284
+ const result = await query.distinct('status').paginate(page, size)
285
+ result.totalCount // counts UN-deduped rows
286
+ ```
287
+
288
+ Correct:
289
+
290
+ ```typescript
291
+ // run your own countDistinct when the total must match the deduped page
292
+ ```
293
+
294
+ Lucid's count leg rebuilds the SELECT, so the reported total counts un-deduped
295
+ rows while the page itself is deduped.
296
+
297
+ Source: docs/guides/lucid.mdx (distinct/paginate total callout)
298
+
299
+ ### [MEDIUM] Wrapping large text columns in query-time to_tsvector
300
+
301
+ Wrong:
302
+
303
+ ```typescript
304
+ fullText: { column: ['title', 'body'], columnKind: 'text' } // re-tokenizes every row
305
+ ```
306
+
307
+ Correct:
308
+
309
+ ```typescript
310
+ fullText: { column: 'search_vector', columnKind: 'tsvector' } // GIN-indexable
311
+ ```
312
+
313
+ Query-time `to_tsvector` cannot use an index — fine for small tables, a
314
+ sequential scan on anything big.
315
+
316
+ Source: docs/search/full-text.mdx (text-columns warning)
317
+
318
+ See also: `../filter-definitions/SKILL.md` (where fullText/vectorSimilarity are
319
+ declared), `../filter-safety/SKILL.md` (the allow-list these paths respect).