@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.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@adonis-agora/filter",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/DavideCarvalho/adonis-filter.git",
8
+ "url": "git+https://github.com/DavideCarvalho/adonis-agora-filter.git",
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "author": "Davi Carvalho <davi@goflip.ai>",
@@ -23,6 +23,11 @@
23
23
  "import": "./dist/src/testing.js",
24
24
  "default": "./dist/src/testing.js"
25
25
  },
26
+ "./configure": {
27
+ "types": "./dist/configure.d.ts",
28
+ "import": "./dist/configure.js",
29
+ "default": "./dist/configure.js"
30
+ },
26
31
  "./commands": {
27
32
  "types": "./dist/commands/main.d.ts",
28
33
  "import": "./dist/commands/main.js",
@@ -37,7 +42,8 @@
37
42
  "files": [
38
43
  "dist/",
39
44
  "README.md",
40
- "CHANGELOG.md"
45
+ "CHANGELOG.md",
46
+ "skills"
41
47
  ],
42
48
  "sideEffects": false,
43
49
  "peerDependencies": {
@@ -53,15 +59,17 @@
53
59
  }
54
60
  },
55
61
  "devDependencies": {
56
- "@adonisjs/core": "^7.3.0",
62
+ "@adonisjs/assembler": "8.4.0",
63
+ "@tanstack/intent": "^0.3.2",
64
+ "@adonisjs/core": "7.4.0",
57
65
  "@adonisjs/lucid": "22.4.2",
58
- "@types/luxon": "^3.7.2",
59
- "@types/node": "^20.0.0",
60
- "@types/pg": "^8.20.0",
61
- "luxon": "^3.7.2",
62
- "pg": "^8.22.0",
63
- "typescript": "^5.4.0",
64
- "vitest": "^3.0.0"
66
+ "@types/luxon": "3.7.4",
67
+ "@types/node": "20.19.43",
68
+ "@types/pg": "8.21.0",
69
+ "luxon": "3.7.2",
70
+ "pg": "8.23.0",
71
+ "typescript": "5.9.3",
72
+ "vitest": "3.2.7"
65
73
  },
66
74
  "engines": {
67
75
  "node": ">=20.6.0"
@@ -74,7 +82,8 @@
74
82
  "query",
75
83
  "spatie",
76
84
  "pagination",
77
- "agora"
85
+ "agora",
86
+ "tanstack-intent"
78
87
  ],
