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 CHANGED
@@ -1,21 +1,26 @@
1
1
  # zod-paginate
2
2
 
3
+ ![Coverage](https://img.shields.io/badge/coverage-94.26%25-brightgreen)
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
- // Pre-built response validator (uses defaultSelect)
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
- // Or build a context-aware validator from the parsed request
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
- ## Adapters
127
+ ## Summary
86
128
 
87
- `zod-paginate` is ORM/query-builder agnostic by design — it parses and validates query parameters but does not generate database queries. **Adapters** bridge the gap between the parsed output and your data layer.
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
- | Adapter | Description | Link |
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
- ## API
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
- ### `paginate(config)`
152
+ ```ts
153
+ import { select } from "zod-paginate";
154
+ ```
96
155
 
97
156
  Returns:
98
157
 
99
- - `queryParamsSchema(extraShape?)`: function returning a Zod schema to parse query objects (strings / string arrays). Call without arguments for the base schema, or pass an extra Zod shape to extend it with additional fields (see [Extending `queryParamsSchema`](#extending-queryparamsschema)).
100
- - `validatorSchema(parsed?)`: function returning a Zod schema to validate the response payload, projected based on the parsed `select`.
101
- - `responseSchema`: pre-built `ZodObject` for validating responses using `defaultSelect` (or all selectable fields). Equivalent to calling `validatorSchema()` with no arguments. Being a `ZodObject`, it exposes `.shape`, `.partial()`, etc. and `z.infer<typeof responseSchema>` correctly narrows both `data` keys and pagination metadata.
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
- // Overload 1 LIMIT_OFFSET
105
- export function paginate<
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
- // Overload 2 — CURSOR
113
- export function paginate<
114
- TSchema extends DataSchema,
115
- const TSelectable extends readonly AllowedPath<TSchema>[],
116
- >(
117
- config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & CursorPaginationConfig<…>,
118
- ): PaginateResult<TSchema, TSelectable[number], "CURSOR">;
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
- #### `PaginateResult<TSchema, TSelectable?, TType?>`
203
+ // Generic response schema (based on defaultSelect)
204
+ responseSchema.parse({
205
+ data: [{ id: 1, name: "Widget", price: 9.99 }],
206
+ });
122
207
 
123
- 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.
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
- - **`TType`** (`'LIMIT_OFFSET' | 'CURSOR'`): When specified, narrows the response/payload types so you get `totalItems`/`totalPages` (LIMIT_OFFSET) or `cursor` (CURSOR) without manual narrowing. Defaults to the union if omitted.
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 { paginate, type PaginateResult } from "zod-paginate";
225
+ import { select, type SelectResult } from "zod-paginate";
129
226
 
130
- // TSelectable defaults to all paths if omitted, TType defaults to union
131
- function createPaginator(): PaginateResult<typeof ModelSchema, "id" | "status", "LIMIT_OFFSET"> {
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
- // Without TType — pagination is still a union, but data keys are narrowed
141
- function createPaginatorUnion(): PaginateResult<typeof ModelSchema, "id" | "status"> {
142
- return paginate({ dataSchema: ModelSchema, selectable: ["id", "status"], /* */ });
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
- ## Configuration (`paginate({...})`)
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"` | Select pagination mode. |
151
- | `dataSchema` | `z.ZodObject` | Zod schema representing one **data item** returned by your API (used for projection + cursor inference). |
152
- | `selectable?` | `string[]` (typed paths) | Allowlist of selectable fields (dot paths supported). Enables `select`. |
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 | The field used for cursor paging. Cursor type is inferred from `dataSchema` at that path and the query input cursor is coerced accordingly. |
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
- // With extra — additional fields alongside pagination
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
- Extra fields are validated together with paginationerrors from both sides are collected in a single `ZodError`.
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
- This also works with `select()`:
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
- const { queryParamsSchema } = select({ });
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
- `queryParamsSchema()` accepts any record-like input:
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
- ```ts
199
- Record<string, unknown>
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: string numeric (e.g. `"10"`)
213
- - Output: number
214
- - Rules
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: string numeric (e.g. `"2"`)
222
- - Output: number
223
- - Rules
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: string (querystring input is always string)
230
- - Output: `number | string` (coerced)
231
- - Rules
232
- - Only valid when `paginationType: "CURSOR"`
233
- - Forbidden in LIMIT_OFFSET mode
234
- - If provided, it is coerced based on the Zod type of `cursorProperty` in `dataSchema`:
235
- - `z.number()` field `"123"` becomes `123` (integer-only)
236
- - `z.string()` field `"abc"` stays `"abc"`
237
- - `z.date()` field → must be ISO date or ISO datetime, stays a string (`"2022-01-01"` or `"2022-01-01T12:00:00Z"`)
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: string or string[]
242
- - Output: `[{ property, direction }]`
243
- - Rules
244
- - Requires `sortable` in config
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: string
253
- - Output: string[] (typed paths)
254
- - Rules
255
- - Requires `selectable` in config
256
- - string is split by `,`, trimmed, empty items removed
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
- ## Filters
349
+ ## Response validation
263
350
 
264
- Filters are passed as query keys with this pattern:
351
+ Both `select()` and `paginate()` return tools to validate your API response.
265
352
 
266
- ```txt
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
- You configure which fields are filterable and which operators are allowed via `filterable`.
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
- ### Operators
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
- | Operator | Meaning | Value format |
277
- |---|---|---|
278
- | `$eq` | equals | number / string / ISO date depending on field type |
279
- | `$null` | is null | no value |
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
- ```txt
295
- filter.status=$eq:active
296
- filter.id=$eq:42
297
- filter.createdAt=$eq:2025-01-15
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
- #### `$null` — is null
378
+ ### `validatorSchema(parsed?)`outgoing data validator
301
379
 
302
- Matches rows where the field is `NULL`. No value is required after the operator.
380
+ Validates outgoing data projected to the actual `select` requested by the client:
303
381
 
304
- ```txt
305
- filter.deletedAt=$null
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
- To match rows where the field is **not** null, combine with `$not`:
392
+ ### Expected response shape
309
393
 
310
- ```txt
311
- filter.deletedAt=$not:$null
312
- ```
394
+ **LIMIT/OFFSET:**
313
395
 
314
- #### `$in` — in list
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
- Matches rows where the field value is one of the provided comma-separated values.
410
+ **CURSOR:**
317
411
 
318
- ```txt
319
- filter.status=$in:active,pending,review
320
- filter.id=$in:1,2,3,10
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
- #### `$contains` — contains values
424
+ ## Filters
324
425
 
325
- Matches rows where the field (typically an array column) contains all the provided comma-separated values.
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
- ```txt
328
- filter.tags=$contains:typescript,zod
329
- filter.roles=$contains:admin
330
- ```
428
+ ### Operators
331
429
 
332
- #### `$gt` / `$gte` / `$lt` / `$lte` comparisons
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
- Standard comparison operators: greater than, greater than or equal, less than, less than or equal. Works with numbers and ISO dates.
446
+ ### Negation: `$not`
447
+
448
+ Prefix any operator with `$not:` to negate the condition:
335
449
 
336
450
  ```txt
337
- filter.id=$gt:100
338
- filter.id=$lte:500
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
- Combine multiple comparisons to build ranges:
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
- #### `$btw` — between
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
- #### `$ilike` case-insensitive contains
467
+ Runtime validation enforces: field allowlist (`filterable`), operator allowlist per field (`ops`), and value type compatibility.
360
468
 
361
- Matches rows where the string field contains the given substring, ignoring case. Useful for search-style filtering.
469
+ ## Filter groups
362
470
 
363
- ```txt
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
- #### `$sw` starts with
473
+ ### Assigning conditions to a group: `$g:<id>`
370
474
 
371
- Matches rows where the string field starts with the given prefix.
475
+ Prefix any filter DSL with `$g:<groupId>:`:
372
476
 
373
477
  ```txt
374
- filter.name=$sw:Jon
375
- filter.email=$sw:admin@
376
- filter.path=$sw:/api/v2
478
+ filter.status=$g:1:$eq:active
377
479
  ```
378
480
 
379
- Runtime validation enforces:
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
- If the filter does **not** start with `$`, it is interpreted as `$eq:<value>`.
483
+ ### Group tree definitions: `group.<id>.*`
388
484
 
389
- ### Negation: `$not`
485
+ Define parent-child relationships between groups:
390
486
 
391
- Prefix any operator with `$not:` to negate the condition.
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
- Examples:
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
- ```txt
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
- ### Multiple conditions for the same field
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
- Use repeated query params:
500
+ "group.1.parent": "0",
501
+ "group.2.parent": "0",
502
+ "group.2.join": "$and",
503
+ });
403
504
 
404
- ```txt
405
- filter.id=$gt:10&filter.id=$lt:100
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
- Or in object form:
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
- "filter.id": ["$gt:10", "$lt:100"]
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
- ## Groups
531
+ ### Compile-time enforcement on `selectable`
417
532
 
418
- Groups let you build nested AND/OR boolean logic.
533
+ The discriminator field **must** be present in `selectable`. Omitting it is a TypeScript error:
419
534
 
420
- There are two layers:
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
- 1) Combine multiple conditions inside the same group
423
- 2) Build a group tree (attach groups as children of other groups)
543
+ // OK "type" is included
544
+ select({
545
+ dataSchema: MediaSchema,
546
+ selectable: ["id", "name", "type"],
547
+ defaultSelect: "*",
548
+ });
549
+ ```
424
550
 
425
- ### Put a condition into a group: `$g:<id>`
551
+ The same applies to `paginate()`:
426
552
 
427
- Prefix any filter DSL with:
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
- ```txt
430
- $g:<groupId>:
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
- ### Combine conditions inside a group: `$and` / `$or`
575
+ ### Runtime rejection of explicit `select` without discriminator
434
576
 
435
- Within a group, the **first** condition cannot have `$and`/`$or`. All following conditions may be prefixed with `$and` or `$or`.
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
- ### Group tree definitions: `group.<id>.*`
579
+ ```ts
580
+ const { queryParamsSchema } = select({
581
+ dataSchema: MediaSchema,
582
+ selectable: ["id", "type", "duration", "bitrate"],
583
+ defaultSelect: ["id", "type"],
584
+ });
438
585
 
439
- To nest groups, define these query keys:
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
- - `group.<id>.parent`parent group id (integer string)
442
- - `group.<id>.join` how this group is joined to its parent (`$and` or `$or`)
443
- - `group.<id>.op` default join used when combining this group's children (optional)
590
+ // select=* always works expands to all selectable fields including the discriminator
591
+ queryParamsSchema().parse({ select: "*" });
592
+ // → ["id", "type", "duration", "bitrate"]
444
593
 
445
- Rules:
594
+ // ✓ Including the discriminator explicitly
595
+ queryParamsSchema().parse({ select: "id,type,duration" });
596
+ // → ["id", "type", "duration"]
597
+ ```
446
598
 
447
- - Root group id is always `"0"`.
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
- ## Validating responses with `validatorSchema()`
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
- `validatorSchema(parsed)` returns a Zod schema you can use to validate your API response.
603
+ ```ts
604
+ const { queryParamsSchema, validatorSchema } = select({
605
+ dataSchema: MediaSchema,
606
+ selectable: ["id", "type", "duration", "bitrate"],
607
+ defaultSelect: "*",
608
+ });
455
609
 
456
- What it does:
610
+ const parsed = queryParamsSchema().parse({ select: "id,type,duration,bitrate" });
611
+ const schema = validatorSchema(parsed);
457
612
 
458
- - Uses the effective `select` (explicit `select`, else `defaultSelect`, else full schema) to project the item schema.
459
- - Validates cursor type (CURSOR mode) based on `cursorProperty`.
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
- ### What `validatorSchema(parsed)` expects
616
+ // Audio item — matches AudioSchema option
617
+ schema.parse({ data: [{ id: 2, type: "audio", bitrate: 320 }] });
463
618
 
464
- **LIMIT/OFFSET mode**:
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
- ```ts
467
- {
468
- data: Array<ProjectedItem>,
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
- **CURSOR mode**:
632
+ ## Extending `queryParamsSchema`
481
633
 
482
- ```ts
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
- Notes:
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
- - `ProjectedItem` is computed from `dataSchema` + the effective `select`.
497
- - If `cursorProperty` points to a `z.number()` field, `pagination.cursor` must be a number.
498
- - If `cursorProperty` points to a `z.string()` field, `pagination.cursor` must be a string.
499
- - If `cursorProperty` points to a `z.date()` field, this library accepts an ISO string or a `Date` (depending on implementation).
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
- You can call `validatorSchema()` without arguments to build a validator based on defaults (`defaultSelect`, `cursorProperty`, etc.).
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
- ### Example 1 — LIMIT/OFFSET
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: [...] } // WhereNode AST
682
+ // filters: { type: "and", items: [...] }
533
683
  // }
534
684
  ```
535
685
 
536
- ### Example 2 — CURSOR + coercion
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", // id is z.number()
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
- ### Example 3 — groups
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"] → LimitOffsetPaginationResponseMeta (not a union!)
613
- // Response["pagination"].totalItems → number ✓ (no manual narrowing needed)
720
+ // Response["pagination"].totalItemsnumber ✓
614
721
  ```
615
722
 
616
- Or using `validatorSchema(parsed)` for request-aware projection:
723
+ ## TypeScript reference
724
+
725
+ <details>
726
+ <summary><code>paginate()</code> overloads</summary>
617
727
 
618
728
  ```ts
619
- const parsed = queryParamsSchema().parse({ select: "id,status", limit: "10", page: "1" });
620
- const contextSchema = validatorSchema(parsed.pagination);
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
- // contextSchema expects data items shaped like { id, status } only
623
- contextSchema.parse({
624
- data: [{ id: 1, status: "active" }],
625
- pagination: { itemsPerPage: 10, totalItems: 1, currentPage: 1, totalPages: 1 },
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
- ## `select()` — standalone field projection
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
- ### API
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
- Returns:
760
+ </details>
647
761
 
648
- - `queryParamsSchema(extraShape?)`: function returning a Zod schema to parse `{ select: "id,name" }` into `{ select: ["id", "name"] }`. Call without arguments for the base schema, or pass an extra Zod shape to extend it (see [Extending `queryParamsSchema`](#extending-queryparamsschema)).
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
- Use `SelectResult<TSchema, TSelectable>` instead of `ReturnType<typeof select>` for explicit return types:
653
-
654
- ```ts
655
- import { select, type SelectResult } from "zod-paginate";
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
- function createSelector(): SelectResult<typeof ProductSchema, "id" | "name" | "price"> {
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
- // Missing select uses defaultSelect
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) |