zod-paginate 1.6.0 → 1.7.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
@@ -52,7 +52,7 @@ const { queryParamsSchema, validatorSchema, responseSchema } = select({
52
52
  });
53
53
 
54
54
  const parsed = queryParamsSchema().parse({ select: "id,name,price" });
55
- // parsed.select → ["id", "name", "price"]
55
+ // parsed.select.fields → ["id", "name", "price"]
56
56
 
57
57
  // Generic response schema — valid for all possible responses based on config
58
58
  responseSchema.parse({
@@ -194,11 +194,11 @@ const { queryParamsSchema, validatorSchema, responseSchema } = select({
194
194
 
195
195
  // select=* expands to all selectable fields
196
196
  const parsed = queryParamsSchema().parse({ select: "*" });
197
- // parsed.select → ["id", "name", "price", "details.weight", "details.color"]
197
+ // parsed.select.fields → ["id", "name", "price", "details.weight", "details.color"]
198
198
 
199
199
  // Specific fields
200
200
  const parsed2 = queryParamsSchema().parse({ select: "id,name,details.color" });
201
- // parsed2.select → ["id", "name", "details.color"]
201
+ // parsed2.select.fields → ["id", "name", "details.color"]
202
202
 
203
203
  // Generic response schema (based on defaultSelect)
204
204
  responseSchema.parse({
@@ -216,7 +216,7 @@ contextSchema.parse({
216
216
 
217
217
  // Missing select → uses defaultSelect
218
218
  const parsed3 = queryParamsSchema().parse({});
219
- // parsed3.select → ["id", "name", "price"]
219
+ // parsed3.select.fields → ["id", "name", "price"]
220
220
  ```
221
221
 
222
222
  Use `SelectResult<TSchema, TSelectable>` instead of `ReturnType<typeof select>` for explicit return types:
@@ -270,7 +270,7 @@ Returns:
270
270
  |---|---:|---|
271
271
  | `paginationType` | `"LIMIT_OFFSET"` \| `"CURSOR"` | Pagination mode. |
272
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`. |
273
+ | `selectable` | `string[]` (typed paths) | **Required.** Allowlist of selectable fields (dot paths). Enables `select`. |
274
274
  | `sortable?` | `string[]` (typed paths) | Allowlist of sortable fields. Enables `sortBy`. |
275
275
  | `filterable?` | object | Allowlist of filterable fields and allowed operators + field type. |
276
276
  | `defaultSortBy?` | `{ property, direction }[]` | Default sort if `sortBy` missing/empty. |
@@ -335,7 +335,7 @@ function createPaginatorUnion(): PaginateResult<typeof ModelSchema, "id" | "stat
335
335
 
336
336
  - **Input:** string or string[] — format: `field:ASC` or `field:DESC`
337
337
  - **Output:** `[{ property, direction }]`
338
- - Properties are matched against the `sortable` allowlist (unknown fields are dropped).
338
+ - Properties are matched against the `sortable` allowlist (unknown fields are **rejected**).
339
339
  - Falls back to `defaultSortBy` when missing.
340
340
 
341
341
  ### `select`
@@ -371,7 +371,7 @@ responseSchema.parse({
371
371
 
372
372
  // Type-safe: z.infer narrows data keys to selectable paths
373
373
  type Response = z.infer<typeof responseSchema>;
374
- // Response["data"][0] → { id?: unknown; status?: unknown; createdAt?: unknown; meta?: unknown }
374
+ // Response["data"][0] → { id?: number; status?: string; createdAt?: Date; meta?: { score: number } }
375
375
  // Response["pagination"].totalItems → number ✓ (no manual narrowing)
376
376
  ```
377
377
 
@@ -648,7 +648,7 @@ const parsed = queryParamsSchema({ search: z.string().optional() }).parse({
648
648
  select: "id,name",
649
649
  search: "widget",
650
650
  });
651
- // parsed.select → ["id", "name"]
651
+ // parsed.select.fields → ["id", "name"]
652
652
  // parsed.search → "widget"
653
653
  ```
654
654
 
@@ -716,7 +716,7 @@ responseSchema.parse({
716
716
  });
717
717
 
718
718
  type Response = z.infer<typeof responseSchema>;
719
- // Response["data"][0] → { id?: unknown; status?: unknown; createdAt?: unknown; meta?: unknown }
719
+ // Response["data"][0] → { id?: number; status?: string; createdAt?: Date; meta?: { score: number } }
720
720
  // Response["pagination"].totalItems → number ✓
721
721
  ```
722
722
 
@@ -767,6 +767,9 @@ export function select<
767
767
  | `AllowedPath<TSchema>` | All valid dot-notation paths for a given schema |
768
768
  | `SelectConfig<TSchema, TSelectable>` | Configuration for `select()` |
769
769
  | `SelectResult<TSchema, TSelectable>` | Return type of `select()` |
770
+ | `SelectQueryPayload<TSchema, TSelectable>` | Parsed output of `select()` — `{ select: { fields, responseType? } }` |
771
+ | `TypedProjectedData<TSchema, TSelect>` | Projected data item with real value types (used in response types) |
772
+ | `ProjectedData<TSchema, TSelect>` | Projected data item with `unknown` values (key autocompletion only) |
770
773
  | `PaginateResult<TSchema, TSelectable?, TType?>` | Return type of `paginate()` |
771
774
 
772
775
  ## Adapters
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod';
2
- import { type AllowedPath, type DataSchema, type EnsureDiscriminatorInSelectable, type InferData, type Path, type PathValue, type ProjectedData, type ZodShape } from './select';
2
+ import { type AllowedPath, type DataSchema, type EnsureDiscriminatorInSelectable, type InferData, type Path, type PathValue, type TypedProjectedData, type ZodShape } from './select';
3
3
  interface LimitOffsetPaginationConfig {
4
+ /** Pagination mode: classic limit/offset with page numbers. */
4
5
  paginationType: 'LIMIT_OFFSET';
5
6
  }
6
7
  interface CursorPaginationConfig<T> {
8
+ /** Pagination mode: cursor-based (keyset). */
7
9
  paginationType: 'CURSOR';
10
+ /** Field used as cursor. Its Zod type determines cursor coercion (number, string, or ISO date). */
8
11
  cursorProperty: Path<T>;
9
12
  }
10
13
  export declare const SortDirectionSchema: z.ZodEnum<{
@@ -154,19 +157,28 @@ interface FilterableFieldConfig<TKind extends FieldType> {
154
157
  type: TKind;
155
158
  ops: readonly OpsForFieldType<TKind>[];
156
159
  }
160
+ /** Configuration shared by all pagination modes. */
157
161
  export interface CommonQueryConfigFromSchema<TSchema extends DataSchema, TSelectable extends AllowedPath<TSchema> = AllowedPath<TSchema>> {
162
+ /** Zod schema representing one data item (object, discriminated union, or union). */
158
163
  dataSchema: TSchema;
159
- selectable?: readonly TSelectable[];
164
+ /** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
165
+ selectable: readonly TSelectable[];
166
+ /** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
160
167
  sortable?: readonly AllowedPath<TSchema>[];
168
+ /** Map of filterable fields to their allowed type and operators. Enables the `filter.*` query parameters. */
161
169
  filterable?: Partial<{
162
170
  [P in AllowedPath<TSchema>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
163
171
  }>;
172
+ /** Default sort order applied when `sortBy` is omitted from the query. */
164
173
  defaultSortBy?: readonly {
165
174
  property: AllowedPath<TSchema>;
166
175
  direction: SortDirection;
167
176
  }[];
177
+ /** Default number of items per page when `limit` is omitted. */
168
178
  defaultLimit: number;
179
+ /** Default fields returned when `select` is omitted. Use `"*"` to select all. */
169
180
  defaultSelect: readonly TSelectable[] | '*';
181
+ /** Maximum allowed value for `limit`. Requests exceeding this are rejected. */
170
182
  maxLimit: number;
171
183
  }
172
184
  /**
@@ -224,11 +236,11 @@ export interface CursorPaginationResponseMeta {
224
236
  filter?: WhereNode;
225
237
  }
226
238
  export interface LimitOffsetPaginationResponse<TSchema extends DataSchema, TSelect extends AllowedPath<TSchema> = AllowedPath<TSchema>> {
227
- data: ProjectedData<TSchema, TSelect>[];
239
+ data: TypedProjectedData<TSchema, TSelect>[];
228
240
  pagination: LimitOffsetPaginationResponseMeta;
229
241
  }
230
242
  export interface CursorPaginationResponse<TSchema extends DataSchema, TSelect extends AllowedPath<TSchema> = AllowedPath<TSchema>> {
231
- data: ProjectedData<TSchema, TSelect>[];
243
+ data: TypedProjectedData<TSchema, TSelect>[];
232
244
  pagination: CursorPaginationResponseMeta;
233
245
  }
234
246
  export type PaginationResponse<TSchema extends DataSchema, TSelect extends AllowedPath<TSchema> = AllowedPath<TSchema>, TType extends PaginationType = PaginationType> = TType extends 'LIMIT_OFFSET' ? LimitOffsetPaginationResponse<TSchema, TSelect> : TType extends 'CURSOR' ? CursorPaginationResponse<TSchema, TSelect> : never;
@@ -276,7 +288,7 @@ export interface PaginateResult<TSchema extends DataSchema, TType extends Pagina
276
288
  (): z.ZodType<PaginationQueryParams<TSchema, TType>>;
277
289
  <TExtraShape extends z.ZodRawShape>(extraShape: TExtraShape): z.ZodType<PaginationQueryParams<TSchema, TType> & z.infer<z.ZodObject<TExtraShape>>>;
278
290
  };
279
- validatorSchema: (parsed?: PaginationPayload<TSchema>) => z.ZodType;
291
+ validatorSchema: (parsed?: PaginationPayload<TSchema>) => z.ZodType<PaginationResponse<TSchema, AllowedPath<TSchema>, TType>>;
280
292
  responseSchema: z.ZodObject<PaginationResponseSchemaShape<TType>>;
281
293
  }
282
294
  /**
@@ -288,37 +300,52 @@ export interface PaginateResult<TSchema extends DataSchema, TType extends Pagina
288
300
  * - `responseSchema`: A pre-built Zod schema for validating the response (uses defaultSelect or all selectable fields).
289
301
  */
290
302
  export declare function paginate<TSchema extends DataSchema, const TSelectable extends readonly AllowedPath<TSchema>[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & LimitOffsetPaginationConfig & {
291
- selectable?: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
303
+ /** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
304
+ selectable: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
305
+ /** Default fields returned when `select` is omitted. Use `"*"` to select all. */
292
306
  defaultSelect: readonly NoInfer<TSelectable[number]>[] | '*';
307
+ /** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
293
308
  sortable?: readonly NoInfer<TSelectable[number]>[];
309
+ /** Default sort order applied when `sortBy` is omitted from the query. */
294
310
  defaultSortBy?: readonly {
295
311
  property: NoInfer<TSelectable[number]>;
296
312
  direction: SortDirection;
297
313
  }[];
314
+ /** Map of filterable fields to their allowed type and operators. Enables the `filter.*` query parameters. */
298
315
  filterable?: Partial<{
299
316
  [P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
300
317
  }>;
301
318
  }): PaginateResult<TSchema, 'LIMIT_OFFSET'>;
302
319
  export declare function paginate<TSchema extends DataSchema, const TSelectable extends readonly AllowedPath<TSchema>[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & CursorPaginationConfig<InferData<TSchema>> & {
303
- selectable?: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
320
+ /** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
321
+ selectable: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
322
+ /** Default fields returned when `select` is omitted. Use `"*"` to select all. */
304
323
  defaultSelect: readonly NoInfer<TSelectable[number]>[] | '*';
324
+ /** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
305
325
  sortable?: readonly NoInfer<TSelectable[number]>[];
326
+ /** Default sort order applied when `sortBy` is omitted from the query. */
306
327
  defaultSortBy?: readonly {
307
328
  property: NoInfer<TSelectable[number]>;
308
329
  direction: SortDirection;
309
330
  }[];
331
+ /** Map of filterable fields to their allowed type and operators. Enables the `filter.*` query parameters. */
310
332
  filterable?: Partial<{
311
333
  [P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
312
334
  }>;
313
335
  }): PaginateResult<TSchema, 'CURSOR'>;
314
336
  export declare function paginate<TSchema extends DataSchema, const TSelectable extends readonly AllowedPath<TSchema>[]>(config: Omit<CommonQueryConfigFromSchema<TSchema, TSelectable[number]>, 'selectable' | 'defaultSelect' | 'sortable' | 'defaultSortBy' | 'filterable'> & {
315
- selectable?: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
337
+ /** Allowlist of selectable fields (dot-notation paths). Enables the `select` query parameter. */
338
+ selectable: EnsureDiscriminatorInSelectable<TSchema, TSelectable>;
339
+ /** Default fields returned when `select` is omitted. Use `"*"` to select all. */
316
340
  defaultSelect: readonly NoInfer<TSelectable[number]>[] | '*';
341
+ /** Allowlist of sortable fields. Enables the `sortBy` query parameter. Unknown sort fields are rejected. */
317
342
  sortable?: readonly NoInfer<TSelectable[number]>[];
343
+ /** Default sort order applied when `sortBy` is omitted from the query. */
318
344
  defaultSortBy?: readonly {
319
345
  property: NoInfer<TSelectable[number]>;
320
346
  direction: SortDirection;
321
347
  }[];
348
+ /** Map of filterable fields to their allowed type and operators. Enables the `filter.*` query parameters. */
322
349
  filterable?: Partial<{
323
350
  [P in NoInfer<TSelectable[number]>]: FilterableFieldConfig<FieldTypeFromValue<PathValue<InferData<TSchema>, P>>>;
324
351
  }>;
package/dist/paginate.js CHANGED
@@ -547,12 +547,16 @@ function computeSortBy(sortByRaw, config) {
547
547
  if (sortByRaw) {
548
548
  const cleaned = sortByRaw.map((s) => s.trim()).filter(Boolean);
549
549
  if (cleaned.length > 0) {
550
+ const seen = new Set();
550
551
  const out = [];
551
552
  for (const raw of cleaned) {
552
553
  const parsed = parseSortItem(raw);
553
554
  const picked = (0, select_1.pickFromAllowlist)(config.sortable, parsed.property);
554
555
  if (!picked)
555
556
  continue;
557
+ if (seen.has(picked))
558
+ continue;
559
+ seen.add(picked);
556
560
  out.push({ property: picked, direction: parsed.direction });
557
561
  }
558
562
  return out.length > 0 ? out : undefined;
@@ -719,7 +723,7 @@ function buildCursorResponseSchema(dataItemSchema, parts) {
719
723
  function paginate(config) {
720
724
  const discriminatorKey = (0, select_1.getDiscriminatorKey)(config.dataSchema);
721
725
  const nestedDiscriminators = (0, select_1.findNestedDiscriminators)(config.dataSchema);
722
- const selectableStrings = [...(config.selectable ?? [])];
726
+ const selectableStrings = [...config.selectable];
723
727
  const effectiveConfig = {
724
728
  selectable: selectableStrings,
725
729
  defaultSelect: config.defaultSelect === '*' ? '*' : Array.from(config.defaultSelect, String),
@@ -787,7 +791,7 @@ function paginate(config) {
787
791
  example: `${String(config.sortable[0])}:ASC`,
788
792
  });
789
793
  }
790
- if (config.selectable && config.selectable.length > 0) {
794
+ if (config.selectable.length > 0) {
791
795
  const defaultSelectDesc = config.defaultSelect === '*' ? '*' : [...config.defaultSelect].join(', ');
792
796
  rootShape.select = zod_1.z
793
797
  .string()
@@ -858,14 +862,6 @@ function paginate(config) {
858
862
  message: `limit must be <= ${config.maxLimit}`,
859
863
  });
860
864
  }
861
- // select forbidden if no selectable configured
862
- if (val.select && (!config.selectable || config.selectable.length === 0)) {
863
- ctx.addIssue({
864
- code: 'custom',
865
- path: ['select'],
866
- message: `select is not allowed (no selectable fields configured)`,
867
- });
868
- }
869
865
  // select allowlist + "*" expandability
870
866
  const selectForValidation = val.select ??
871
867
  (effectiveConfig.defaultSelect === '*' ? ['*'] : effectiveConfig.defaultSelect);
@@ -923,7 +919,6 @@ function paginate(config) {
923
919
  }
924
920
  }
925
921
  // sort allowlist
926
- const sortItems = computeSortBy(val.sortBy, config);
927
922
  if (val.sortBy) {
928
923
  if (!config.sortable || config.sortable.length === 0) {
929
924
  ctx.addIssue({
@@ -932,14 +927,16 @@ function paginate(config) {
932
927
  message: `sortBy is not allowed (no sortable fields configured)`,
933
928
  });
934
929
  }
935
- else if (sortItems) {
930
+ else {
931
+ const cleaned = val.sortBy.map((s) => s.trim()).filter(Boolean);
936
932
  let index = 0;
937
- for (const item of sortItems) {
938
- if (!allowedSortable.has(item.property)) {
933
+ for (const raw of cleaned) {
934
+ const parsed = parseSortItem(raw);
935
+ if (!allowedSortable.has(parsed.property)) {
939
936
  ctx.addIssue({
940
937
  code: 'custom',
941
938
  path: ['sortBy', index],
942
- message: `sort property "${item.property}" is not allowed`,
939
+ message: `sort property "${parsed.property}" is not allowed`,
943
940
  });
944
941
  }
945
942
  index += 1;
@@ -1059,7 +1056,7 @@ function paginate(config) {
1059
1056
  const LIMIT_OFFSET_META_DESC = 'Pagination metadata for limit/offset mode';
1060
1057
  const CURSOR_META_DESC = 'Pagination metadata for cursor mode';
1061
1058
  const CURSOR_VALUE_DESC = 'Cursor value pointing to the last item returned';
1062
- const validatorSchema = (parsed) => {
1059
+ function validatorSchema(parsed) {
1063
1060
  const effectiveSelect = parsed?.select ?? (0, select_1.computeSelect)(undefined, effectiveConfig) ?? undefined;
1064
1061
  const dataItemSchema = effectiveSelect && effectiveSelect.length > 0
1065
1062
  ? (0, select_1.projectDataSchemaPreservingUnion)(config.dataSchema, effectiveSelect.map(String))
@@ -1091,7 +1088,7 @@ function paginate(config) {
1091
1088
  })
1092
1089
  .meta({ description: CURSOR_META_DESC }),
1093
1090
  });
1094
- };
1091
+ }
1095
1092
  const partialDataItemSchema = selectableStrings.length > 0
1096
1093
  ? (0, select_1.projectDataSchemaPreservingUnion)(config.dataSchema, selectableStrings, { partial: true })
1097
1094
  : (0, select_1.resolveToZodObject)(config.dataSchema);