zod-paginate 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +489 -429
- package/dist/paginate.d.ts +8 -15
- package/dist/paginate.js +47 -16
- package/dist/paginate.js.map +1 -1
- package/dist/select.d.ts +73 -13
- package/dist/select.js +235 -33
- package/dist/select.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# zod-paginate
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
A small utility to **parse and validate pagination + select + sort + filters** from querystring-like objects using **Zod v4**, and to generate a **response validator** that automatically projects your `dataSchema` based on the requested `select`.
|
|
4
6
|
|
|
5
7
|
It is designed for Node.js HTTP stacks where query parameters arrive as strings (or string arrays). It outputs a **typed, normalized** structure you can map to your ORM/query builder.
|
|
6
8
|
|
|
7
|
-
- Supports **LIMIT/OFFSET pagination** (`limit` + `page`).
|
|
8
|
-
- Supports **CURSOR pagination** with cursor coercion based on `cursorProperty` (number / string / ISO date string).
|
|
9
|
-
- Supports **field projection** using `select`, including wildcard expansion (`*`) when enabled.
|
|
10
|
-
- Supports **sorting** with an allowlist of sortable fields.
|
|
11
|
-
- Supports a **filter DSL** with `$` operators and **nested AND/OR grouping**.
|
|
12
|
-
- Provides a **response validator** (`validatorSchema` / `responseSchema`) to validate API responses against the projected schema. `z.infer<typeof responseSchema>` gives you **key autocompletion** narrowed to configured `selectable` paths.
|
|
13
|
-
- Also exports a lightweight **`select()`** utility for field-projection-only use cases.
|
|
14
|
-
- Compatible with **OpenAPI tooling** ([zod-openapi](https://github.com/samchungy/zod-openapi) etc.).
|
|
15
|
-
|
|
16
9
|
> This library does **not** bind DB queries automatically.
|
|
17
10
|
> It gives you a safe parsed structure; you decide how to map it to your data layer.
|
|
18
11
|
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- **Field projection** using `select`, including wildcard expansion (`*`).
|
|
15
|
+
- **LIMIT/OFFSET pagination** (`limit` + `page`).
|
|
16
|
+
- **CURSOR pagination** with cursor coercion based on `cursorProperty` (number / string / ISO date string).
|
|
17
|
+
- **Sorting** with an allowlist of sortable fields.
|
|
18
|
+
- **Filter DSL** with `$` operators and **nested AND/OR grouping**.
|
|
19
|
+
- **Response validation** — `responseSchema` is a generic schema covering all possible responses based on your config; `validatorSchema(parsed)` validates outgoing data projected to the actual requested `select`. `z.infer<typeof responseSchema>` gives you **key autocompletion** narrowed to configured `selectable` paths.
|
|
20
|
+
- **Discriminated union support** — `z.discriminatedUnion()` and `z.union()` as `dataSchema`, with compile-time and runtime discriminator enforcement.
|
|
21
|
+
- **Standalone `select()`** utility for field-projection-only use cases.
|
|
22
|
+
- Compatible with **OpenAPI tooling** ([zod-openapi](https://github.com/samchungy/zod-openapi) etc.).
|
|
23
|
+
|
|
19
24
|
## Installation
|
|
20
25
|
|
|
21
26
|
```bash
|
|
@@ -28,6 +33,41 @@ yarn add zod-paginate
|
|
|
28
33
|
|
|
29
34
|
## Quick start
|
|
30
35
|
|
|
36
|
+
### Field projection with `select()`
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { z } from "zod";
|
|
40
|
+
import { select } from "zod-paginate";
|
|
41
|
+
|
|
42
|
+
const ProductSchema = z.object({
|
|
43
|
+
id: z.number(),
|
|
44
|
+
name: z.string(),
|
|
45
|
+
price: z.number(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { queryParamsSchema, validatorSchema, responseSchema } = select({
|
|
49
|
+
dataSchema: ProductSchema,
|
|
50
|
+
selectable: ["id", "name", "price"],
|
|
51
|
+
defaultSelect: ["id", "name"],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const parsed = queryParamsSchema().parse({ select: "id,name,price" });
|
|
55
|
+
// parsed.select → ["id", "name", "price"]
|
|
56
|
+
|
|
57
|
+
// Generic response schema — valid for all possible responses based on config
|
|
58
|
+
responseSchema.parse({
|
|
59
|
+
data: [{ id: 1, name: "Widget" }],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Outgoing data validator — projected to the actual requested select
|
|
63
|
+
const contextSchema = validatorSchema(parsed);
|
|
64
|
+
contextSchema.parse({
|
|
65
|
+
data: [{ id: 1, name: "Widget", price: 9.99 }],
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Full pagination with `paginate()`
|
|
70
|
+
|
|
31
71
|
```ts
|
|
32
72
|
import { z } from "zod";
|
|
33
73
|
import { paginate } from "zod-paginate";
|
|
@@ -60,7 +100,6 @@ const { queryParamsSchema, validatorSchema, responseSchema } = paginate({
|
|
|
60
100
|
defaultSelect: '*',
|
|
61
101
|
});
|
|
62
102
|
|
|
63
|
-
// Example querystring-like input
|
|
64
103
|
const parsed = queryParamsSchema().parse({
|
|
65
104
|
limit: "10",
|
|
66
105
|
page: "2",
|
|
@@ -71,447 +110,558 @@ const parsed = queryParamsSchema().parse({
|
|
|
71
110
|
|
|
72
111
|
console.log(parsed.pagination);
|
|
73
112
|
|
|
74
|
-
//
|
|
75
|
-
// z.infer<typeof responseSchema> narrows data keys to selectable paths
|
|
113
|
+
// Generic response schema — valid for all possible responses based on config
|
|
76
114
|
responseSchema.parse({
|
|
77
115
|
data: [{ id: 1, status: "active", createdAt: new Date(), meta: { score: 42 } }],
|
|
78
116
|
pagination: { itemsPerPage: 20, totalItems: 1, currentPage: 1, totalPages: 1 },
|
|
79
117
|
});
|
|
80
118
|
|
|
81
|
-
//
|
|
119
|
+
// Outgoing data validator — projected to the actual requested select
|
|
82
120
|
const contextSchema = validatorSchema(parsed.pagination);
|
|
121
|
+
contextSchema.parse({
|
|
122
|
+
data: [{ id: 1, status: "active" }],
|
|
123
|
+
pagination: { itemsPerPage: 10, totalItems: 1, currentPage: 1, totalPages: 1 },
|
|
124
|
+
});
|
|
83
125
|
```
|
|
84
126
|
|
|
85
|
-
##
|
|
127
|
+
## Summary
|
|
86
128
|
|
|
87
|
-
|
|
129
|
+
- [select() API](#select)
|
|
130
|
+
- [SelectConfig](#selectconfig)
|
|
131
|
+
- [responseType: 'one'](#responsetype-one)
|
|
132
|
+
- [paginate() API](#paginate)
|
|
133
|
+
- [PaginateConfig](#paginateconfig)
|
|
134
|
+
- [PaginateResult](#paginateresulttschema-tselectable-ttype)
|
|
135
|
+
- [Query parameters](#query-parameters)
|
|
136
|
+
- [limit](#limit) · [page](#page-limit_offset-only) · [cursor](#cursor-cursor-only) · [sortBy](#sortby) · [select](#select-1)
|
|
137
|
+
- [Response validation](#response-validation)
|
|
138
|
+
- [Filters](#filters)
|
|
139
|
+
- [Operators](#operators) · [Negation](#negation-not) · [Multiple conditions](#multiple-conditions-for-the-same-field)
|
|
140
|
+
- [Filter groups](#filter-groups)
|
|
141
|
+
- [Discriminated unions](#discriminated-unions)
|
|
142
|
+
- [Compile-time enforcement](#compile-time-enforcement-on-selectable) · [Runtime enforcement](#runtime-rejection-of-explicit-select-without-discriminator) · [Union-preserving validation](#union-preserving-response-validation)
|
|
143
|
+
- [Extending queryParamsSchema](#extending-queryparamsschema)
|
|
144
|
+
- [End-to-end examples](#end-to-end-examples)
|
|
145
|
+
- [TypeScript reference](#typescript-reference)
|
|
146
|
+
- [Adapters](#adapters)
|
|
88
147
|
|
|
89
|
-
|
|
90
|
-
|---|---|---|
|
|
91
|
-
| **zod-paginate-drizzle** | Drizzle ORM adapter — automatically maps parsed pagination, filters, sorting, and select to Drizzle queries. | [GitHub](https://github.com/nolway/zod-paginate-drizzle) |
|
|
148
|
+
## `select()`
|
|
92
149
|
|
|
93
|
-
|
|
150
|
+
Standalone **field projection** utility. Use it when you only need to parse a `select` query parameter and validate the response — no pagination, sorting, or filters.
|
|
94
151
|
|
|
95
|
-
|
|
152
|
+
```ts
|
|
153
|
+
import { select } from "zod-paginate";
|
|
154
|
+
```
|
|
96
155
|
|
|
97
156
|
Returns:
|
|
98
157
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
158
|
+
| Property | Description |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `queryParamsSchema(extraShape?)` | Zod schema to parse `{ select: "id,name" }` into `{ select: ["id", "name"] }`. |
|
|
161
|
+
| `validatorSchema(parsed?)` | Validates outgoing data projected to the actual requested `select`. Shape: `{ data: ProjectedItem[] }` (or `{ data: ProjectedItem }` when `responseType: 'one'`). |
|
|
162
|
+
| `responseSchema` | Generic `ZodObject` covering all possible responses based on your config. `z.infer<typeof responseSchema>` narrows data keys to `selectable` paths. |
|
|
163
|
+
|
|
164
|
+
### `SelectConfig`
|
|
165
|
+
|
|
166
|
+
| Option | Type | Description |
|
|
167
|
+
|---|---:|---|
|
|
168
|
+
| `dataSchema` | `z.ZodObject` \| `z.ZodDiscriminatedUnion` \| `z.ZodUnion` | Zod schema representing one data item. |
|
|
169
|
+
| `selectable` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths supported). |
|
|
170
|
+
| `defaultSelect` | `field[] \| "*"` | **Required.** Default when `select` is missing. `"*"` expands to `selectable`. |
|
|
171
|
+
| `responseType` | `"many" \| "one"` | Shape of `data` in the response (default: `"many"`). |
|
|
172
|
+
|
|
173
|
+
#### Example
|
|
102
174
|
|
|
103
175
|
```ts
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
TSchema extends DataSchema,
|
|
107
|
-
const TSelectable extends readonly AllowedPath<TSchema>[],
|
|
108
|
-
>(
|
|
109
|
-
config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & { paginationType: "LIMIT_OFFSET" },
|
|
110
|
-
): PaginateResult<TSchema, TSelectable[number], "LIMIT_OFFSET">;
|
|
176
|
+
import { z } from "zod";
|
|
177
|
+
import { select } from "zod-paginate";
|
|
111
178
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
179
|
+
const ProductSchema = z.object({
|
|
180
|
+
id: z.number(),
|
|
181
|
+
name: z.string(),
|
|
182
|
+
price: z.number(),
|
|
183
|
+
details: z.object({
|
|
184
|
+
weight: z.number(),
|
|
185
|
+
color: z.string(),
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const { queryParamsSchema, validatorSchema, responseSchema } = select({
|
|
190
|
+
dataSchema: ProductSchema,
|
|
191
|
+
selectable: ["id", "name", "price", "details.weight", "details.color"],
|
|
192
|
+
defaultSelect: ["id", "name", "price"],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// select=* expands to all selectable fields
|
|
196
|
+
const parsed = queryParamsSchema().parse({ select: "*" });
|
|
197
|
+
// parsed.select → ["id", "name", "price", "details.weight", "details.color"]
|
|
198
|
+
|
|
199
|
+
// Specific fields
|
|
200
|
+
const parsed2 = queryParamsSchema().parse({ select: "id,name,details.color" });
|
|
201
|
+
// parsed2.select → ["id", "name", "details.color"]
|
|
120
202
|
|
|
121
|
-
|
|
203
|
+
// Generic response schema (based on defaultSelect)
|
|
204
|
+
responseSchema.parse({
|
|
205
|
+
data: [{ id: 1, name: "Widget", price: 9.99 }],
|
|
206
|
+
});
|
|
122
207
|
|
|
123
|
-
|
|
208
|
+
// Outgoing data validator projected to the actual requested select
|
|
209
|
+
const contextSchema = validatorSchema(parsed2);
|
|
210
|
+
contextSchema.parse({
|
|
211
|
+
data: [
|
|
212
|
+
{ id: 1, name: "Widget", details: { color: "red" } },
|
|
213
|
+
{ id: 2, name: "Gadget", details: { color: "blue" } },
|
|
214
|
+
],
|
|
215
|
+
});
|
|
124
216
|
|
|
125
|
-
|
|
217
|
+
// Missing select → uses defaultSelect
|
|
218
|
+
const parsed3 = queryParamsSchema().parse({});
|
|
219
|
+
// parsed3.select → ["id", "name", "price"]
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Use `SelectResult<TSchema, TSelectable>` instead of `ReturnType<typeof select>` for explicit return types:
|
|
126
223
|
|
|
127
224
|
```ts
|
|
128
|
-
import {
|
|
225
|
+
import { select, type SelectResult } from "zod-paginate";
|
|
129
226
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return paginate({
|
|
133
|
-
paginationType: "LIMIT_OFFSET",
|
|
134
|
-
dataSchema: ModelSchema,
|
|
135
|
-
selectable: ["id", "status"],
|
|
136
|
-
/* … */
|
|
137
|
-
});
|
|
227
|
+
function createSelector(): SelectResult<typeof ProductSchema, "id" | "name" | "price"> {
|
|
228
|
+
return select({ dataSchema: ProductSchema, selectable: ["id", "name", "price"], /* … */ });
|
|
138
229
|
}
|
|
230
|
+
```
|
|
139
231
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
232
|
+
### `responseType: 'one'`
|
|
233
|
+
|
|
234
|
+
By default, `select()` validates `data` as an array. Pass `responseType: 'one'` to validate a single item instead:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
const { responseSchema } = select({
|
|
238
|
+
dataSchema: ProductSchema,
|
|
239
|
+
selectable: ["id", "name", "price"],
|
|
240
|
+
defaultSelect: ["id", "name"],
|
|
241
|
+
responseType: "one",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Single object → valid
|
|
245
|
+
responseSchema.parse({ data: { id: 1, name: "Widget" } });
|
|
246
|
+
|
|
247
|
+
// Array → rejected
|
|
248
|
+
responseSchema.parse({ data: [{ id: 1 }] }); // throws
|
|
144
249
|
```
|
|
145
250
|
|
|
146
|
-
##
|
|
251
|
+
## `paginate()`
|
|
252
|
+
|
|
253
|
+
Full pagination, sorting, filtering, and field projection. Extends everything `select()` does with pagination support.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { paginate } from "zod-paginate";
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
|
|
261
|
+
| Property | Description |
|
|
262
|
+
|---|---|
|
|
263
|
+
| `queryParamsSchema(extraShape?)` | Zod schema to parse query objects (strings / string arrays). |
|
|
264
|
+
| `validatorSchema(parsed?)` | Validates outgoing data projected to the actual requested `select`. |
|
|
265
|
+
| `responseSchema` | Generic `ZodObject` covering all possible responses based on your config. `z.infer<typeof responseSchema>` narrows both data keys and pagination metadata. |
|
|
266
|
+
|
|
267
|
+
### `PaginateConfig`
|
|
147
268
|
|
|
148
269
|
| Option | Type | Description |
|
|
149
270
|
|---|---:|---|
|
|
150
|
-
| `paginationType` | `"LIMIT_OFFSET"` \| `"CURSOR"` |
|
|
151
|
-
| `dataSchema` | `z.ZodObject` | Zod schema
|
|
152
|
-
| `selectable?` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths
|
|
271
|
+
| `paginationType` | `"LIMIT_OFFSET"` \| `"CURSOR"` | Pagination mode. |
|
|
272
|
+
| `dataSchema` | `z.ZodObject` \| `z.ZodDiscriminatedUnion` \| `z.ZodUnion` | Zod schema for one data item (used for projection + cursor inference). |
|
|
273
|
+
| `selectable?` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths). Enables `select`. |
|
|
153
274
|
| `sortable?` | `string[]` (typed paths) | Allowlist of sortable fields. Enables `sortBy`. |
|
|
154
275
|
| `filterable?` | object | Allowlist of filterable fields and allowed operators + field type. |
|
|
155
276
|
| `defaultSortBy?` | `{ property, direction }[]` | Default sort if `sortBy` missing/empty. |
|
|
156
277
|
| `defaultLimit` | `number` | **Required.** Default limit if `limit` missing. |
|
|
157
278
|
| `maxLimit` | `number` | **Required.** Rejects `limit` values above this. |
|
|
158
279
|
| `defaultSelect` | `field[] \| "*"` | **Required.** Default select if `select` missing. `"*"` expands to `selectable`. |
|
|
159
|
-
| `cursorProperty` | (CURSOR only) typed path |
|
|
160
|
-
|
|
161
|
-
### Extending `queryParamsSchema`
|
|
162
|
-
|
|
163
|
-
`queryParamsSchema` accepts an optional Zod shape to add extra fields **as sibling properties** of `pagination` in the parsed output:
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
// Without extra — base schema
|
|
167
|
-
const parsed = queryParamsSchema().parse({ limit: "10", page: "1" });
|
|
168
|
-
// parsed.pagination → { type: "LIMIT_OFFSET", limit: 10, page: 1, … }
|
|
280
|
+
| `cursorProperty` | (CURSOR only) typed path | Field used for cursor paging. Cursor type is inferred from `dataSchema`. |
|
|
169
281
|
|
|
170
|
-
|
|
171
|
-
const parsed = queryParamsSchema({
|
|
172
|
-
search: z.string().optional(),
|
|
173
|
-
locale: z.enum(["en", "fr"]).default("en"),
|
|
174
|
-
}).parse({ limit: "10", search: "alice", locale: "fr" });
|
|
175
|
-
// parsed.pagination → { type: "LIMIT_OFFSET", limit: 10, … }
|
|
176
|
-
// parsed.search → "alice"
|
|
177
|
-
// parsed.locale → "fr"
|
|
178
|
-
```
|
|
282
|
+
### `PaginateResult<TSchema, TSelectable?, TType?>`
|
|
179
283
|
|
|
180
|
-
|
|
284
|
+
Use `PaginateResult<TSchema, TSelectable, TType>` instead of `ReturnType<typeof paginate>` when you need an explicit return type — it preserves the generics so that `z.infer<typeof responseSchema>` correctly narrows both data keys and pagination metadata.
|
|
181
285
|
|
|
182
|
-
|
|
286
|
+
**`TType`** (`'LIMIT_OFFSET' | 'CURSOR'`): When specified, narrows the response types so you get `totalItems`/`totalPages` (LIMIT_OFFSET) or `cursor` (CURSOR) without manual narrowing.
|
|
183
287
|
|
|
184
288
|
```ts
|
|
185
|
-
|
|
186
|
-
const parsed = queryParamsSchema({ search: z.string().optional() }).parse({
|
|
187
|
-
select: "id,name",
|
|
188
|
-
search: "widget",
|
|
189
|
-
});
|
|
190
|
-
// parsed.select → ["id", "name"]
|
|
191
|
-
// parsed.search → "widget"
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
## Query input shape
|
|
289
|
+
import { paginate, type PaginateResult } from "zod-paginate";
|
|
195
290
|
|
|
196
|
-
|
|
291
|
+
function createPaginator(): PaginateResult<typeof ModelSchema, "id" | "status", "LIMIT_OFFSET"> {
|
|
292
|
+
return paginate({
|
|
293
|
+
paginationType: "LIMIT_OFFSET",
|
|
294
|
+
dataSchema: ModelSchema,
|
|
295
|
+
selectable: ["id", "status"],
|
|
296
|
+
/* … */
|
|
297
|
+
});
|
|
298
|
+
}
|
|
197
299
|
|
|
198
|
-
|
|
199
|
-
|
|
300
|
+
// Without TType — pagination metadata is a union, but data keys are still narrowed
|
|
301
|
+
function createPaginatorUnion(): PaginateResult<typeof ModelSchema, "id" | "status"> {
|
|
302
|
+
return paginate({ dataSchema: ModelSchema, selectable: ["id", "status"], /* … */ });
|
|
303
|
+
}
|
|
200
304
|
```
|
|
201
305
|
|
|
202
|
-
Typical querystring parsers produce values like:
|
|
203
|
-
|
|
204
|
-
- `"10"` (string)
|
|
205
|
-
- `["a", "b"]` (repeated query params)
|
|
206
|
-
- everything else is ignored / treated as undefined
|
|
207
|
-
|
|
208
306
|
## Query parameters
|
|
209
307
|
|
|
308
|
+
`queryParamsSchema()` accepts any `Record<string, unknown>` input. Typical querystring parsers produce `"10"` (string) or `["a", "b"]` (repeated params).
|
|
309
|
+
|
|
210
310
|
### `limit`
|
|
211
311
|
|
|
212
|
-
- Input
|
|
213
|
-
- Output
|
|
214
|
-
-
|
|
215
|
-
- Must be a numeric string
|
|
216
|
-
- Must be `<= maxLimit` if configured
|
|
217
|
-
- Falls back to `defaultLimit` when missing
|
|
312
|
+
- **Input:** string numeric (e.g. `"10"`)
|
|
313
|
+
- **Output:** number
|
|
314
|
+
- Must be `<= maxLimit`; falls back to `defaultLimit` when missing.
|
|
218
315
|
|
|
219
316
|
### `page` (LIMIT_OFFSET only)
|
|
220
317
|
|
|
221
|
-
- Input
|
|
222
|
-
- Output
|
|
223
|
-
-
|
|
224
|
-
- Only valid when `paginationType: "LIMIT_OFFSET"`
|
|
225
|
-
- Forbidden in CURSOR mode
|
|
318
|
+
- **Input:** string numeric (e.g. `"2"`)
|
|
319
|
+
- **Output:** number
|
|
320
|
+
- Only valid when `paginationType: "LIMIT_OFFSET"`. Forbidden in CURSOR mode.
|
|
226
321
|
|
|
227
322
|
### `cursor` (CURSOR only)
|
|
228
323
|
|
|
229
|
-
- Input
|
|
230
|
-
- Output
|
|
231
|
-
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
324
|
+
- **Input:** string
|
|
325
|
+
- **Output:** `number | string` (coerced)
|
|
326
|
+
- Coerced based on the Zod type of `cursorProperty` in `dataSchema`:
|
|
327
|
+
|
|
328
|
+
| `cursorProperty` type | Input | Output |
|
|
329
|
+
|---|---|---|
|
|
330
|
+
| `z.number()` | `"123"` | `123` (integer) |
|
|
331
|
+
| `z.string()` | `"abc"` | `"abc"` |
|
|
332
|
+
| `z.date()` | `"2022-01-01"` | `"2022-01-01"` (ISO string) |
|
|
238
333
|
|
|
239
334
|
### `sortBy`
|
|
240
335
|
|
|
241
|
-
- Input
|
|
242
|
-
- Output
|
|
243
|
-
-
|
|
244
|
-
|
|
245
|
-
- Format: `field:ASC` or `field:DESC`
|
|
246
|
-
- Empty items are ignored
|
|
247
|
-
- If missing (or becomes empty after cleanup), falls back to `defaultSortBy` if configured
|
|
248
|
-
- Properties are matched against the allowlist (unknown fields are dropped)
|
|
336
|
+
- **Input:** string or string[] — format: `field:ASC` or `field:DESC`
|
|
337
|
+
- **Output:** `[{ property, direction }]`
|
|
338
|
+
- Properties are matched against the `sortable` allowlist (unknown fields are dropped).
|
|
339
|
+
- Falls back to `defaultSortBy` when missing.
|
|
249
340
|
|
|
250
341
|
### `select`
|
|
251
342
|
|
|
252
|
-
- Input
|
|
253
|
-
- Output
|
|
254
|
-
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
- `*` expands to the configured `selectable` allowlist
|
|
258
|
-
- If missing, falls back to `defaultSelect` if configured
|
|
259
|
-
- `select=` (empty) is rejected
|
|
260
|
-
- Unknown fields are rejected at parse-time (strict allowlist)
|
|
343
|
+
- **Input:** comma-separated string (e.g. `"id,name,meta.score"`)
|
|
344
|
+
- **Output:** string[] (typed paths)
|
|
345
|
+
- `*` expands to the `selectable` allowlist.
|
|
346
|
+
- Falls back to `defaultSelect` when missing.
|
|
347
|
+
- `select=` (empty) is rejected. Unknown fields are rejected (strict allowlist).
|
|
261
348
|
|
|
262
|
-
##
|
|
349
|
+
## Response validation
|
|
263
350
|
|
|
264
|
-
|
|
351
|
+
Both `select()` and `paginate()` return tools to validate your API response.
|
|
265
352
|
|
|
266
|
-
|
|
267
|
-
filter.<field>=<dsl>
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
Where `<field>` is a dot-path field (example: `meta.score`).
|
|
353
|
+
### `responseSchema` — generic response schema
|
|
271
354
|
|
|
272
|
-
|
|
355
|
+
Covers all possible responses based on your config (uses `defaultSelect` or all selectable fields). Ideal for OpenAPI schema generation, static validation, or type inference:
|
|
273
356
|
|
|
274
|
-
|
|
357
|
+
```ts
|
|
358
|
+
const { responseSchema } = paginate({
|
|
359
|
+
paginationType: "LIMIT_OFFSET",
|
|
360
|
+
dataSchema: ModelSchema,
|
|
361
|
+
selectable: ["id", "status", "createdAt", "meta.score"],
|
|
362
|
+
defaultSelect: '*',
|
|
363
|
+
defaultLimit: 20,
|
|
364
|
+
maxLimit: 100,
|
|
365
|
+
});
|
|
275
366
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
| `$in` | in list | `a,b,c` (comma-separated) |
|
|
281
|
-
| `$contains` | contains values | `a,b,c` (comma-separated) |
|
|
282
|
-
| `$gt` | greater than | number or ISO date |
|
|
283
|
-
| `$gte` | greater than or equal | number or ISO date |
|
|
284
|
-
| `$lt` | less than | number or ISO date |
|
|
285
|
-
| `$lte` | less than or equal | number or ISO date |
|
|
286
|
-
| `$btw` | between | `a,b` where both are numbers OR both are ISO dates |
|
|
287
|
-
| `$ilike` | case-insensitive contains (string) | string |
|
|
288
|
-
| `$sw` | starts with (string) | string |
|
|
289
|
-
|
|
290
|
-
#### `$eq` — equals
|
|
291
|
-
|
|
292
|
-
Matches rows where the field is exactly equal to the given value. The value type must match the field type (number, string, or ISO date).
|
|
367
|
+
responseSchema.parse({
|
|
368
|
+
data: [{ id: 1, status: "active", createdAt: new Date(), meta: { score: 42 } }],
|
|
369
|
+
pagination: { itemsPerPage: 20, totalItems: 1, currentPage: 1, totalPages: 1 },
|
|
370
|
+
});
|
|
293
371
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
372
|
+
// Type-safe: z.infer narrows data keys to selectable paths
|
|
373
|
+
type Response = z.infer<typeof responseSchema>;
|
|
374
|
+
// Response["data"][0] → { id?: unknown; status?: unknown; createdAt?: unknown; meta?: unknown }
|
|
375
|
+
// Response["pagination"].totalItems → number ✓ (no manual narrowing)
|
|
298
376
|
```
|
|
299
377
|
|
|
300
|
-
|
|
378
|
+
### `validatorSchema(parsed?)` — outgoing data validator
|
|
301
379
|
|
|
302
|
-
|
|
380
|
+
Validates outgoing data projected to the actual `select` requested by the client:
|
|
303
381
|
|
|
304
|
-
```
|
|
305
|
-
|
|
382
|
+
```ts
|
|
383
|
+
const parsed = queryParamsSchema().parse({ select: "id,status", limit: "10", page: "1" });
|
|
384
|
+
const contextSchema = validatorSchema(parsed.pagination);
|
|
385
|
+
|
|
386
|
+
contextSchema.parse({
|
|
387
|
+
data: [{ id: 1, status: "active" }],
|
|
388
|
+
pagination: { itemsPerPage: 10, totalItems: 1, currentPage: 1, totalPages: 1 },
|
|
389
|
+
});
|
|
306
390
|
```
|
|
307
391
|
|
|
308
|
-
|
|
392
|
+
### Expected response shape
|
|
309
393
|
|
|
310
|
-
|
|
311
|
-
filter.deletedAt=$not:$null
|
|
312
|
-
```
|
|
394
|
+
**LIMIT/OFFSET:**
|
|
313
395
|
|
|
314
|
-
|
|
396
|
+
```ts
|
|
397
|
+
{
|
|
398
|
+
data: Array<ProjectedItem>,
|
|
399
|
+
pagination: {
|
|
400
|
+
itemsPerPage: number,
|
|
401
|
+
totalItems: number,
|
|
402
|
+
currentPage: number,
|
|
403
|
+
totalPages: number,
|
|
404
|
+
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
|
|
405
|
+
filter?: WhereNode
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
```
|
|
315
409
|
|
|
316
|
-
|
|
410
|
+
**CURSOR:**
|
|
317
411
|
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
|
|
412
|
+
```ts
|
|
413
|
+
{
|
|
414
|
+
data: Array<ProjectedItem>,
|
|
415
|
+
pagination: {
|
|
416
|
+
itemsPerPage: number,
|
|
417
|
+
cursor: number | string | Date,
|
|
418
|
+
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
|
|
419
|
+
filter?: WhereNode
|
|
420
|
+
}
|
|
421
|
+
}
|
|
321
422
|
```
|
|
322
423
|
|
|
323
|
-
|
|
424
|
+
## Filters
|
|
324
425
|
|
|
325
|
-
|
|
426
|
+
Filters use query keys with the pattern `filter.<field>=<dsl>` where `<field>` is a dot-path (e.g. `meta.score`). Configure which fields and operators are allowed via the `filterable` option.
|
|
326
427
|
|
|
327
|
-
|
|
328
|
-
filter.tags=$contains:typescript,zod
|
|
329
|
-
filter.roles=$contains:admin
|
|
330
|
-
```
|
|
428
|
+
### Operators
|
|
331
429
|
|
|
332
|
-
|
|
430
|
+
| Operator | Meaning | Value format | Example |
|
|
431
|
+
|---|---|---|---|
|
|
432
|
+
| `$eq` | equals | number / string / ISO date | `filter.status=$eq:active` |
|
|
433
|
+
| `$null` | is null | _(no value)_ | `filter.deletedAt=$null` |
|
|
434
|
+
| `$in` | in list | comma-separated | `filter.status=$in:active,pending` |
|
|
435
|
+
| `$contains` | contains values | comma-separated | `filter.tags=$contains:typescript,zod` |
|
|
436
|
+
| `$gt` | greater than | number or ISO date | `filter.id=$gt:100` |
|
|
437
|
+
| `$gte` | greater than or equal | number or ISO date | `filter.createdAt=$gte:2025-01-01` |
|
|
438
|
+
| `$lt` | less than | number or ISO date | `filter.id=$lt:500` |
|
|
439
|
+
| `$lte` | less than or equal | number or ISO date | `filter.id=$lte:500` |
|
|
440
|
+
| `$btw` | between (inclusive) | `a,b` (same type) | `filter.id=$btw:10,100` |
|
|
441
|
+
| `$ilike` | case-insensitive contains | string | `filter.name=$ilike:john` |
|
|
442
|
+
| `$sw` | starts with | string | `filter.name=$sw:Jon` |
|
|
443
|
+
|
|
444
|
+
If the filter value does **not** start with `$`, it is interpreted as `$eq:<value>`.
|
|
333
445
|
|
|
334
|
-
|
|
446
|
+
### Negation: `$not`
|
|
447
|
+
|
|
448
|
+
Prefix any operator with `$not:` to negate the condition:
|
|
335
449
|
|
|
336
450
|
```txt
|
|
337
|
-
filter.
|
|
338
|
-
filter.
|
|
339
|
-
filter.createdAt=$gte:2025-01-01
|
|
340
|
-
filter.createdAt=$lt:2025-06-01T00:00:00Z
|
|
451
|
+
filter.deletedAt=$not:$null
|
|
452
|
+
filter.status=$not:$eq:active
|
|
341
453
|
```
|
|
342
454
|
|
|
343
|
-
|
|
455
|
+
### Multiple conditions for the same field
|
|
456
|
+
|
|
457
|
+
Use repeated query params or pass an array:
|
|
344
458
|
|
|
345
459
|
```txt
|
|
346
460
|
filter.id=$gt:10&filter.id=$lt:100
|
|
347
461
|
```
|
|
348
462
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
Matches rows where the field value falls between two bounds (inclusive). Both bounds must be the same type — either both numbers or both ISO dates.
|
|
352
|
-
|
|
353
|
-
```txt
|
|
354
|
-
filter.id=$btw:10,100
|
|
355
|
-
filter.createdAt=$btw:2025-01-01,2025-12-31
|
|
356
|
-
filter.createdAt=$btw:2025-01-01T00:00:00Z,2025-06-30T23:59:59Z
|
|
463
|
+
```ts
|
|
464
|
+
{ "filter.id": ["$gt:10", "$lt:100"] }
|
|
357
465
|
```
|
|
358
466
|
|
|
359
|
-
|
|
467
|
+
Runtime validation enforces: field allowlist (`filterable`), operator allowlist per field (`ops`), and value type compatibility.
|
|
360
468
|
|
|
361
|
-
|
|
469
|
+
## Filter groups
|
|
362
470
|
|
|
363
|
-
|
|
364
|
-
filter.status=$ilike:act
|
|
365
|
-
filter.name=$ilike:john
|
|
366
|
-
filter.email=$ilike:@example.com
|
|
367
|
-
```
|
|
471
|
+
Groups let you build nested AND/OR boolean logic.
|
|
368
472
|
|
|
369
|
-
|
|
473
|
+
### Assigning conditions to a group: `$g:<id>`
|
|
370
474
|
|
|
371
|
-
|
|
475
|
+
Prefix any filter DSL with `$g:<groupId>:`:
|
|
372
476
|
|
|
373
477
|
```txt
|
|
374
|
-
filter.
|
|
375
|
-
filter.email=$sw:admin@
|
|
376
|
-
filter.path=$sw:/api/v2
|
|
478
|
+
filter.status=$g:1:$eq:active
|
|
377
479
|
```
|
|
378
480
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
1) field allowlist (`filterable`)
|
|
382
|
-
2) operator allowlist per field (`ops`)
|
|
383
|
-
3) value type compatibility (number vs date vs string)
|
|
384
|
-
|
|
385
|
-
### Default operator: `$eq`
|
|
481
|
+
Within a group, the **first** condition cannot have `$and`/`$or`. Following conditions may be prefixed with `$and` or `$or`.
|
|
386
482
|
|
|
387
|
-
|
|
483
|
+
### Group tree definitions: `group.<id>.*`
|
|
388
484
|
|
|
389
|
-
|
|
485
|
+
Define parent-child relationships between groups:
|
|
390
486
|
|
|
391
|
-
|
|
487
|
+
- `group.<id>.parent` — parent group id (integer string)
|
|
488
|
+
- `group.<id>.join` — how this group joins its parent (`$and` or `$or`)
|
|
489
|
+
- `group.<id>.op` — default join for this group's children (optional)
|
|
392
490
|
|
|
393
|
-
|
|
491
|
+
Rules: root group id is always `"0"`. `group.0.parent` and `group.0.join` are forbidden. Cycles are rejected. Child groups are resolved in numeric order.
|
|
394
492
|
|
|
395
|
-
|
|
396
|
-
filter.createdAt=$not:$null
|
|
397
|
-
filter.status=$not:$eq:active
|
|
398
|
-
```
|
|
493
|
+
**Example:** `(status == active OR status == postponed) AND (id > 10)`
|
|
399
494
|
|
|
400
|
-
|
|
495
|
+
```ts
|
|
496
|
+
const parsed = queryParamsSchema().parse({
|
|
497
|
+
"filter.status": ["$g:1:$eq:active", "$g:1:$or:$eq:postponed"],
|
|
498
|
+
"filter.id": "$g:2:$gt:10",
|
|
401
499
|
|
|
402
|
-
|
|
500
|
+
"group.1.parent": "0",
|
|
501
|
+
"group.2.parent": "0",
|
|
502
|
+
"group.2.join": "$and",
|
|
503
|
+
});
|
|
403
504
|
|
|
404
|
-
|
|
405
|
-
|
|
505
|
+
// parsed.pagination.filters
|
|
506
|
+
// {
|
|
507
|
+
// type: "and",
|
|
508
|
+
// items: [
|
|
509
|
+
// { type: "or", items: [ ...status filters... ] },
|
|
510
|
+
// { type: "filter", field: "id", condition: { op: "$gt", value: 10, ... } }
|
|
511
|
+
// ]
|
|
512
|
+
// }
|
|
406
513
|
```
|
|
407
514
|
|
|
408
|
-
|
|
515
|
+
## Discriminated unions
|
|
516
|
+
|
|
517
|
+
Both `select()` and `paginate()` accept `z.discriminatedUnion()` (or `z.union()` of objects) as `dataSchema`. The `selectable` paths are typed as the union of all member paths.
|
|
409
518
|
|
|
410
519
|
```ts
|
|
411
|
-
{
|
|
412
|
-
|
|
413
|
-
|
|
520
|
+
const Codec1 = z.object({ id: z.number(), name: z.string() });
|
|
521
|
+
const Codec2 = z.object({ id: z.number(), title: z.string() });
|
|
522
|
+
const UnionSchema = z.discriminatedUnion("type", [
|
|
523
|
+
Codec1.extend({ type: z.literal("codec1") }),
|
|
524
|
+
Codec2.extend({ type: z.literal("codec2") }),
|
|
525
|
+
]);
|
|
526
|
+
const VideoSchema = z.object({ type: z.literal("video"), id: z.number(), duration: z.number(), codec: UnionSchema });
|
|
527
|
+
const AudioSchema = z.object({ type: z.literal("audio"), id: z.number(), bitrate: z.number() });
|
|
528
|
+
const MediaSchema = z.discriminatedUnion("type", [VideoSchema, AudioSchema]);
|
|
414
529
|
```
|
|
415
530
|
|
|
416
|
-
|
|
531
|
+
### Compile-time enforcement on `selectable`
|
|
417
532
|
|
|
418
|
-
|
|
533
|
+
The discriminator field **must** be present in `selectable`. Omitting it is a TypeScript error:
|
|
419
534
|
|
|
420
|
-
|
|
535
|
+
```ts
|
|
536
|
+
// ✗ Compile error — "type" is missing from selectable
|
|
537
|
+
select({
|
|
538
|
+
dataSchema: MediaSchema,
|
|
539
|
+
selectable: ["id", "name"], // ← TypeScript error
|
|
540
|
+
defaultSelect: "*",
|
|
541
|
+
});
|
|
421
542
|
|
|
422
|
-
|
|
423
|
-
|
|
543
|
+
// ✓ OK — "type" is included
|
|
544
|
+
select({
|
|
545
|
+
dataSchema: MediaSchema,
|
|
546
|
+
selectable: ["id", "name", "type"],
|
|
547
|
+
defaultSelect: "*",
|
|
548
|
+
});
|
|
549
|
+
```
|
|
424
550
|
|
|
425
|
-
|
|
551
|
+
The same applies to `paginate()`:
|
|
426
552
|
|
|
427
|
-
|
|
553
|
+
```ts
|
|
554
|
+
// ✗ Compile error
|
|
555
|
+
paginate({
|
|
556
|
+
paginationType: "LIMIT_OFFSET",
|
|
557
|
+
dataSchema: MediaSchema,
|
|
558
|
+
selectable: ["id", "duration"], // ← missing "type"
|
|
559
|
+
defaultSelect: "*",
|
|
560
|
+
defaultLimit: 20,
|
|
561
|
+
maxLimit: 100,
|
|
562
|
+
});
|
|
428
563
|
|
|
429
|
-
|
|
430
|
-
|
|
564
|
+
// ✓ OK
|
|
565
|
+
paginate({
|
|
566
|
+
paginationType: "LIMIT_OFFSET",
|
|
567
|
+
dataSchema: MediaSchema,
|
|
568
|
+
selectable: ["id", "type", "duration"],
|
|
569
|
+
defaultSelect: ["id", "type"],
|
|
570
|
+
defaultLimit: 20,
|
|
571
|
+
maxLimit: 100,
|
|
572
|
+
});
|
|
431
573
|
```
|
|
432
574
|
|
|
433
|
-
###
|
|
575
|
+
### Runtime rejection of explicit `select` without discriminator
|
|
434
576
|
|
|
435
|
-
|
|
577
|
+
Even though the type system ensures the discriminator is in `selectable`, a client could still send a `select` query that omits it. This is rejected at runtime:
|
|
436
578
|
|
|
437
|
-
|
|
579
|
+
```ts
|
|
580
|
+
const { queryParamsSchema } = select({
|
|
581
|
+
dataSchema: MediaSchema,
|
|
582
|
+
selectable: ["id", "type", "duration", "bitrate"],
|
|
583
|
+
defaultSelect: ["id", "type"],
|
|
584
|
+
});
|
|
438
585
|
|
|
439
|
-
|
|
586
|
+
// ✗ Missing "type" → validation error
|
|
587
|
+
queryParamsSchema().safeParse({ select: "id,duration" });
|
|
588
|
+
// → 'select must include the discriminator field "type" when using a discriminated union'
|
|
440
589
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
590
|
+
// ✓ select=* always works — expands to all selectable fields including the discriminator
|
|
591
|
+
queryParamsSchema().parse({ select: "*" });
|
|
592
|
+
// → ["id", "type", "duration", "bitrate"]
|
|
444
593
|
|
|
445
|
-
|
|
594
|
+
// ✓ Including the discriminator explicitly
|
|
595
|
+
queryParamsSchema().parse({ select: "id,type,duration" });
|
|
596
|
+
// → ["id", "type", "duration"]
|
|
597
|
+
```
|
|
446
598
|
|
|
447
|
-
-
|
|
448
|
-
- `group.0.parent` and `group.0.join` are forbidden.
|
|
449
|
-
- Cycles are rejected.
|
|
450
|
-
- Child groups are resolved in numeric order (deterministic).
|
|
599
|
+
### Union-preserving response validation
|
|
451
600
|
|
|
452
|
-
|
|
601
|
+
When using a discriminated union, `validatorSchema` and `responseSchema` preserve the union structure — each option is projected independently. A response item only needs to match **one** of the union options:
|
|
453
602
|
|
|
454
|
-
|
|
603
|
+
```ts
|
|
604
|
+
const { queryParamsSchema, validatorSchema } = select({
|
|
605
|
+
dataSchema: MediaSchema,
|
|
606
|
+
selectable: ["id", "type", "duration", "bitrate"],
|
|
607
|
+
defaultSelect: "*",
|
|
608
|
+
});
|
|
455
609
|
|
|
456
|
-
|
|
610
|
+
const parsed = queryParamsSchema().parse({ select: "id,type,duration,bitrate" });
|
|
611
|
+
const schema = validatorSchema(parsed);
|
|
457
612
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
- Enforces mode-specific pagination metadata shape.
|
|
613
|
+
// ✓ Video item — matches VideoSchema option
|
|
614
|
+
schema.parse({ data: [{ id: 1, type: "video", duration: 120 }] });
|
|
461
615
|
|
|
462
|
-
|
|
616
|
+
// ✓ Audio item — matches AudioSchema option
|
|
617
|
+
schema.parse({ data: [{ id: 2, type: "audio", bitrate: 320 }] });
|
|
463
618
|
|
|
464
|
-
|
|
619
|
+
// ✓ Mixed array — each item matches its own option
|
|
620
|
+
schema.parse({
|
|
621
|
+
data: [
|
|
622
|
+
{ id: 1, type: "video", duration: 120 },
|
|
623
|
+
{ id: 2, type: "audio", bitrate: 320 },
|
|
624
|
+
],
|
|
625
|
+
});
|
|
465
626
|
|
|
466
|
-
|
|
467
|
-
{
|
|
468
|
-
|
|
469
|
-
pagination: {
|
|
470
|
-
itemsPerPage: number,
|
|
471
|
-
totalItems: number,
|
|
472
|
-
currentPage: number,
|
|
473
|
-
totalPages: number,
|
|
474
|
-
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
|
|
475
|
-
filter?: WhereNode
|
|
476
|
-
}
|
|
477
|
-
}
|
|
627
|
+
// ✗ Invalid — type "video" with bitrate instead of duration
|
|
628
|
+
schema.safeParse({ data: [{ id: 1, type: "video", bitrate: 320 }] });
|
|
629
|
+
// → fails validation
|
|
478
630
|
```
|
|
479
631
|
|
|
480
|
-
|
|
632
|
+
## Extending `queryParamsSchema`
|
|
481
633
|
|
|
482
|
-
|
|
483
|
-
{
|
|
484
|
-
data: Array<ProjectedItem>,
|
|
485
|
-
pagination: {
|
|
486
|
-
itemsPerPage: number,
|
|
487
|
-
cursor: number | string | Date,
|
|
488
|
-
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
|
|
489
|
-
filter?: WhereNode
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
```
|
|
634
|
+
Both `select()` and `paginate()` support extending `queryParamsSchema` with additional fields:
|
|
493
635
|
|
|
494
|
-
|
|
636
|
+
```ts
|
|
637
|
+
// paginate()
|
|
638
|
+
const parsed = queryParamsSchema({
|
|
639
|
+
search: z.string().optional(),
|
|
640
|
+
locale: z.enum(["en", "fr"]).default("en"),
|
|
641
|
+
}).parse({ limit: "10", search: "alice", locale: "fr" });
|
|
642
|
+
// parsed.pagination → { type: "LIMIT_OFFSET", limit: 10, … }
|
|
643
|
+
// parsed.search → "alice"
|
|
644
|
+
// parsed.locale → "fr"
|
|
495
645
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
646
|
+
// select()
|
|
647
|
+
const parsed = queryParamsSchema({ search: z.string().optional() }).parse({
|
|
648
|
+
select: "id,name",
|
|
649
|
+
search: "widget",
|
|
650
|
+
});
|
|
651
|
+
// parsed.select → ["id", "name"]
|
|
652
|
+
// parsed.search → "widget"
|
|
653
|
+
```
|
|
500
654
|
|
|
501
|
-
|
|
655
|
+
Extra fields are validated together — errors from both sides are collected in a single `ZodError`.
|
|
502
656
|
|
|
503
657
|
## End-to-end examples
|
|
504
658
|
|
|
505
|
-
###
|
|
506
|
-
|
|
507
|
-
HTTP query:
|
|
659
|
+
### LIMIT/OFFSET
|
|
508
660
|
|
|
509
661
|
```txt
|
|
510
662
|
?limit=20&page=1&select=id,status,createdAt&sortBy=createdAt:DESC&filter.status=$ilike:act&filter.id=$gt:10
|
|
511
663
|
```
|
|
512
664
|
|
|
513
|
-
Parsing:
|
|
514
|
-
|
|
515
665
|
```ts
|
|
516
666
|
const parsed = queryParamsSchema().parse({
|
|
517
667
|
limit: "20",
|
|
@@ -529,66 +679,26 @@ const parsed = queryParamsSchema().parse({
|
|
|
529
679
|
// page: 1,
|
|
530
680
|
// select: ["id", "status", "createdAt"],
|
|
531
681
|
// sortBy: [{ property: "createdAt", direction: "DESC" }],
|
|
532
|
-
// filters: { type: "and", items: [...] }
|
|
682
|
+
// filters: { type: "and", items: [...] }
|
|
533
683
|
// }
|
|
534
684
|
```
|
|
535
685
|
|
|
536
|
-
###
|
|
537
|
-
|
|
538
|
-
Config:
|
|
686
|
+
### CURSOR with coercion
|
|
539
687
|
|
|
540
688
|
```ts
|
|
541
689
|
const { queryParamsSchema } = paginate({
|
|
542
690
|
paginationType: "CURSOR",
|
|
543
691
|
dataSchema: ModelSchema,
|
|
544
|
-
cursorProperty: "id", //
|
|
692
|
+
cursorProperty: "id", // z.number() → cursor is coerced to number
|
|
545
693
|
selectable: ["id", "status", "createdAt"],
|
|
546
694
|
defaultSelect: ["id", "createdAt"],
|
|
547
695
|
});
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
Parsing:
|
|
551
696
|
|
|
552
|
-
```ts
|
|
553
697
|
const parsed = queryParamsSchema().parse({ cursor: "123", limit: "10" });
|
|
554
|
-
|
|
555
|
-
// parsed.pagination
|
|
556
|
-
// {
|
|
557
|
-
// type: "CURSOR",
|
|
558
|
-
// limit: 10,
|
|
559
|
-
// cursor: 123, // <- coerced from "123" because cursorProperty is a number
|
|
560
|
-
// cursorProperty: "id",
|
|
561
|
-
// select: ["id", "createdAt"]
|
|
562
|
-
// }
|
|
698
|
+
// parsed.pagination.cursor → 123 (coerced from "123")
|
|
563
699
|
```
|
|
564
700
|
|
|
565
|
-
###
|
|
566
|
-
|
|
567
|
-
Goal: `(status == active OR status == postponed) AND (id > 10)`
|
|
568
|
-
|
|
569
|
-
```ts
|
|
570
|
-
const parsed = queryParamsSchema().parse({
|
|
571
|
-
"filter.status": ["$g:1:$eq:active", "$g:1:$or:$eq:postponed"],
|
|
572
|
-
"filter.id": "$g:2:$gt:10",
|
|
573
|
-
|
|
574
|
-
"group.1.parent": "0",
|
|
575
|
-
"group.2.parent": "0",
|
|
576
|
-
"group.2.join": "$and",
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
// parsed.pagination.filters
|
|
580
|
-
// {
|
|
581
|
-
// type: "and",
|
|
582
|
-
// items: [
|
|
583
|
-
// { type: "or", items: [ ...status filters... ] },
|
|
584
|
-
// { type: "filter", field: "id", condition: { op: "$gt", value: 10, ... } }
|
|
585
|
-
// ]
|
|
586
|
-
// }
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
### Example 4 — validating your response
|
|
590
|
-
|
|
591
|
-
Using the pre-built `responseSchema` (based on `defaultSelect`):
|
|
701
|
+
### Validating a response
|
|
592
702
|
|
|
593
703
|
```ts
|
|
594
704
|
const { responseSchema } = paginate({
|
|
@@ -600,41 +710,45 @@ const { responseSchema } = paginate({
|
|
|
600
710
|
maxLimit: 100,
|
|
601
711
|
});
|
|
602
712
|
|
|
603
|
-
// Validate without parsing a request first
|
|
604
713
|
responseSchema.parse({
|
|
605
714
|
data: [{ id: 1, status: "active", createdAt: new Date(), meta: { score: 42 } }],
|
|
606
715
|
pagination: { itemsPerPage: 20, totalItems: 1, currentPage: 1, totalPages: 1 },
|
|
607
716
|
});
|
|
608
717
|
|
|
609
|
-
// Type-safe: z.infer narrows data keys to selectable paths
|
|
610
718
|
type Response = z.infer<typeof responseSchema>;
|
|
611
719
|
// Response["data"][0] → { id?: unknown; status?: unknown; createdAt?: unknown; meta?: unknown }
|
|
612
|
-
// Response["pagination"] →
|
|
613
|
-
// Response["pagination"].totalItems → number ✓ (no manual narrowing needed)
|
|
720
|
+
// Response["pagination"].totalItems → number ✓
|
|
614
721
|
```
|
|
615
722
|
|
|
616
|
-
|
|
723
|
+
## TypeScript reference
|
|
724
|
+
|
|
725
|
+
<details>
|
|
726
|
+
<summary><code>paginate()</code> overloads</summary>
|
|
617
727
|
|
|
618
728
|
```ts
|
|
619
|
-
|
|
620
|
-
|
|
729
|
+
// Overload 1 — LIMIT_OFFSET
|
|
730
|
+
export function paginate<
|
|
731
|
+
TSchema extends DataSchema,
|
|
732
|
+
const TSelectable extends readonly AllowedPath<TSchema>[],
|
|
733
|
+
>(
|
|
734
|
+
config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & { paginationType: "LIMIT_OFFSET" },
|
|
735
|
+
): PaginateResult<TSchema, TSelectable[number], "LIMIT_OFFSET">;
|
|
621
736
|
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
737
|
+
// Overload 2 — CURSOR
|
|
738
|
+
export function paginate<
|
|
739
|
+
TSchema extends DataSchema,
|
|
740
|
+
const TSelectable extends readonly AllowedPath<TSchema>[],
|
|
741
|
+
>(
|
|
742
|
+
config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & CursorPaginationConfig<…>,
|
|
743
|
+
): PaginateResult<TSchema, TSelectable[number], "CURSOR">;
|
|
627
744
|
```
|
|
628
745
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
If you only need **field projection** without pagination, sorting, or filters, you can use the `select()` utility directly.
|
|
746
|
+
</details>
|
|
632
747
|
|
|
633
|
-
|
|
748
|
+
<details>
|
|
749
|
+
<summary><code>select()</code> signature</summary>
|
|
634
750
|
|
|
635
751
|
```ts
|
|
636
|
-
import { select } from "zod-paginate";
|
|
637
|
-
|
|
638
752
|
export function select<
|
|
639
753
|
TSchema extends DataSchema,
|
|
640
754
|
const TSelectable extends readonly AllowedPath<TSchema>[],
|
|
@@ -643,76 +757,22 @@ export function select<
|
|
|
643
757
|
): SelectResult<TSchema, TSelectable[number]>;
|
|
644
758
|
```
|
|
645
759
|
|
|
646
|
-
|
|
760
|
+
</details>
|
|
647
761
|
|
|
648
|
-
|
|
649
|
-
- `validatorSchema(parsed?)`: function returning a Zod schema expecting `{ data: Array<ProjectedItem> }`.
|
|
650
|
-
- `responseSchema`: pre-built `ZodObject` for validating responses using `defaultSelect` (or all selectable fields). `z.infer<typeof responseSchema>` narrows data keys to the configured `selectable` paths.
|
|
762
|
+
### Exported types
|
|
651
763
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
764
|
+
| Type | Description |
|
|
765
|
+
|---|---|
|
|
766
|
+
| `DataSchema` | `z.ZodObject \| z.ZodDiscriminatedUnion \| z.ZodUnion` |
|
|
767
|
+
| `AllowedPath<TSchema>` | All valid dot-notation paths for a given schema |
|
|
768
|
+
| `SelectConfig<TSchema, TSelectable>` | Configuration for `select()` |
|
|
769
|
+
| `SelectResult<TSchema, TSelectable>` | Return type of `select()` |
|
|
770
|
+
| `PaginateResult<TSchema, TSelectable?, TType?>` | Return type of `paginate()` |
|
|
656
771
|
|
|
657
|
-
|
|
658
|
-
return select({ dataSchema: ProductSchema, selectable: ["id", "name", "price"], /* … */ });
|
|
659
|
-
}
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
#### `SelectConfig`
|
|
663
|
-
|
|
664
|
-
| Option | Type | Description |
|
|
665
|
-
|---|---:|---|
|
|
666
|
-
| `dataSchema` | `z.ZodObject` | Zod schema representing one data item. |
|
|
667
|
-
| `selectable` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths supported). |
|
|
668
|
-
| `defaultSelect` | `field[] \| "*"` | **Required.** Default select if `select` is missing. `"*"` expands to `selectable`. |
|
|
669
|
-
|
|
670
|
-
#### Example
|
|
671
|
-
|
|
672
|
-
```ts
|
|
673
|
-
import { z } from "zod";
|
|
674
|
-
import { select } from "zod-paginate";
|
|
675
|
-
|
|
676
|
-
const ProductSchema = z.object({
|
|
677
|
-
id: z.number(),
|
|
678
|
-
name: z.string(),
|
|
679
|
-
price: z.number(),
|
|
680
|
-
details: z.object({
|
|
681
|
-
weight: z.number(),
|
|
682
|
-
color: z.string(),
|
|
683
|
-
}),
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
const { queryParamsSchema, validatorSchema, responseSchema } = select({
|
|
687
|
-
dataSchema: ProductSchema,
|
|
688
|
-
selectable: ["id", "name", "price", "details.weight", "details.color"],
|
|
689
|
-
defaultSelect: ["id", "name", "price"],
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
// select=* expands to all selectable fields
|
|
693
|
-
const parsed = queryParamsSchema().parse({ select: "*" });
|
|
694
|
-
// parsed.select → ["id", "name", "price", "details.weight", "details.color"]
|
|
695
|
-
|
|
696
|
-
// With specific fields
|
|
697
|
-
const parsed2 = queryParamsSchema().parse({ select: "id,name,details.color" });
|
|
698
|
-
// parsed2.select → ["id", "name", "details.color"]
|
|
699
|
-
|
|
700
|
-
// Pre-built response validator (based on defaultSelect)
|
|
701
|
-
responseSchema.parse({
|
|
702
|
-
data: [{ id: 1, name: "Widget", price: 9.99 }],
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
// Or context-aware validator from parsed request
|
|
706
|
-
const contextSchema = validatorSchema(parsed2);
|
|
707
|
-
contextSchema.parse({
|
|
708
|
-
data: [
|
|
709
|
-
{ id: 1, name: "Widget", details: { color: "red" } },
|
|
710
|
-
{ id: 2, name: "Gadget", details: { color: "blue" } },
|
|
711
|
-
],
|
|
712
|
-
});
|
|
772
|
+
## Adapters
|
|
713
773
|
|
|
714
|
-
|
|
715
|
-
const parsed3 = queryParamsSchema().parse({});
|
|
716
|
-
// parsed3.select → ["id", "name", "price"]
|
|
717
|
-
```
|
|
774
|
+
`zod-paginate` is ORM/query-builder agnostic by design. **Adapters** bridge the gap between the parsed output and your data layer.
|
|
718
775
|
|
|
776
|
+
| Adapter | Description | Link |
|
|
777
|
+
|---|---|---|
|
|
778
|
+
| **zod-paginate-drizzle** | Drizzle ORM adapter — maps parsed pagination, filters, sorting, and select to Drizzle queries. | [GitHub](https://github.com/nolway/zod-paginate-drizzle) |
|