79
88
  "scripts": {
80
89
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,248 @@
1
+ ---
2
+ name: filter-basics
3
+ description: >
4
+ Add Spatie/JSON:API-style query filtering to an AdonisJS endpoint with
5
+ @adonis-agora/filter. Covers parseFilterRequest(qs), applyFilter(query, input,
6
+ { allowed, sortable, searchable }) with the returned/clamped { page, size },
7
+ the declarative defineFilter + applyFilterFromRequest flow (tenant scope,
8
+ defaultFilters, defaultSort, defaultSize/maxSize), the optional provider
9
+ macros .applyFilterFromRequest() / .filterPaginate(), wire format parsing
10
+ (filter[field][op]=value, sort=-createdAt, page/size), and input sourcing from
11
+ query string, POST body, or a nested key. Use when building a filtered list
12
+ endpoint, wiring a reusable filter policy, or deciding free functions vs macros.
13
+ metadata:
14
+ type: core
15
+ library: "@adonis-agora/filter"
16
+ library_version: "0.7.0"
17
+ framework: adonisjs
18
+ sources:
19
+ - "DavideCarvalho/adonis-filter:docs/getting-started.mdx"
20
+ - "DavideCarvalho/adonis-filter:docs/guides/controllers.mdx"
21
+ - "DavideCarvalho/adonis-filter:docs/guides/filter-classes.mdx"
22
+ - "DavideCarvalho/adonis-filter:docs/guides/provider.mdx"
23
+ - "DavideCarvalho/adonis-filter:docs/guides/operators.mdx"
24
+ ---
25
+
26
+ # Filter basics: parse a request, apply it to a Lucid query
27
+
28
+ The whole server surface is two functions: `parseFilterRequest(request.qs())`
29
+ reshapes the request into a structured `FilterInput`, then
30
+ `applyFilter(query, input, config)` validates it, prunes it against an
31
+ allow-list, mutates your Lucid query in place, and returns the resolved
32
+ `{ page, size }`. No config file, no decorators; the optional service provider
33
+ only adds chainable macros.
34
+
35
+ ## Setup
36
+
37
+ ```bash
38
+ npm install @adonis-agora/filter
39
+ ```
40
+
41
+ ```typescript
42
+ // app/controllers/users_controller.ts
43
+ import type { HttpContext } from '@adonisjs/core/http'
44
+ import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
45
+ import User from '#models/user'
46
+
47
+ export default class UsersController {
48
+ async index({ request }: HttpContext) {
49
+ const input = parseFilterRequest(request.qs())
50
+ const query = User.query()
51
+
52
+ const { page, size } = applyFilter(query, input, {
53
+ allowed: ['name', 'email', 'age', 'status'],
54
+ sortable: ['name', 'createdAt'],
55
+ searchable: ['name', 'email'],
56
+ defaultSize: 25,
57
+ maxSize: 100,
58
+ })
59
+
60
+ return query.paginate(page, size)
61
+ }
62
+ }
63
+ ```
64
+
65
+ ```typescript
66
+ // start/routes.ts
67
+ import router from '@adonisjs/core/services/router'
68
+ const UsersController = () => import('#controllers/users_controller')
69
+
70
+ router.get('/users', [UsersController, 'index'])
71
+ ```
72
+
73
+ A request like
74
+ `GET /users?filter[status]=active&filter[age][gte]=18&search=fleet&sort=-createdAt&page=1&size=25`
75
+ parses, validates, prunes against the allow-list, applies WHERE/ORDER BY/ILIKE,
76
+ and returns `{ page: 1, size: 25 }` for `query.paginate()`.
77
+
78
+ ## Core patterns
79
+
80
+ ### 1. Two-function primitive — parse once, apply once
81
+
82
+ `parseFilterRequest` is a pure reshape (no validation, no allow-listing);
83
+ `applyFilter` owns validation and pruning. It reads any decoded object, so the
84
+ same controller serves GET query strings and structured POST bodies:
85
+
86
+ ```typescript
87
+ import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
88
+
89
+ // GET — decoded query string
90
+ const input = parseFilterRequest(request.qs())
91
+
92
+ // POST /search — the body may be the flat Spatie shape or the structured
93
+ // object the client builder's .build() returns ({ filter: { where }, sort, paginate })
94
+ const bodyInput = parseFilterRequest(request.body())
95
+ ```
96
+
97
+ Source: `docs/guides/controllers.mdx`, `docs/guides/operators.mdx`
98
+
99
+ ### 2. Reusable policies with defineFilter + applyFilterFromRequest
100
+
101
+ When a policy spans endpoints, declare a `FilterSpec` once at module scope and
102
+ apply it in one call. Server scope (tenant + `defaultFilters`) is injected
103
+ before allow-listed request filters; `defaultSort` applies when the request has
104
+ none:
105
+
106
+ ```typescript
107
+ // app/filters/user_filter.ts
108
+ import { defineFilter } from '@adonis-agora/filter'
109
+ import User from '#models/user'
110
+
111
+ export const userFilter = defineFilter({
112
+ filterable: ['name', 'email', 'age', 'status'],
113
+ sortable: ['name', 'createdAt'],
114
+ searchable: ['name', 'email'],
115
+ relations: { posts: { filterable: ['title', 'status'] } },
116
+ tenant: { column: 'tenantId', resolve: (ctx) => (ctx as any).auth.user!.tenantId },
117
+ defaultFilters: [{ field: 'deletedAt', operator: 'isNull' }],
118
+ defaultSort: [{ field: 'createdAt', direction: 'desc' }],
119
+ defaultSize: 25,
120
+ maxSize: 100,
121
+ })
122
+ ```
123
+
124
+ ```typescript
125
+ // app/controllers/users_controller.ts
126
+ import { applyFilterFromRequest } from '@adonis-agora/filter'
127
+ import { userFilter } from '#filters/user_filter'
128
+ import User from '#models/user'
129
+
130
+ async index(ctx: HttpContext) {
131
+ const query = User.query()
132
+ const { page, size } = applyFilterFromRequest(query, userFilter, ctx)
133
+ return query.paginate(page, size)
134
+ }
135
+ ```
136
+
137
+ Source: `docs/definitions/index.mdx`
138
+
139
+ ### 3. Optional macros — inline chaining
140
+
141
+ Register the provider (`node ace add @adonis-agora/filter`, or add
142
+ `() => import('@adonis-agora/filter/filter_provider')` under `providers` in
143
+ `adonisrc.ts`) and every model query gains `.applyFilterFromRequest(spec)` /
144
+ `.filterPaginate(spec)`:
145
+
146
+ ```typescript
147
+ import { userFilter } from '#filters/user_filter'
148
+ import User from '#models/user'
149
+
150
+ // ctx read from AsyncLocalStorage inside a request
151
+ return User.query().where('active', true).applyFilterFromRequest(userFilter)
152
+
153
+ // filter + paginate in one terminal call
154
+ return User.query().filterPaginate(userFilter)
155
+ ```
156
+
157
+ The free `applyFilterFromRequest(query, spec, ctx, options?)` always needs an
158
+ explicit `ctx` — the AsyncLocalStorage fallback exists only in the macro layer.
159
+
160
+ Source: `docs/guides/provider.mdx`
161
+
162
+ ### 4. Pagination is returned, not applied
163
+
164
+ `applyFilter` resolves `size = clamp(input.size ?? defaultSize ?? 25, 1, maxSize ?? 100)`
165
+ and `page = max(1, input.page ?? 1)` but leaves execution to you — so you can
166
+ paginate, run plain `.exec()`, or layer a DISTINCT projection on the same
167
+ filtered query.
168
+
169
+ Source: `docs/getting-started.mdx`, `docs/guides/filter-classes.mdx`
170
+
171
+ ## Common mistakes
172
+
173
+ ### [CRITICAL] Feeding raw request page/size to paginate()
174
+
175
+ Wrong:
176
+
177
+ ```typescript
178
+ const input = parseFilterRequest(request.qs())
179
+ await User.query().paginate(input.page ?? 1, input.size ?? 25)
180
+ ```
181
+
182
+ Correct:
183
+
184
+ ```typescript
185
+ const query = User.query()
186
+ const { page, size } = applyFilter(query, input, { allowed: ['name'], maxSize: 100 })
187
+ return query.paginate(page, size)
188
+ ```
189
+
190
+ `applyFilter` clamps size into `[1, maxSize]` and floors page at 1; values taken
191
+ straight off the query string skip the clamp entirely, so `?size=500000`
192
+ executes against the database.
193
+
194
+ Source: docs/getting-started.mdx ("Why pagination is returned, not applied"); docs/guides/filter-classes.mdx (Pagination bounds)
195
+
196
+ ### [HIGH] Putting the tenant column in the allow-list
197
+
198
+ Wrong:
199
+
200
+ ```typescript
201
+ export const userFilter = defineFilter({
202
+ filterable: ['name', 'tenantId'], // client can now constrain tenantId too
203
+ tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
204
+ })
205
+ ```
206
+
207
+ Correct:
208
+
209
+ ```typescript
210
+ export const userFilter = defineFilter({
211
+ filterable: ['name'],
212
+ tenant: { column: 'tenantId', resolve: (ctx) => ctx.tenantId },
213
+ })
214
+ ```
215
+
216
+ The tenant scope always lands as trusted server policy; if the same column is
217
+ also client-filterable, a sent value AND-combines with it and silently returns
218
+ an empty result set. Same rule for pre-scoped queries: constrain before
219
+ `applyFilter` and leave the column out of `allowed`.
220
+
221
+ Source: docs/definitions/index.mdx ("Tenant scope is un-tamperable"); docs/guides/controllers.mdx
222
+
223
+ ### [MEDIUM] Expecting `search` to work without `searchable`
224
+
225
+ Wrong:
226
+
227
+ ```typescript
228
+ applyFilter(query, input, { allowed: ['name'] })
229
+ // ?search=fleet is silently skipped — no searchable columns declared
230
+ ```
231
+
232
+ Correct:
233
+
234
+ ```typescript
235
+ applyFilter(query, input, {
236
+ allowed: ['name'],
237
+ searchable: ['name', 'email'],
238
+ })
239
+ ```
240
+
241
+ The free-text term only routes anywhere when `searchable` is non-empty;
242
+ otherwise it is dropped with no error and rows come back unfiltered by search.
243
+
244
+ Source: docs/guides/filter-classes.mdx ("searchable enables free-text search")
245
+
246
+ See also: `../filter-safety/SKILL.md` (tightening the boundary before exposing
247
+ the endpoint publicly), `../filter-definitions/SKILL.md` (growing the policy
248
+ into a reusable spec).
@@ -0,0 +1,246 @@
1
+ ---
2
+ name: filter-codegen
3
+ description: >
4
+ Generate a typed @adonis-agora/filter-client builder from a defineFilter spec
5
+ in @adonis-agora/filter — generateFilterClient(spec, options) pure string
6
+ transform, generateFilterClients(manifest), the make:filter-client ace command
7
+ with a FilterClientManifest in config/filter.ts, emitted artifacts
8
+ (<Name>FilterFields union, <Name>FilterFieldTypes interface,
9
+ <Name>FilterFieldMeta, <Name>FilterQuery factory), fieldTypes operator
10
+ narrowing (kind | enumValues | typeRef | nullable), maxDepth/banner/clientModule
11
+ options, filterableFieldPaths/sortableFieldPaths enumerators, and adonisrc.ts
12
+ commands-barrel registration. Use when sharing one field allow-list between
13
+ browser and server, regenerating clients after spec changes, or emitting
14
+ enum-aware typed builders.
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/codegen/index.mdx"
22
+ - "DavideCarvalho/adonis-filter:docs/definitions/index.mdx"
23
+ ---
24
+
25
+ # Client codegen: one spec, both ends of the wire
26
+
27
+ A `defineFilter` spec already knows which fields are filterable. Codegen turns
28
+ that spec into a typed front-end module — a `filterQueryTyped<Fields, FieldTypes>()`
29
+ builder scoped to the spec's fields — so browser and server share one
30
+ field-name allow-list, checked at compile time. Generation is a pure function
31
+ of the spec: string in, string out, no AST walk, no reflection.
32
+
33
+ ## Setup
34
+
35
+ Register the command barrel, declare a manifest, run the command:
36
+
37
+ ```typescript
38
+ // adonisrc.ts
39
+ {
40
+ commands: [
41
+ // ...
42
+ () => import('@adonis-agora/filter/commands'),
43
+ ],
44
+ }
45
+ ```
46
+
47
+ ```typescript
48
+ // config/filter.ts
49
+ import { defineFilter } from '@adonis-agora/filter'
50
+ import type { FilterClientManifest } from '@adonis-agora/filter'
51
+
52
+ export const filters: FilterClientManifest = {
53
+ people: {
54
+ spec: defineFilter({
55
+ filterable: ['age', 'name', 'status'],
56
+ sortable: ['age', 'name'],
57
+ searchable: ['name'],
58
+ relations: { posts: { filterable: ['title', 'published'] } },
59
+ defaultSort: [{ field: 'name', direction: 'asc' }],
60
+ defaultSize: 25,
61
+ maxSize: 100,
62
+ }),
63
+ fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
64
+ },
65
+ }
66
+ ```
67
+
68
+ ```bash
69
+ node ace make:filter-client
70
+ # create app/generated/filters/people_filter_client.ts
71
+ ```
72
+
73
+ Defaults: entrypoint `config/filter.js`, output `app/generated/filters`. Pass
74
+ an explicit entrypoint/dir with
75
+ `node ace make:filter-client config/filter.js --output app/generated/filters`.
76
+
77
+ ## Core patterns
78
+
79
+ ### 1. The pure core — generateFilterClient
80
+
81
+ Skip the command entirely when you want to own the IO:
82
+
83
+ ```typescript
84
+ import { defineFilter, generateFilterClient } from '@adonis-agora/filter'
85
+
86
+ const spec = defineFilter({
87
+ filterable: ['age', 'name', 'status'],
88
+ sortable: ['age', 'name'],
89
+ })
90
+
91
+ const code = generateFilterClient(spec, {
92
+ name: 'people',
93
+ fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
94
+ })
95
+ // code is a TypeScript module string — write it wherever you like
96
+ ```
97
+
98
+ For `name: 'people'` the module exports: `type PeopleFilterFields` (union of
99
+ filterable paths — base plus relation-dotted), `interface PeopleFilterFieldTypes`
100
+ (only when `fieldTypes` supplied), `const peopleFilterMeta` (runtime metadata),
101
+ and `function peopleFilterQuery()` (a `filterQueryTyped` factory).
102
+
103
+ Options: `clientModule` (import specifier, default `@adonis-agora/filter-client`),
104
+ `maxDepth` (relation-path cap, defaults to `spec.maxDepth`), `banner`
105
+ (the DO NOT EDIT header, default `true`).
106
+
107
+ Source: `docs/codegen/index.mdx`
108
+
109
+ ### 2. Consume the generated client
110
+
111
+ Import the generated factory and get field-name-checked, operator-narrowed
112
+ queries:
113
+
114
+ ```typescript
115
+ // web/people-table.ts
116
+ import { peopleFilterQuery } from '#generated/filters/people_filter_client'
117
+
118
+ const qs = peopleFilterQuery()
119
+ .contains('name', 'Al') // 'name' checked against PeopleFilterFields
120
+ .gte('age', 18) // number ops via fieldTypes narrowing
121
+ .toQueryString()
122
+
123
+ await fetch(`/people?${qs}`)
124
+ ```
125
+
126
+ Source: `docs/codegen/index.mdx`
127
+
128
+ ### 3. fieldTypes drives operator narrowing
129
+
130
+ The spec carries the allow-list but not column value types (Lucid models are
131
+ not reflected). Supply kinds per field path to unlock type-aware narrowing:
132
+
133
+ | `FilterFieldTypeInfo` | Emits |
134
+ |---|---|
135
+ | `{ kind: 'string' \| 'number' \| 'boolean' \| 'date' \| 'json' }` | matching TS type |
136
+ | `{ enumValues: ['A', 'B'] }` | union `"A" \| "B"` (wins over `kind`) |
137
+ | `{ typeRef: 'Role' }` | named type verbatim (wins over everything) |
138
+ | `{ nullable: true }` | appends `\| null` |
139
+
140
+ Without `fieldTypes` the client stays field-name-safe but operator-permissive.
141
+ On the server side the same declaration powers value coercion — one
142
+ declaration, both ends.
143
+
144
+ Source: `docs/codegen/index.mdx`, `docs/definitions/index.mdx`
145
+
146
+ ### 4. Just enumerate the paths
147
+
148
+ `filterableFieldPaths(spec)` / `sortableFieldPaths(spec)` export the enumerated
149
+ field-path lists if you're building your own artifact:
150
+
151
+ ```typescript
152
+ import { filterableFieldPaths } from '@adonis-agora/filter'
153
+
154
+ const paths = filterableFieldPaths(peopleSpec)
155
+ // ['age', 'name', 'status', 'posts.title', 'posts.published']
156
+ ```
157
+
158
+ Source: `docs/codegen/index.mdx`
159
+
160
+ ## Common mistakes
161
+
162
+ ### [HIGH] Running make:filter-client with the commands barrel unregistered
163
+
164
+ Wrong:
165
+
166
+ ```typescript
167
+ // adonisrc.ts — no filter commands entry
168
+ { commands: [] }
169
+ ```
170
+
171
+ Correct:
172
+
173
+ ```typescript
174
+ { commands: [() => import('@adonis-agora/filter/commands')] }
175
+ ```
176
+
177
+ Ace only sees commands an app opts into; without the barrel the command fails
178
+ with "command not found", which reads as though it does not exist.
179
+ `node ace add @adonis-agora/filter` adds the entry for you.
180
+
181
+ Source: docs/codegen/index.mdx (Step 1 callout)
182
+
183
+ ### [HIGH] Hand-editing generated clients instead of regenerating
184
+
185
+ Wrong:
186
+
187
+ ```typescript
188
+ // renamed a spec field, then edited people_filter_client.ts by hand
189
+ ```
190
+
191
+ Correct:
192
+
193
+ ```bash
194
+ node ace make:filter-client # regenerate after every spec change
195
+ ```
196
+
197
+ Generated modules carry a DO NOT EDIT banner and are a pure function of the
198
+ spec; a stale client keeps sending fields the server now drops, so filters
199
+ silently stop applying with no error on either end.
200
+
201
+ Source: docs/codegen/index.mdx (banner option, Step 4)
202
+
203
+ ### [MEDIUM] Expecting codegen unions from a '*' allow-list
204
+
205
+ Wrong:
206
+
207
+ ```typescript
208
+ defineFilter({ filterable: ['*'] }) // PeopleFilterFields falls back to string
209
+ ```
210
+
211
+ Correct:
212
+
213
+ ```typescript
214
+ defineFilter({ filterable: ['age', 'name', 'status'] }) // enumerable literal union
215
+ ```
216
+
217
+ A star list cannot be enumerated, so the emitter degrades to a permissive
218
+ string union — the generated client looks typed but checks nothing.
219
+
220
+ Source: docs/codegen/index.mdx (filterableFieldPaths callout)
221
+
222
+ ### [MEDIUM] Skipping fieldTypes then expecting operator narrowing
223
+
224
+ Wrong:
225
+
226
+ ```typescript
227
+ generateFilterClient(spec, { name: 'people' })
228
+ peopleFilterQuery().gte('name', 5) // compiles — gte gated to orderable fields only
229
+ ```
230
+
231
+ Correct:
232
+
233
+ ```typescript
234
+ generateFilterClient(spec, {
235
+ name: 'people',
236
+ fieldTypes: { age: { kind: 'number' }, status: { enumValues: ['active', 'inactive'] } },
237
+ })
238
+ ```
239
+
240
+ Without a field-type map every field is kind-unknown, so all operators compile
241
+ and mismatches surface only server-side as drops or coercion errors.
242
+
243
+ Source: docs/codegen/index.mdx ("fieldTypes — unlock operator narrowing")
244
+
245
+ See also: `packages/client/skills/filter-query-builder/SKILL.md` (the runtime
246
+ the generated code targets), `../filter-definitions/SKILL.md` (spec authoring).